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