diff --git a/.changeset/dev-false-production-runtime.md b/.changeset/dev-false-production-runtime.md new file mode 100644 index 0000000..1bbced6 --- /dev/null +++ b/.changeset/dev-false-production-runtime.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +`dev: false` now serves Solid's production builds under `vite dev`, as documented (#62). The plugin already dropped its own `development` condition, but Vite's default `development|production` condition resolves to `development` in any non-production mode, so the client still pre-bundled `solid.dev.js` / `web.dev.js` and SSR loaded `server.dev.js`. With `dev: false` the plugin now rewrites that condition to `production` under `vite dev`; vitest runs and `vite build` keep Vite's default resolution. Vite conditions apply per environment, so other packages that ship a `development` export also get their production builds in that case. It also stops emitting HMR refresh wrappers and pre-bundling `solid-js/refresh`, because the production refresh runtime is an inert stub that only warns; edits fall back to a full reload. The diagnostics bridge also stays off with `dev: false` unless `observe` is on, since its channels exist only in the dev and observe builds. diff --git a/examples/ssr/package.json b/examples/ssr/package.json index 118f7cb..7bebe63 100644 --- a/examples/ssr/package.json +++ b/examples/ssr/package.json @@ -8,7 +8,7 @@ "build:client": "vite build --outDir dist/client", "build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server", "serve": "NODE_ENV=production node server.js", - "test": "node test/run.mjs && node test/boundary.mjs && node test/scan.mjs && node test/server-components-define.mjs && node test/ts-module-url.mjs" + "test": "node test/run.mjs && node test/boundary.mjs && node test/scan.mjs && node test/server-components-define.mjs && node test/ts-module-url.mjs && node test/dev-false.mjs" }, "devDependencies": { "vite": "^8.2.1", diff --git a/examples/ssr/test/dev-false.mjs b/examples/ssr/test/dev-false.mjs new file mode 100644 index 0000000..301fae9 --- /dev/null +++ b/examples/ssr/test/dev-false.mjs @@ -0,0 +1,222 @@ +// Regression for #62. With `dev: false`, `vite dev` must resolve Solid's +// production builds: the plugin drops its own `development` condition, but +// Vite's default `development|production` token still resolves to +// `development` in any non-production mode, so the client was pre-bundling +// `dist/solid.dev.js` and SSR was loading `dist/server.dev.js`. +// +// Asserts, for client and SSR: +// - `dev: false` during `vite dev` (`command === 'serve'`, mode not +// `test`) rewrites that token to `production` and `resolveId('solid-js')` +// ends at `dist/solid.js` / `dist/server.js`, +// - default serve (`dev` unset and `dev: true`), `mode: 'test'` (including +// `dev: false`, which must not opt tests into the rewrite), and +// `vite build` leave the token alone. Resolution then follows Vite: +// `development` under serve (test mode also injects `browser`, so SSR +// lands on the browser dev build) and `production` under build. +// +// Serve goes through `pluginContainer.resolveId`. When the client optimizer +// rewrites that id to a prebundle, the recorded `metadata.src` is the Solid +// file it resolved. Build uses Vite's `createIdResolver` (`resolveId` against +// the build config; `vite build` pins NODE_ENV=production). SSR externalizes +// `solid-js` during build, so that case sets `ssr.noExternal` to ask the same +// resolver for the file path. No browser. +// Requires the plugin built (pnpm build at the repo root). +// Usage: node test/dev-false.mjs + +import { fileURLToPath } from 'node:url'; +import { mkdtempSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createBuilder, createIdResolver, createServer } from 'vite'; +import solidPlugin from '@solidjs/vite-plugin'; + +const exampleDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const TOKEN = 'development|production'; +const CLIENT_PROD = '/dist/solid.js'; +const CLIENT_DEV = '/dist/solid.dev.js'; +const SSR_PROD = '/dist/server.js'; +const SSR_DEV = '/dist/server.dev.js'; + +const results = []; +function record(name, ok, detail = '') { + results.push({ name, ok, detail }); + console.log( + ` [dev-false] ${ok ? 'PASS' : 'FAIL'} ${name}${detail && !ok ? ` — ${detail}` : ''}`, + ); +} + +function inlineConfig(solidOptions, { mode } = {}) { + return { + root: exampleDir, + configFile: false, + logLevel: 'silent', + ...(mode ? { mode } : {}), + plugins: [solidPlugin({ ssr: true, ...solidOptions })], + server: { middlewareMode: true, hmr: false, watch: null }, + optimizeDeps: { noDiscovery: true }, + }; +} + +function fileName(id) { + return String(id ?? '') + .split('?')[0] + .replace(/\\/g, '/'); +} + +/** Package file `resolveId` selected, following an optimized dep back to its src. */ +function packagePath(environment, resolved) { + const id = fileName(typeof resolved === 'string' ? resolved : resolved?.id); + if (id.includes('/node_modules/solid-js/dist/')) return id; + const meta = environment.depsOptimizer?.metadata; + const info = meta?.optimized?.['solid-js'] ?? meta?.discovered?.['solid-js']; + return fileName(info?.src || ''); +} + +function conditionsFollow(conditions, force) { + return force + ? conditions.includes('production') && !conditions.includes(TOKEN) + : conditions.includes(TOKEN) && !conditions.includes('production'); +} + +async function withNodeEnv(nodeEnv, fn) { + const before = process.env.NODE_ENV; + process.env.NODE_ENV = nodeEnv; + try { + return await fn(); + } finally { + if (before === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = before; + } +} + +async function resolveServe(solidOptions, { mode } = {}) { + // `vite dev` leaves NODE_ENV unset, and resolveConfig then pins development. + // A fresh cache so a previous case's prebundle can't answer this resolve. + return withNodeEnv('development', async () => { + const server = await createServer({ + ...inlineConfig(solidOptions, { mode }), + cacheDir: mkdtempSync(path.join(os.tmpdir(), 'solid-dev-false-')), + }); + try { + const [client, ssr] = await Promise.all([ + server.environments.client.pluginContainer.resolveId('solid-js'), + server.environments.ssr.pluginContainer.resolveId('solid-js'), + ]); + return { + clientConditions: server.environments.client.config.resolve.conditions, + ssrConditions: server.environments.ssr.config.resolve.conditions, + client: packagePath(server.environments.client, client), + ssr: packagePath(server.environments.ssr, ssr), + }; + } finally { + await server.close(); + } + }); +} + +async function resolveBuild(solidOptions) { + // `vite build` pins NODE_ENV=production, which is what expands the token. + // `ssr.noExternal` only so the externalized SSR id is the file path; it + // does not rewrite conditions. + return withNodeEnv('production', async () => { + const builder = await createBuilder({ + ...inlineConfig(solidOptions), + ssr: { noExternal: ['solid-js'] }, + }); + const resolveId = createIdResolver(builder.config); + const [client, ssr] = await Promise.all([ + resolveId(builder.environments.client, 'solid-js'), + resolveId(builder.environments.ssr, 'solid-js'), + ]); + return { + clientConditions: builder.environments.client.config.resolve.conditions, + ssrConditions: builder.environments.ssr.config.resolve.conditions, + client: packagePath(builder.environments.client, client), + ssr: packagePath(builder.environments.ssr, ssr), + }; + }); +} + +function check(name, result, { force, client, ssr }) { + const listed = `client [${result.clientConditions.join(', ')}] -> ${result.client}; ssr [${result.ssrConditions.join(', ')}] -> ${result.ssr}`; + record( + `${name}: client conditions ${force ? 'force production' : 'keep the token'}`, + conditionsFollow(result.clientConditions, force), + listed, + ); + record( + `${name}: ssr conditions ${force ? 'force production' : 'keep the token'}`, + conditionsFollow(result.ssrConditions, force), + listed, + ); + record( + `${name}: client resolveId ends at ${client}`, + result.client.endsWith(client), + result.client, + ); + record(`${name}: ssr resolveId ends at ${ssr}`, result.ssr.endsWith(ssr), result.ssr); +} + +const cases = [ + { + name: 'vite dev with dev: false', + run: () => resolveServe({ dev: false }), + force: true, + client: CLIENT_PROD, + ssr: SSR_PROD, + }, + { + name: 'vite dev (dev unset)', + run: () => resolveServe({}), + force: false, + client: CLIENT_DEV, + ssr: SSR_DEV, + }, + { + name: 'vite dev with dev: true', + run: () => resolveServe({ dev: true }), + force: false, + client: CLIENT_DEV, + ssr: SSR_DEV, + }, + { + name: "vite dev in mode 'test'", + run: () => resolveServe({}, { mode: 'test' }), + force: false, + client: CLIENT_DEV, + ssr: CLIENT_DEV, + }, + { + name: "vite dev in mode 'test' with dev: false", + run: () => resolveServe({ dev: false }, { mode: 'test' }), + force: false, + client: CLIENT_DEV, + ssr: CLIENT_DEV, + }, + { + name: 'vite build (dev unset)', + run: () => resolveBuild({}), + force: false, + client: CLIENT_PROD, + ssr: SSR_PROD, + }, + { + name: 'vite build with dev: false', + run: () => resolveBuild({ dev: false }), + force: false, + client: CLIENT_PROD, + ssr: SSR_PROD, + }, +]; + +for (const entry of cases) { + check(entry.name, await entry.run(), entry); +} + +const failed = results.filter((r) => !r.ok); +console.log(`\n${results.length - failed.length}/${results.length} dev-false assertions passed`); +if (failed.length) { + console.log('\nFailures:'); + for (const f of failed) console.log(` ${f.name} — ${f.detail}`); +} +process.exit(failed.length ? 1 : 0); diff --git a/src/index.ts b/src/index.ts index cfc9af2..ced3064 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1143,6 +1143,9 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // `start: true` is sugar for the empty options bag — one start mode, // two spellings — so normalize here and let everything downstream see a // single shape (`false` behaves exactly like omission). + // The diagnostics channels exist only in Solid's dev and observe builds. + const diagnosticsEnabled = + options.diagnostics !== false && (options.dev !== false || options.observe === true); const startOptions: StartOptions | null = options.start === true ? {} : options.start || null; const styleFilterOptions = startOptions?.css?.filter; @@ -1177,6 +1180,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { let needHmr = false; let replaceDev = false; + let forceProduction = false; let observe = false; // Resolved absolute path of the start-mode document shell (normalized to // forward slashes, matching Vite ids), reported back by the start plugin's @@ -1382,6 +1386,7 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { observe = options.observe === true; projectRoot = userConfig.root || projectRoot; isTestMode = userConfig.mode === 'test'; + forceProduction = options.dev === false && command === 'serve' && !isTestMode; // Per-vitest-project posture: the client posture (browser conditions, // dom codegen, jsdom default) is right for DOM component tests but // wrong for server-runtime unit tests. A project that explicitly opts @@ -1583,7 +1588,10 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // Dev refresh wrappers import the solid-js/refresh runtime in // every mode; pre-bundle it up front so its discovery doesn't // trigger a re-optimize + full reload on first use. - ...(command === 'serve' && options.hot !== false && !options.refresh?.disabled + ...(command === 'serve' && + options.dev !== false && + options.hot !== false && + !options.refresh?.disabled ? [REFRESH_RUNTIME_SOURCE] : []), // The server-components client runtime is imported by the @@ -1665,7 +1673,13 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { // keep the default server conditions instead, so the framework's // real server build resolves (isServer true). ...(isTestMode && !serverTestPosture && !opts.isSsrTargetWebworker ? ['browser'] : []), - ...config.resolve.conditions, + // Vite's `development|production` resolves to `development` in any + // non-production mode, which would undo `dev: false` under `vite dev`. + ...(forceProduction + ? config.resolve.conditions.map((c) => + c === 'development|production' ? 'production' : c, + ) + : config.resolve.conditions), ]; // Vite seeds only the `client` environment from the top-level @@ -1842,6 +1856,8 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { needHmr = config.command === 'serve' && config.mode !== 'production' && + // The production `solid-js/refresh` runtime is an inert stub. + options.dev !== false && options.hot !== false && !options.refresh?.disabled; }, @@ -2499,7 +2515,7 @@ export default manifest;`; serverComponents, ssr: !!options.ssr, styleFilter: filterDevStyles, - diagnostics: options.diagnostics ?? 'auto', + diagnostics: diagnosticsEnabled ? (options.diagnostics ?? 'auto') : false, performanceTracks: performanceTracksOptions !== null, onDocumentResolved(documentPath) { // Normalize to forward slashes to match Vite's transform ids. @@ -2516,7 +2532,7 @@ export default manifest;`; // plugin no-ops itself for builds and preview via `apply`, and in the // default auto mode additionally disables itself unless the app has // `@solidjs/diagnostics` installed). - if (options.diagnostics !== false) { + if (diagnosticsEnabled) { plugins.push(solidDiagnostics(options.diagnostics === true ? true : 'auto')); }