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/.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..fd2ce0c9 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
@@ -255,12 +272,44 @@ 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()),
)
) {
- 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
@@ -301,7 +350,7 @@ export default function solidPlugin(options: Partial = {}): Plugin {
: {}),
},
...(!isVite6OrNewer ? { ssr: solidPkgsConfig.ssr } : {}),
- ...(test.server ? { test } : {}),
+ ...(test.server || test.sharedViteServer === false ? { test } : {}),
};
},