From da7c3476638cb02940290eee175f102b0d77588a Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 27 Sep 2026 02:37:01 -0700 Subject: [PATCH] chore: Node >=22.12 floor to match the rc runtime; dedupe @solidjs/signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `engines.node` moves from `^20.19.0 || >=22.12.0` to `>=22.12.0`. Since Solid 2.0.0-rc.8 the runtime packages (`solid-js`, `@solidjs/web`, `@solidjs/signals`) are ESM-only with that same floor, so the plugin's Node 20 lane was already unusable with the peer range it declares. The README requirement line follows. CI already runs Node 24 in every workflow (cr, e2e, release, vitest); no matrix change. No `.npmrc`, and no example declares its own `engines`. Under `serve`, `@solidjs/signals` joins `solid-js` and `@solidjs/web` in `resolve.dedupe` — gated on the app root reaching a copy of it (`node_modules/@solidjs/signals`, walking up: an npm-style hoist or a direct dependency). `@solidjs/diagnostics/browser` and `solid-js/attribution` import the signals core directly, so a nested/duplicated install yields a second engine beside the one `solid-js` loads — the same two-engines symptom #374 addressed for the pre-bundle path with `optimizeDeps.include`. Why the gate: `resolve.dedupe` resolves the listed package from the root. Vite 8's native (rolldown) resolver falls back to the importer when that misses, but the JS `tryNodeResolve` used by the SSR externalize decision and by `fetchModule` (the module runner's resolution of externalized bare imports) does not. Under pnpm's isolated layout signals exists only as `solid-js`'s transitive dependency, so an unconditional entry turned `import "@solidjs/signals"` from the inlined `solid-js/dist/server.dev.js` into `ERR_MODULE_NOT_FOUND` as soon as anything externalized it — and vitefu does exactly that once a semi-framework package such as `@solidjs/diagnostics` lists signals under `dependencies`. Reproduced in a strict-pnpm app (solid-js + @solidjs/web + @solidjs/diagnostics) through the SSR module runner: baseline OK, unconditional dedupe throws, dedupe with a root copy OK. With no root copy there is nothing to dedupe to, so the gate loses nothing. Signals stays out of `optimizeDeps.include`: the optimizer already reaches it through `solid-js`, and an include entry that doesn't resolve from the root logs `Failed to resolve dependency` on every start (observed in this repo's own examples). Tests: start-ssr gains test/dedupe.mjs (pure resolveConfig): in the example's own pnpm layout signals is absent from `resolve.dedupe` and `optimizeDeps.include`; in a temp root with a stub `node_modules/@solidjs/signals` it is present in `dedupe` and still absent from `include`; under `build` the list is empty. Co-authored-by: Claude via Cursor --- .changeset/node-22-floor-signals-dedupe.md | 5 + README.md | 3 +- examples/start-ssr/package.json | 2 +- examples/start-ssr/test/dedupe.mjs | 125 +++++++++++++++++++++ package.json | 2 +- src/index.ts | 26 ++++- 6 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 .changeset/node-22-floor-signals-dedupe.md create mode 100644 examples/start-ssr/test/dedupe.mjs diff --git a/.changeset/node-22-floor-signals-dedupe.md b/.changeset/node-22-floor-signals-dedupe.md new file mode 100644 index 0000000..1a626dc --- /dev/null +++ b/.changeset/node-22-floor-signals-dedupe.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +Node floor and a signals dedupe. `engines.node` is now `>=22.12.0` (was `^20.19.0 || >=22.12.0`), matching the `solid-js`, `@solidjs/web` and `@solidjs/signals` 2.0 runtime packages, which have been ESM-only with that floor since rc.8; the README says the same. Under `serve`, `@solidjs/signals` joins `solid-js` and `@solidjs/web` in `resolve.dedupe` when the app root can reach a copy of it (`node_modules/@solidjs/signals`, walking up — an npm-style hoist or a direct dependency): `@solidjs/diagnostics/browser` and `solid-js/attribution` import the signals core directly, so a nested/duplicated install yielded a second engine beside the one `solid-js` loads. The entry is gated on the root copy because `resolve.dedupe` resolves the listed package from the root and Vite's Node-side resolver (`fetchModule` for externalized SSR imports, and the externalize decision) has no importer fallback when that misses — under pnpm's isolated layout, where signals exists only as `solid-js`'s transitive dependency, an unconditional entry turned `import "@solidjs/signals"` from the inlined `solid-js` into `ERR_MODULE_NOT_FOUND` once anything externalized it (vitefu does, when a semi-framework package such as `@solidjs/diagnostics` lists it under `dependencies`). With no root copy there is nothing to dedupe to, so the gate loses nothing. Signals does not join `optimizeDeps.include`: the optimizer already reaches it through `solid-js`, and an include entry that doesn't resolve from the root warns on every start. diff --git a/README.md b/README.md index fc11153..b529ec8 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ Join [solid discord](https://discord.com/invite/solidjs) and check the [troubles ## Requirements -This module is 100% ESM compatible and requires Node.js `^20.19.0 || >=22.12.0`. +This module is 100% ESM compatible and requires Node.js `>=22.12.0` (the same +floor as the `solid-js` 2.0 runtime packages). You can check your current Node.js version by running `node -v`. Use a version manager such as [Volta](https://volta.sh/) or [nvm](https://github.com/nvm-sh/nvm) diff --git a/examples/start-ssr/package.json b/examples/start-ssr/package.json index 8fcb0bf..866607b 100644 --- a/examples/start-ssr/package.json +++ b/examples/start-ssr/package.json @@ -6,7 +6,7 @@ "dev": "vite", "build": "vite build", "serve": "NODE_ENV=production node server.js", - "test": "node test/run.mjs && node test/http-bridge.mjs && node test/components-warning.mjs && node test/webworker-warning.mjs" + "test": "node test/run.mjs && node test/http-bridge.mjs && node test/components-warning.mjs && node test/webworker-warning.mjs && node test/dedupe.mjs" }, "devDependencies": { "jsdom": "^26.1.0", diff --git a/examples/start-ssr/test/dedupe.mjs b/examples/start-ssr/test/dedupe.mjs new file mode 100644 index 0000000..5349547 --- /dev/null +++ b/examples/start-ssr/test/dedupe.mjs @@ -0,0 +1,125 @@ +// Config-level test for the dev `resolve.dedupe` list: +// - `solid-js` and `@solidjs/web` are always deduped under `serve`, +// - `@solidjs/signals` joins them only when the app root can reach a copy +// (`/node_modules/@solidjs/signals`, walking up). Dedupe resolves +// the listed package from the root and Vite's Node-side resolver +// (`fetchModule`, the externalize decision) has no importer fallback, +// so under pnpm's isolated layout — signals present only as `solid-js`'s +// transitive dependency, which is this example's own layout — the entry +// would turn `import "@solidjs/signals"` from the inlined `solid-js` into +// ERR_MODULE_NOT_FOUND once anything externalizes signals, +// - `@solidjs/signals` never rides into `optimizeDeps.include` (an include +// entry that doesn't resolve from the root warns on every start), +// - under `build` the list is empty (dev-only fix). +// +// Pure resolveConfig — no dev server, no browser. Requires the plugin built +// (pnpm build at the repo root). Usage: node test/dedupe.mjs + +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { resolveConfig } from 'vite'; +import solidPlugin from '@solidjs/vite-plugin'; + +const exampleDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +const results = []; +function record(name, ok, detail = '') { + results.push({ name, ok, detail }); + console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail && !ok ? ` — ${detail}` : ''}`); +} + +function findUp(root, name) { + let dir = root; + while (true) { + if (fs.existsSync(path.join(dir, 'node_modules', name, 'package.json'))) return dir; + const parent = path.dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +async function resolveWith(root, command) { + return resolveConfig( + { root, configFile: false, logLevel: 'error', plugins: [solidPlugin({ ssr: true })] }, + command, + ); +} + +// ---- this example: pnpm isolated layout, signals is transitive only ------- +{ + const reachable = findUp(exampleDir, '@solidjs/signals'); + record( + 'precondition: @solidjs/signals is not reachable from the example root', + reachable === undefined, + `found under ${reachable}`, + ); + const config = await resolveWith(exampleDir, 'serve'); + record( + 'serve: solid-js and @solidjs/web deduped', + config.resolve.dedupe.includes('solid-js') && config.resolve.dedupe.includes('@solidjs/web'), + config.resolve.dedupe.join(', '), + ); + record( + 'serve, no root copy: @solidjs/signals NOT in resolve.dedupe', + !config.resolve.dedupe.includes('@solidjs/signals'), + config.resolve.dedupe.join(', '), + ); + record( + 'serve: @solidjs/signals not in optimizeDeps.include', + !config.optimizeDeps.include.includes('@solidjs/signals'), + config.optimizeDeps.include.join(', '), + ); +} + +// ---- a root that CAN reach a copy (npm-style hoist / direct dependency) ---- +{ + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'solid-dedupe-')); + try { + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ name: 'dedupe-probe', private: true, type: 'module' }), + ); + const stub = path.join(root, 'node_modules', '@solidjs', 'signals'); + fs.mkdirSync(stub, { recursive: true }); + fs.writeFileSync( + path.join(stub, 'package.json'), + JSON.stringify({ name: '@solidjs/signals', version: '0.0.0-stub' }), + ); + + const serve = await resolveWith(root, 'serve'); + record( + 'serve, root copy present: @solidjs/signals in resolve.dedupe', + serve.resolve.dedupe.includes('@solidjs/signals'), + serve.resolve.dedupe.join(', '), + ); + record( + 'serve, root copy present: solid-js and @solidjs/web still deduped', + serve.resolve.dedupe.includes('solid-js') && serve.resolve.dedupe.includes('@solidjs/web'), + serve.resolve.dedupe.join(', '), + ); + record( + 'serve, root copy present: @solidjs/signals still not in optimizeDeps.include', + !serve.optimizeDeps.include.includes('@solidjs/signals'), + serve.optimizeDeps.include.join(', '), + ); + + const build = await resolveWith(root, 'build'); + record( + 'build: resolve.dedupe is empty (dev-only fix)', + build.resolve.dedupe.length === 0, + build.resolve.dedupe.join(', '), + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +const failed = results.filter((r) => !r.ok); +console.log(`\n${results.length - failed.length}/${results.length} assertions passed`); +if (failed.length) { + console.log('\nFailures:'); + for (const f of failed) console.log(` ${f.name} — ${f.detail}`); + process.exit(1); +} diff --git a/package.json b/package.json index 9bed622..c741420 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "solid-js integration plugin for Vite", "type": "module", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=22.12.0" }, "files": [ "dist", diff --git a/src/index.ts b/src/index.ts index affac6f..f7b48cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1287,6 +1287,30 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // fix for bundling dev in production const nestedDeps = replaceDev ? ['solid-js', '@solidjs/web'] : []; + // `@solidjs/signals` is the reactive core `solid-js` depends on. + // `@solidjs/diagnostics/browser` and `solid-js/attribution` import it + // directly, so a nested/duplicated install (npm nesting a second copy + // under a package that lists it as a dependency) yields a second + // engine beside the one `solid-js` loads — the same two-engines + // symptom the `optimizeDeps.include` entries below guard against on + // the pre-bundle path. Dedupe it to the app's copy — but only when the + // app root can reach one. `resolve.dedupe` resolves the listed package + // from the root, and Vite's Node-side resolver (`fetchModule` for + // externalized SSR imports, and the externalize decision itself) has + // no importer fallback when that misses. Under pnpm's isolated layout + // signals exists only as `solid-js`'s transitive dependency, so with + // the entry always on, `import "@solidjs/signals"` from the inlined + // `solid-js` fails with ERR_MODULE_NOT_FOUND as soon as anything + // externalizes it (vitefu does, once a semi-framework package such as + // `@solidjs/diagnostics` lists it under `dependencies`). With no root + // copy there is nothing to dedupe TO, so the gate loses nothing. Not + // added to `optimizeDeps.include`: the optimizer already reaches + // signals through `solid-js`, and an include entry that doesn't + // resolve from the root logs a warning on every start. + const dedupe = + replaceDev && findPackageDir('@solidjs/signals', path.resolve(projectRoot || process.cwd())) + ? [...nestedDeps, '@solidjs/signals'] + : nestedDeps; const userTest = (userConfig as any).test ?? {}; const test = {} as any; @@ -1361,7 +1385,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // esbuild: { include: /\.ts$/ }, // resolve.conditions is handled per-environment in configEnvironment. resolve: { - dedupe: nestedDeps, + dedupe, }, optimizeDeps: { extensions: ['.tsrx'],