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 } : {}), }; },