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'],