From 32adc90cd1fc8df36116e584d66729aac23f7e21 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Mon, 19 Oct 2026 10:17:18 +0800 Subject: [PATCH 1/2] fix(vitest): inline solid-js and its consumers in jsdom tests DOM tests kept solid-js external, so packages without a `solid` export condition (such as @solidjs/testing-library) were imported natively and their own imports were resolved by the host runtime. Node gets the `browser` condition from vitest's worker --conditions, but Deno does not apply it in worker threads and resolves `deno`/`node`, so @solidjs/testing-library loaded the server build of solid-js/web and `render` threw "Client-only API called on the server side". - Inline solid-js and the runtime consumers found by the vitefu crawl through test.server.deps.inline when the `ssr` option is off. - Merge with a user inline list; skip for `inline: true` or a user external entry for solid-js. `ssr: true` keeps solid-js external. - Add a jsdom suite to examples/vite-6 (`pnpm test:jsdom`) that emulates Deno's resolution with a Node resolve hook, and run it in CI. Refs solidjs/solid-vite-plugin#369 Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/vitest-client-inline-runtime.md | 5 ++ .github/workflows/vitest.yml | 4 ++ examples/vite-6/package.json | 4 +- examples/vite-6/tests/deno-conditions.mjs | 29 ++++++++++ .../tests/jsdom/testing-library.test.tsx | 33 +++++++++++ examples/vite-6/vitest.config.ts | 4 +- examples/vite-6/vitest.jsdom.config.ts | 12 ++++ pnpm-lock.yaml | 58 +++++-------------- src/index.ts | 40 ++++++++++++- 9 files changed, 144 insertions(+), 45 deletions(-) create mode 100644 .changeset/vitest-client-inline-runtime.md create mode 100644 examples/vite-6/tests/deno-conditions.mjs create mode 100644 examples/vite-6/tests/jsdom/testing-library.test.tsx create mode 100644 examples/vite-6/vitest.jsdom.config.ts diff --git a/.changeset/vitest-client-inline-runtime.md b/.changeset/vitest-client-inline-runtime.md new file mode 100644 index 00000000..3cda3233 --- /dev/null +++ b/.changeset/vitest-client-inline-runtime.md @@ -0,0 +1,5 @@ +--- +'vite-plugin-solid': patch +--- + +In Vitest DOM tests (the jsdom default, no `ssr` option), the plugin now inlines `solid-js` and the packages that import it 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 and resolves `deno`/`node`. Under Deno, `@solidjs/testing-library` loaded the server build of `solid-js/web` and `render` threw "Client-only API called on the server side" (solidjs/solid-vite-plugin#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. With `ssr: true`, tests keep `solid-js` external as before. diff --git a/.github/workflows/vitest.yml b/.github/workflows/vitest.yml index fc062d92..ec6ac3cf 100644 --- a/.github/workflows/vitest.yml +++ b/.github/workflows/vitest.yml @@ -34,3 +34,7 @@ jobs: - name: Run tests working-directory: examples/vite-6 run: pnpm run test + + - name: Run jsdom tests + working-directory: examples/vite-6 + run: pnpm run test:jsdom diff --git a/examples/vite-6/package.json b/examples/vite-6/package.json index a233ac35..fd5c45e2 100644 --- a/examples/vite-6/package.json +++ b/examples/vite-6/package.json @@ -7,13 +7,15 @@ "build": "vite build", "preview": "vite preview", "test": "vitest --browser.headless --run", - "test-dev": "vitest" + "test-dev": "vitest", + "test:jsdom": "NODE_OPTIONS=--import=./tests/deno-conditions.mjs vitest --run --config vitest.jsdom.config.ts --pool=forks" }, "devDependencies": { "@solidjs/testing-library": "^0.8.10", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/browser": "^3.0.7", + "jsdom": "^26.0.0", "playwright": "^1.50.1", "vite": "^6.2.0", "vite-plugin-solid": "workspace:*", diff --git a/examples/vite-6/tests/deno-conditions.mjs b/examples/vite-6/tests/deno-conditions.mjs new file mode 100644 index 00000000..a62dbf26 --- /dev/null +++ b/examples/vite-6/tests/deno-conditions.mjs @@ -0,0 +1,29 @@ +// Preloaded into the vitest workers (NODE_OPTIONS=--import) by `test:jsdom`. +// It makes Node resolve bare imports the way Deno does in a worker thread. +// Deno does not apply the `browser` condition that vitest passes to the +// workers as `--conditions`, so an externalized package's own +// `import "solid-js/web"` resolves `deno`/`node`, which is the server build +// (solidjs/solid-vite-plugin#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/vite-6/tests/jsdom/testing-library.test.tsx b/examples/vite-6/tests/jsdom/testing-library.test.tsx new file mode 100644 index 00000000..d90da1ea --- /dev/null +++ b/examples/vite-6/tests/jsdom/testing-library.test.tsx @@ -0,0 +1,33 @@ +// jsdom regression test for solidjs/solid-vite-plugin#369, run by +// `pnpm test:jsdom` with tests/deno-conditions.mjs preloaded. +// `@solidjs/testing-library` has no `solid` export condition. Left external, +// its own `import "solid-js/web"` is resolved by the host runtime, and under +// Deno that is the server build, so `render` threw "Client-only API called +// on the server side". The plugin inlines it with solid-js, so its imports +// go through Vite's `browser` condition and share the test file's instance. +import { render } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { isServer } from 'solid-js/web'; +import { expect, test } from 'vitest'; + +function Counter() { + const [count, setCount] = createSignal(0); + return ( + + ); +} + +test('resolves the browser build in the test file', () => { + expect(isServer).toBe(false); +}); + +test('renders and updates through @solidjs/testing-library', () => { + const { getByRole, unmount } = render(() => ); + const button = getByRole('button'); + expect(button.textContent).toBe('count: 0'); + button.click(); + expect(button.textContent).toBe('count: 1'); + unmount(); +}); diff --git a/examples/vite-6/vitest.config.ts b/examples/vite-6/vitest.config.ts index eb10c0e7..05ffcff4 100644 --- a/examples/vite-6/vitest.config.ts +++ b/examples/vite-6/vitest.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; import solidPlugin from '../../src/index.js'; export default defineConfig({ @@ -8,6 +8,8 @@ export default defineConfig({ }, test: { environment: 'node', + // tests/jsdom runs under vitest.jsdom.config.ts (`pnpm test:jsdom`). + exclude: [...configDefaults.exclude, 'tests/jsdom/**'], browser: { enabled: true, provider: 'playwright', diff --git a/examples/vite-6/vitest.jsdom.config.ts b/examples/vite-6/vitest.jsdom.config.ts new file mode 100644 index 00000000..37c61600 --- /dev/null +++ b/examples/vite-6/vitest.jsdom.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; +import solidPlugin from '../../src/index.js'; + +// jsdom suite (`pnpm test:jsdom`). The default vitest.config.ts runs the +// browser-mode suite and excludes tests/jsdom. +export default defineConfig({ + plugins: [solidPlugin()], + test: { + environment: 'jsdom', + include: ['tests/jsdom/**/*.test.tsx'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eed12c18..3c7bd36d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: '@vitest/browser': specifier: ^3.0.7 version: 3.0.7(@types/node@18.19.66)(playwright@1.50.1)(typescript@5.7.2)(vite@6.2.0(@types/node@18.19.66)(lightningcss@1.32.0))(vitest@3.0.7) + jsdom: + specifier: ^26.0.0 + version: 26.0.0(supports-color@8.1.1) playwright: specifier: ^1.50.1 version: 1.50.1 @@ -4303,7 +4306,6 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) '@csstools/css-tokenizer': 3.0.3 lru-cache: 10.4.3 - optional: true '@babel/code-frame@7.26.2': dependencies: @@ -5156,14 +5158,12 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@csstools/color-helpers@5.0.2': - optional: true + '@csstools/color-helpers@5.0.2': {} '@csstools/css-calc@2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': dependencies: '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) '@csstools/css-tokenizer': 3.0.3 - optional: true '@csstools/css-color-parser@3.0.8(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': dependencies: @@ -5171,15 +5171,12 @@ snapshots: '@csstools/css-calc': 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) '@csstools/css-tokenizer': 3.0.3 - optional: true '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': dependencies: '@csstools/css-tokenizer': 3.0.3 - optional: true - '@csstools/css-tokenizer@3.0.3': - optional: true + '@csstools/css-tokenizer@3.0.3': {} '@cypress/request@3.0.7': dependencies: @@ -6090,8 +6087,7 @@ snapshots: loupe: 3.1.3 tinyrainbow: 2.0.0 - agent-base@7.1.3: - optional: true + agent-base@7.1.3: {} aggregate-error@3.1.0: dependencies: @@ -6360,7 +6356,6 @@ snapshots: dependencies: '@asamuzakjp/css-color': 2.8.3 rrweb-cssom: 0.8.0 - optional: true csstype@3.1.3: {} @@ -6436,7 +6431,6 @@ snapshots: dependencies: whatwg-mimetype: 4.0.0 whatwg-url: 14.1.1 - optional: true dayjs@1.11.13: {} @@ -6458,8 +6452,7 @@ snapshots: optionalDependencies: supports-color: 8.1.1 - decimal.js@10.5.0: - optional: true + decimal.js@10.5.0: {} deep-eql@5.0.2: {} @@ -6928,7 +6921,6 @@ snapshots: html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 - optional: true html-entities@2.3.3: {} @@ -6938,7 +6930,6 @@ snapshots: debug: 4.4.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color - optional: true http-signature@1.4.0: dependencies: @@ -6952,7 +6943,6 @@ snapshots: debug: 4.4.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color - optional: true human-id@1.0.2: {} @@ -6965,7 +6955,6 @@ snapshots: iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 - optional: true ieee754@1.2.1: {} @@ -7011,8 +7000,7 @@ snapshots: is-path-inside@3.0.3: {} - is-potential-custom-element-name@1.0.1: - optional: true + is-potential-custom-element-name@1.0.1: {} is-reference@1.2.1: dependencies: @@ -7072,7 +7060,6 @@ snapshots: - bufferutil - supports-color - utf-8-validate - optional: true jsesc@3.0.2: {} @@ -7191,8 +7178,7 @@ snapshots: loupe@3.1.3: {} - lru-cache@10.4.3: - optional: true + lru-cache@10.4.3: {} lru-cache@5.1.1: dependencies: @@ -7288,8 +7274,7 @@ snapshots: dependencies: path-key: 3.1.1 - nwsapi@2.2.16: - optional: true + nwsapi@2.2.16: {} object-inspect@1.13.3: {} @@ -7623,8 +7608,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.46.1 fsevents: 2.3.3 - rrweb-cssom@0.8.0: - optional: true + rrweb-cssom@0.8.0: {} run-parallel@1.2.0: dependencies: @@ -7645,7 +7629,6 @@ snapshots: saxes@6.0.0: dependencies: xmlchars: 2.2.0 - optional: true semver@6.3.1: {} @@ -7789,8 +7772,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - symbol-tree@3.2.4: - optional: true + symbol-tree@3.2.4: {} term-size@2.2.1: {} @@ -7850,7 +7832,6 @@ snapshots: tr46@5.0.0: dependencies: punycode: 2.3.1 - optional: true tree-kill@1.2.2: {} @@ -8059,24 +8040,19 @@ snapshots: w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 - optional: true - webidl-conversions@7.0.0: - optional: true + webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 - optional: true - whatwg-mimetype@4.0.0: - optional: true + whatwg-mimetype@4.0.0: {} whatwg-url@14.1.1: dependencies: tr46: 5.0.0 webidl-conversions: 7.0.0 - optional: true which@2.0.2: dependencies: @@ -8103,11 +8079,9 @@ snapshots: ws@8.18.1: {} - xml-name-validator@5.0.0: - optional: true + xml-name-validator@5.0.0: {} - xmlchars@2.2.0: - optional: true + xmlchars@2.2.0: {} y18n@5.0.8: {} diff --git a/src/index.ts b/src/index.ts index 82770246..ec973e94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -214,6 +214,10 @@ export default function solidPlugin(options: Partial = {}): Plugin { let projectRoot = process.cwd(); let isTestMode = false; let solidPkgsConfig: Awaited>; + // Packages that import solid-js without a `solid` export condition, found + // by the crawl in test mode. They are inlined with solid-js (see + // `test.server.deps` below). + const testRuntimeConsumers = new Set(); return { name: 'solid', @@ -235,6 +239,19 @@ export default function solidPlugin(options: Partial = {}): Plugin { isFrameworkPkgByJson(pkgJson) { return containsSolidField(pkgJson.exports || {}); }, + // Only records the runtime consumers for the jsdom test posture. + // Returning false keeps vitefu's output unchanged. + isSemiFrameworkPkgByJson(pkgJson) { + if ( + isTestMode && + !options.ssr && + typeof pkgJson.name === 'string' && + (pkgJson.dependencies?.['solid-js'] || pkgJson.peerDependencies?.['solid-js']) + ) { + testRuntimeConsumers.add(pkgJson.name); + } + return false; + }, }); // fix for bundling dev in production @@ -260,7 +277,28 @@ export default function solidPlugin(options: Partial = {}): Plugin { /solid-js/.test(item.toString()), ) ) { - test.server = { deps: { external: [/solid-js/] } }; + if (options.ssr) { + test.server = { deps: { external: [/solid-js/] } }; + } else if (userTest.server?.deps?.inline !== true) { + // 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 (in a worker thread) resolves + // `deno`/`node` and lands on the server build: `render` from + // `@solidjs/testing-library` threw "Client-only API called on the + // server side" (#369). Inline solid-js and the packages that + // import it, so these imports go through Vite's `browser` + // condition and load one client-build instance. A consumer left + // external would load a second, native copy. Packages with a + // `solid` export condition are inlined already through vitefu. + // A user `inline` list is merged with this one; `inline: true` + // inlines everything already. + test.server = { + deps: { + inline: [/[\\/]node_modules[\\/]solid-js[\\/]/, ...testRuntimeConsumers], + }, + }; + } } if (!userTest.browser?.enabled) { // vitest browser mode already has bundled jest-dom assertions From 5d6846c0ba66fa1a15e9773e1ede6bc365775ecb Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Mon, 19 Oct 2026 10:18:08 +0800 Subject: [PATCH 2/2] fix(vitest): turn off sharedViteServer for inline projects Vitest 5 defaults test.sharedViteServer to true. Inline projects then reuse the root Vite server and take their test options from the raw root block captured before any config hook runs, so the jsdom default, the jest-dom setup file and server.deps never reach them. Set the option to false when the root declares test.projects and leaves it unset, as @solidjs/vite-plugin does since #370. Refs solidjs/solid-vite-plugin#369 Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/vitest-5-shared-server-projects.md | 5 +++++ src/index.ts | 13 ++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .changeset/vitest-5-shared-server-projects.md diff --git a/.changeset/vitest-5-shared-server-projects.md b/.changeset/vitest-5-shared-server-projects.md new file mode 100644 index 00000000..1e9d9dbe --- /dev/null +++ b/.changeset/vitest-5-shared-server-projects.md @@ -0,0 +1,5 @@ +--- +'vite-plugin-solid': patch +--- + +When the root config declares `test.projects` and leaves `test.sharedViteServer` unset, the plugin now sets it to `false` so every inline project resolves its own Vite config, as under Vitest 4. An explicit value is respected. Vitest 5 defaults the option to `true`, and a shared inline project takes its `test` options from the raw root block captured before any `config` hook runs, so it lost everything the plugin injects (the `jsdom` default, the `@testing-library/jest-dom` setup file, `server.deps`). Ports solidjs/solid-vite-plugin#370; refs #369. diff --git a/src/index.ts b/src/index.ts index ec973e94..fd2ce0c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -272,6 +272,17 @@ export default function solidPlugin(options: Partial = {}): Plugin { test.environment = 'jsdom'; } + // Vitest 5 defaults `test.sharedViteServer` to true: inline projects + // then reuse the root Vite server and take their `test` options from + // the raw root block captured before any `config` hook runs, so + // nothing injected here (jsdom, jest-dom, server.deps) reaches them. + // Vitest reads the option off the resolved root config, so this + // restores per-project resolution as under Vitest 4. Older Vitest + // versions ignore the option. + if (userTest.projects && userTest.sharedViteServer === undefined) { + test.sharedViteServer = false; + } + if ( !userTest.server?.deps?.external?.find((item: string | RegExp) => /solid-js/.test(item.toString()), @@ -339,7 +350,7 @@ export default function solidPlugin(options: Partial = {}): Plugin { : {}), }, ...(!isVite6OrNewer ? { ssr: solidPkgsConfig.ssr } : {}), - ...(test.server ? { test } : {}), + ...(test.server || test.sharedViteServer === false ? { test } : {}), }; },