From 2a714d4a4551b377d63dfa2631a5414be1de9bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sat, 3 Oct 2026 17:51:27 -0300 Subject: [PATCH] fix: pre-bundle the server-function client runtime Compiled server-function references import the client runtime (`@solidjs/web/server-functions` by default), but the dependency scanner never runs the server-function transform that adds that import. Only `serverFunctions.components` put the runtime in `optimizeDeps.include`, so with plain `serverFunctions` the first "use server" module discovered it late and forced a re-optimize and full reload (in Vitest browser mode: "Vite unexpectedly reloaded a test"). Under `serve`, include the client runtime whenever `serverFunctions` is on: the default, or `runtime.client` when it is a package specifier. A path or an alias is app source, and a virtual id doesn't resolve for `include`, so those stay out. The server-components branch is unchanged and the default runtime is listed once. Tests (examples/start-ssr/test/components-warning.mjs): included with `serverFunctions: true` with and without `ssr`, with a custom package runtime, and beside the components entries when both are on; not included without `serverFunctions`, in build, or for path, virtual or alias runtimes; listed once with components on. --- ...rver-functions-client-runtime-prebundle.md | 5 + .../start-ssr/test/components-warning.mjs | 109 +++++++++++++++++- src/index.ts | 36 ++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 .changeset/server-functions-client-runtime-prebundle.md diff --git a/.changeset/server-functions-client-runtime-prebundle.md b/.changeset/server-functions-client-runtime-prebundle.md new file mode 100644 index 00000000..6524c959 --- /dev/null +++ b/.changeset/server-functions-client-runtime-prebundle.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +With `serverFunctions` on, the dev server now pre-bundles the server-function client runtime even without `components` (`@solidjs/web/server-functions`, or `runtime.client` when it names a package), so a cold cache no longer re-optimizes and reloads on the first `"use server"` module. In Vitest browser mode that reload showed up as "Vite unexpectedly reloaded a test". diff --git a/examples/start-ssr/test/components-warning.mjs b/examples/start-ssr/test/components-warning.mjs index 94d48c25..beae0090 100644 --- a/examples/start-ssr/test/components-warning.mjs +++ b/examples/start-ssr/test/components-warning.mjs @@ -13,7 +13,13 @@ // derived serverComponents flag) matches `true` exactly, and stays off // when the option is off, // - under full SSR start mode `'external'` is redundant but harmless: -// silent, exactly like `true`. +// silent, exactly like `true`, +// - independent of server components: `serverFunctions` alone (with or +// without `ssr`) pre-bundles the server-function client runtime under +// `serve` (the dependency scan never runs the transform that adds its +// import), listed once when components also ask for it; not without +// `serverFunctions`, not in build, and a custom `runtime.client` only as +// a package specifier, beside the components entries when both are on. // // Pure resolveConfig — no dev server, no browser. Requires the plugin built // (pnpm build at the repo root). Usage: node test/components-warning.mjs @@ -81,6 +87,14 @@ async function resolveWith(solidOptions) { config.optimizeDeps.include.includes('@solidjs/web/frames') && config.optimizeDeps.include.includes('@solidjs/web/server-functions'), ); + const runtimeEntries = config.optimizeDeps.include.filter( + (dep) => dep === '@solidjs/web/server-functions', + ); + record( + 'enabled: server-function client runtime listed once', + runtimeEntries.length === 1, + config.optimizeDeps.include.join(', '), + ); } // ---- `'external'` in the identical config: silent, still enabled --------- @@ -123,6 +137,84 @@ async function resolveWith(solidOptions) { config.optimizeDeps.include.includes('@solidjs/web/performance-tracks'), config.optimizeDeps.include.join(', '), ); + // Compiled server-function references import the client runtime, but the + // dependency scanner never runs the transform that adds that import. + // Without the entry, the first "use server" module the browser loads + // discovers it late: re-optimize and full reload. + record( + 'serverFunctions without components: server-function client runtime pre-bundled', + config.optimizeDeps.include.includes('@solidjs/web/server-functions'), + config.optimizeDeps.include.join(', '), + ); +} + +// ---- serverFunctions without ssr: same entry ----------------------------- +{ + const { config } = await resolveWith({ serverFunctions: true }); + record( + 'serverFunctions without ssr: server-function client runtime pre-bundled', + config.optimizeDeps.include.includes('@solidjs/web/server-functions'), + config.optimizeDeps.include.join(', '), + ); +} + +// ---- serverFunctions off: no server-function runtime pre-bundle ----------- +{ + const { config } = await resolveWith({ ssr: true }); + record( + 'serverFunctions off: server-function client runtime not pre-bundled', + !config.optimizeDeps.include.includes('@solidjs/web/server-functions'), + config.optimizeDeps.include.join(', '), + ); +} + +// ---- custom runtime: only a package specifier is pre-bundled -------------- +{ + const client = '@acme/server-runtime/client'; + const { config } = await resolveWith({ + ssr: true, + serverFunctions: { runtime: { server: '@acme/server-runtime/server', client } }, + }); + record( + 'custom runtime (package): runtime.client pre-bundled instead of the default', + config.optimizeDeps.include.includes(client) && + !config.optimizeDeps.include.includes('@solidjs/web/server-functions'), + config.optimizeDeps.include.join(', '), + ); +} +{ + const client = '@acme/server-runtime/client'; + const { config } = await resolveWith({ + ssr: true, + serverFunctions: { + components: true, + runtime: { server: '@acme/server-runtime/server', client }, + }, + }); + record( + 'components + custom runtime (package): runtime.client beside the components entries', + config.optimizeDeps.include.includes(client) && + config.optimizeDeps.include.includes('@solidjs/web/server-functions'), + config.optimizeDeps.include.join(', '), + ); +} +for (const client of [ + './src/sf-client.ts', + '/src/sf-client.ts', + 'virtual:sf-client', + '@/sf-client', + '~/sf-client', +]) { + const { config } = await resolveWith({ + ssr: true, + serverFunctions: { runtime: { server: client, client } }, + }); + record( + `custom runtime (${client}): neither it nor the default pre-bundled`, + !config.optimizeDeps.include.includes(client) && + !config.optimizeDeps.include.includes('@solidjs/web/server-functions'), + config.optimizeDeps.include.join(', '), + ); } // ---- build: optimizeDeps is serve-only, the entries stay out -------------- @@ -138,6 +230,21 @@ async function resolveWith(solidOptions) { config.optimizeDeps.include.join(', '), ); } +{ + const config = await resolveConfig( + { + root: exampleDir, + configFile: false, + plugins: [solidPlugin({ ssr: true, serverFunctions: true })], + }, + 'build', + ); + record( + 'build: server-function client runtime not in optimizeDeps.include', + !config.optimizeDeps.include.includes('@solidjs/web/server-functions'), + config.optimizeDeps.include.join(', '), + ); +} // ---- full SSR start mode: `'external'` is redundant but harmless --------- { diff --git a/src/index.ts b/src/index.ts index b752f409..d3a03fd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -582,6 +582,18 @@ function findPackageDir(name: string, root: string): string | undefined { } } +/** + * Whether `id` looks like a package specifier (`name`, `name/sub`, + * `@scope/name/sub`), by syntax alone. Relative and absolute paths, ids with a + * protocol or drive (`virtual:`, `node:`, `C:`), subpath imports (`#x`) and + * aliases like `~/x` or `@/x` don't. + */ +function isPackageSpecifier(id: string): boolean { + if (!id || /^[./\\#~\0]/.test(id) || id.includes(':')) return false; + if (id.startsWith('@')) return /^@[^/]+\/[^/]/.test(id); + return true; +} + function getJestDomExport(setupFiles: string[], root: string) { if (setupFiles?.some((file) => /jest-dom/.test(file))) return undefined; @@ -1057,6 +1069,21 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { const serverComponentsOption = typeof options.serverFunctions === 'object' ? options.serverFunctions.components : undefined; const serverComponents = !!serverComponentsOption; + // The client runtime compiled server-function references import (the + // server-functions plugin's own default unless `runtime` is set), kept only + // when it names a package. A relative path or an alias is app source: the + // optimizer would bundle a copy nothing loads or, for `@/x`, serve that copy + // so edits stop showing up. A virtual id or `/src/x` doesn't resolve for + // `include` and only warns. + const serverFunctionsClientRuntime = options.serverFunctions + ? typeof options.serverFunctions === 'object' && options.serverFunctions.runtime + ? options.serverFunctions.runtime.client + : '@solidjs/web/server-functions' + : undefined; + const serverFunctionsClientDep = + serverFunctionsClientRuntime && isPackageSpecifier(serverFunctionsClientRuntime) + ? serverFunctionsClientRuntime + : undefined; // `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). @@ -1483,6 +1510,15 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { ...(command === 'serve' && serverComponents ? ['@solidjs/web/frames', '@solidjs/web/server-functions'] : []), + // Plain `serverFunctions` needs the runtime too: the scanner never + // runs the transform that adds its import, so the first "use server" + // module forces the same re-optimize + full reload (in Vitest + // browser mode, "Vite unexpectedly reloaded a test"). + ...(command === 'serve' && + serverFunctionsClientDep && + !(serverComponents && serverFunctionsClientDep === '@solidjs/web/server-functions') + ? [serverFunctionsClientDep] + : []), // The attribution engine and the Chrome performance-tracks // recorder are subpaths the scanner only sees when the app's own // graph imports them; a consumer it never crawls (a linked