diff --git a/.changeset/vitest-client-inline-runtime.md b/.changeset/vitest-client-inline-runtime.md new file mode 100644 index 0000000..6245ff3 --- /dev/null +++ b/.changeset/vitest-client-inline-runtime.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +In the Vitest client posture (jsdom, happy-dom), the plugin now inlines `solid-js`, `@solidjs/web` and the packages that import them without a `solid` export condition, such as `@solidjs/testing-library`. Before, they stayed external, and the host runtime resolved their own imports. Node gets the `browser` condition from Vitest's worker `--conditions`, but Deno does not apply it in worker threads (the `threads` pool) and resolves `deno`/`node`. Under Deno, `@solidjs/testing-library` loaded the server build of `@solidjs/web` and `render` failed with `isServer` true (#369). Inlining sends these imports through Vite's `browser` condition and loads one client-build instance. A `test.server.deps.inline` list is merged with the plugin's, and a `test.server.deps.external` entry for `solid-js` still opts out. diff --git a/README.md b/README.md index 74508fc..537b1a0 100644 --- a/README.md +++ b/README.md @@ -1152,7 +1152,7 @@ For other breaking changes, check [the migration guide of vite](https://vitejs.d If you are using [vitest](https://vitest.dev/), this plugin already injects the necessary configuration for you. It even automatically detects if you have `@testing-library/jest-dom` installed in your project and automatically adds it to the `setupFiles`. All you need to add (if you want) is `globals`, `coverage`, and other testing configuration of your choice. If you can live without those, enjoy using vitest without the need to configure it yourself. -Tests default to the client posture, regardless of the app's `ssr` flag: DOM codegen, non-hydratable (nothing hydrates in a test), browser export conditions, and a `jsdom` default `test.environment`. A server-rendered app needs no `ssr: mode !== 'test'` workaround — DOM component tests just work. +Tests default to the client posture, regardless of the app's `ssr` flag: DOM codegen, non-hydratable (nothing hydrates in a test), browser export conditions, and a `jsdom` default `test.environment`. `solid-js`, `@solidjs/web` and the packages that import them (such as `@solidjs/testing-library`) are inlined, so their imports resolve the browser build through Vite on any host runtime, Deno included. A server-rendered app needs no `ssr: mode !== 'test'` workaround — DOM component tests just work. Server-runtime unit tests (server functions, sessions, `renderToString` — anything that needs `isServer` to be `true` and the real server build of the framework) opt out per [vitest project](https://vitest.dev/guide/projects) by setting `test.environment: 'node'` (or `'edge-runtime'`) explicitly. That project gets the server posture end to end: server export conditions, ssr codegen, and the framework inlined so the whole graph — request-event storage included — resolves into one server-build instance. No inline/alias configuration needed. Both postures coexist in one workspace: @@ -1172,7 +1172,7 @@ test: { }, ``` -The posture is chosen while each project resolves its own Vite config. Vitest 5 lets inline projects reuse the root Vite server instead (`test.sharedViteServer`, on by default), which skips that step, so the plugin sets `sharedViteServer: false` when the root config declares `test.projects` — trading Vitest 5's shared-server optimization for the per-project resolution Vitest 4 always did. Setting the option yourself overrides this; with `true`, every shared project runs with the root config's posture, without the injected `jest-dom` setup file, and without the plugin's `server.deps` handling (the framework inlined for the server posture, externalized for the client one). +The posture is chosen while each project resolves its own Vite config. Vitest 5 lets inline projects reuse the root Vite server instead (`test.sharedViteServer`, on by default), which skips that step, so the plugin sets `sharedViteServer: false` when the root config declares `test.projects` — trading Vitest 5's shared-server optimization for the per-project resolution Vitest 4 always did. Setting the option yourself overrides this; with `true`, every shared project runs with the root config's posture, without the injected `jest-dom` setup file, and without the plugin's `server.deps` handling (the framework inlined in both postures, together with its consumers such as `@solidjs/testing-library` in the client one). # Credits diff --git a/examples/start-ssr/package.json b/examples/start-ssr/package.json index fb275b1..177579c 100644 --- a/examples/start-ssr/package.json +++ b/examples/start-ssr/package.json @@ -9,6 +9,7 @@ "test": "node test/run.mjs && node test/http-bridge.mjs && node test/components-warning.mjs && node test/webworker-warning.mjs && node test/dedupe.mjs && node test/ssr-inline.mjs" }, "devDependencies": { + "@solidjs/testing-library": "^1.0.0-beta.2", "jsdom": "^26.1.0", "vite": "^8.2.1", "@solidjs/vite-plugin": "workspace:*", diff --git a/examples/start-ssr/src/testing-library.test.tsx b/examples/start-ssr/src/testing-library.test.tsx new file mode 100644 index 0000000..b715966 --- /dev/null +++ b/examples/start-ssr/src/testing-library.test.tsx @@ -0,0 +1,41 @@ +// Client-posture regression test for runtime-consuming packages (#369). The +// vitest mode of test/run.mjs runs it with test/deno-conditions.mjs +// preloaded. `@solidjs/testing-library` has no `solid` export condition. +// Left external, its own `import "@solidjs/web"` is resolved by the host +// runtime, and under Deno that is the server build, so `render` ran with +// `isServer` true. The plugin inlines it with the runtime, so its imports go +// through Vite's `browser` condition and share the test file's instance. +import { expect, it } from 'vitest'; +import { createSignal } from 'solid-js'; +import { isServer } from '@solidjs/web'; +import { render } from '@solidjs/testing-library'; + +function Counter() { + const [count, setCount] = createSignal(0); + return ( + + ); +} + +const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +it('resolves the browser build in the test file', () => { + expect(isServer).toBe(false); +}); + +it('renders and updates through @solidjs/testing-library', async () => { + const { getByRole, unmount } = render(() => ); + try { + const button = getByRole('button'); + expect(button.textContent).toContain('count: 0'); + button.click(); + for (let i = 0; i < 20 && !button.textContent!.includes('count: 1'); i++) { + await nextTick(); + } + expect(button.textContent).toContain('count: 1'); + } finally { + unmount(); + } +}); diff --git a/examples/start-ssr/test/deno-conditions.mjs b/examples/start-ssr/test/deno-conditions.mjs new file mode 100644 index 0000000..b0b2f95 --- /dev/null +++ b/examples/start-ssr/test/deno-conditions.mjs @@ -0,0 +1,29 @@ +// Preloaded into the vitest workers (NODE_OPTIONS=--import) by the vitest +// mode of test/run.mjs. It makes Node resolve bare imports the way Deno does. +// In worker threads, Deno does not apply the `browser` condition that vitest +// passes to the workers as `--conditions`, so an externalized package's own +// `import "@solidjs/web"` resolves `deno`/`node`, which is the server build +// (#369). +import module from 'node:module'; + +const denoConditions = (context) => ({ + ...context, + conditions: [...context.conditions.filter((c) => c !== 'browser'), 'deno'], +}); + +if (module.registerHooks) { + module.registerHooks({ + resolve: (specifier, context, next) => next(specifier, denoConditions(context)), + }); +} else { + // Node < 22.15 has only the async loader API. + module.register( + 'data:text/javascript,' + + encodeURIComponent(` + export async function resolve(specifier, context, next) { + const conditions = context.conditions.filter((c) => c !== 'browser'); + return next(specifier, { ...context, conditions: [...conditions, 'deno'] }); + } + `), + ); +} diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index d20091a..73e659d 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -6352,6 +6352,44 @@ async function runVitestMode() { testPass ? undefined : out.slice(-2000), ); + // Runtime-consuming packages in the client posture (#369). An externalized + // package's own imports are resolved by the host runtime, not by Vite. + // Node gets the `browser` condition from vitest's worker `--conditions`; + // Deno does not, and `@solidjs/testing-library` then loaded the server + // build of `@solidjs/web`. test/deno-conditions.mjs makes the workers + // resolve the way Deno does, so this fails unless the plugin inlines the + // runtime and its consumers. + let consumersOut = ''; + let consumersOk = false; + try { + consumersOut = execSync('pnpm exec vitest run src/testing-library.test.tsx --pool=forks', { + cwd: exampleDir, + stdio: 'pipe', + timeout: 180000, + env: { + ...process.env, + NO_COLOR: '1', + NODE_OPTIONS: [ + process.env.NODE_OPTIONS, + `--import=${pathToFileURL(path.join(exampleDir, 'test', 'deno-conditions.mjs')).href}`, + ] + .filter(Boolean) + .join(' '), + }, + }).toString(); + consumersOk = true; + } catch (e) { + consumersOut = String(e.stdout || '') + String(e.stderr || ''); + } + const consumersPass = consumersOk && /2 passed/.test(consumersOut); + record( + mode, + 'runtime-consumers', + '@solidjs/testing-library renders with the browser build when the host runtime ignores `browser`', + consumersPass, + consumersPass ? undefined : consumersOut.slice(-2000), + ); + // Both postures in ONE workspace (VITEST_PROJECTS=1 adds test.projects to // the config): the jsdom project keeps the client posture while the node // project gets the server posture end to end just by writing diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 134fa9f..28e2249 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: specifier: 'catalog:' version: 2.0.0-rc.14 devDependencies: + '@solidjs/testing-library': + specifier: ^1.0.0-beta.2 + version: 1.0.0-beta.2(@solidjs/web@2.0.0-rc.14(solid-js@2.0.0-rc.14))(solid-js@2.0.0-rc.14) '@solidjs/vite-plugin': specifier: workspace:* version: link:../.. diff --git a/src/index.ts b/src/index.ts index 7b9ab11..02db8ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1260,6 +1260,10 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // Names of the packages `isSemiFrameworkPkgByJson` classified, so their // dependencies can be taken back out of `optimizeDeps.include` (see below). const semiFrameworkPkgs = new Set(); + // Packages that import the Solid runtime without a `solid` export + // condition, found by the same crawl in the vitest client posture. They are + // inlined there with the runtime (see `test.server.deps` below). + const testRuntimeConsumers = new Set(); const tsrxCss = new Map(); // The client build's manifest, read back by SSR builds. In builder-mode @@ -1469,10 +1473,20 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // the same way `development` does, and an externalized consumer // would split it just the same), never vitest (it manages inlining // via test.server.deps). - if (!(replaceDev || observe) || isTestMode) return false; const semi = SOLID_RUNTIME_PKGS.some( (name) => pkgJson.dependencies?.[name] || pkgJson.peerDependencies?.[name], ); + if (isTestMode) { + // Client test posture: record the runtime consumers so + // `test.server.deps.inline` below can inline them, without + // handing them to vitefu (its `ssr.external` for their + // dependencies would reach vitest's `server.deps.external`). + if (semi && !serverTestPosture && typeof pkgJson.name === 'string') { + testRuntimeConsumers.add(pkgJson.name); + } + return false; + } + if (!(replaceDev || observe)) return false; if (semi && typeof pkgJson.name === 'string') semiFrameworkPkgs.add(pkgJson.name); return semi; }, @@ -1581,11 +1595,36 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { test.server = { deps: { inline: [/solid-js/, /@solidjs[+/]web/] } }; } } else if ( + // `inline: true` inlines everything already (and Vite's config + // merge would turn it into `[true, ...]`, which vitest can't read). + // A user list is merged with this one; a user who externalizes + // solid-js keeps it external. + userTest.server?.deps?.inline !== true && !userTest.server?.deps?.external?.find((item: string | RegExp) => /solid-js/.test(item.toString()), ) ) { - test.server = { deps: { external: [/solid-js/] } }; + // An externalized package is imported natively, and its own imports + // are resolved by the host runtime, not by Vite. Node gets the + // `browser` condition from vitest's worker `--conditions`, but Deno + // does not apply them in worker threads. It resolves `deno`/`node` + // and lands on the server build (`isServer` true inside + // `@solidjs/testing-library`'s `render`, #369). Inline the runtime and the packages that consume it, so + // every import of `solid-js` and `@solidjs/web` goes through Vite's + // `browser` condition and loads one client-build instance. A + // consumer left external would load a second, native copy, so the + // consumers found by the crawl are inlined with the runtime. + // Packages with a `solid` export condition are inlined already + // through vitefu's `ssr.noExternal`. + test.server = { + deps: { + inline: [ + /[\\/]node_modules[\\/]solid-js[\\/]/, + /[\\/]node_modules[\\/]@solidjs[\\/]web[\\/]/, + ...testRuntimeConsumers, + ], + }, + }; } // jest-dom's DOM matchers have no place in a server-posture project; // vitest browser mode already has bundled jest-dom assertions