From c284541f0013502d4c71b1cff1cd5b3f094d4710 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Fri, 25 Sep 2026 18:11:58 -0500 Subject: [PATCH] fix(vite): load server pages unpicked in server environments The server-function handler imports a server page's file whole, so the ?pick=default copy was a second instance that registered the page's server function twice. Fixes #16. --- .changeset/server-page-single-instance.md | 5 ++++ src/vite/index.ts | 31 ++++++++++++++--------- test/vite.spec.ts | 15 +++++++++++ 3 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 .changeset/server-page-single-instance.md diff --git a/.changeset/server-page-single-instance.md b/.changeset/server-page-single-instance.md new file mode 100644 index 0000000..43a2f80 --- /dev/null +++ b/.changeset/server-page-single-instance.md @@ -0,0 +1,5 @@ +--- +"filesystem-routing": patch +--- + +In server-consumer environments, the Vite adapter now imports a server page's module (`server: true`) by its plain id instead of `?pick=...`, for every ref to that file. The server-function handler already imports the file whole, so a picked copy was a second module instance that registered the page's server function again under the same id. That second registration dropped the GET grant `@solidjs/router` makes, so client navigation to server pages answered 405. Client environments still pick. diff --git a/src/vite/index.ts b/src/vite/index.ts index 0868981..933ffcd 100644 --- a/src/vite/index.ts +++ b/src/vite/index.ts @@ -96,6 +96,17 @@ const HANDLER_REF = /^\$[A-Z]+$/; const isServerConsumer = (name: string, consumer: string | undefined) => (consumer ?? (name === "client" ? "client" : "server")) === "server"; +/** + * The id a route module ref is loaded from: its source plus its picks. On the + * server a server page's module is loaded whole, because the server-function + * handler imports it whole too, and a second (picked) instance would register + * the page's server function twice. + */ +const toModuleId = (route: RouteManifestEntry, ref: ModuleRef, serverConsumer: boolean) => + serverConsumer && route.server === true && ref.src === route.$component?.src + ? ref.src + : toPickId(ref.src, ref.pick); + /** * The client's view of a shared server manifest: handler refs removed. A * client bundle can never invoke a request handler, but serializing its ref @@ -197,9 +208,6 @@ export function fileRoutes(options: FileRoutesOptions = {}): PluginOption[] { writeFileSync(file, contents); } - /** The id a route module ref is loaded from: its source plus its picks. */ - const toModuleId = (ref: ModuleRef) => toPickId(ref.src, ref.pick); - return [ { name: "filesystem-routing", @@ -252,8 +260,9 @@ export function fileRoutes(options: FileRoutesOptions = {}): PluginOption[] { // A client environment on the shared router never serializes // handler refs (see load), so their modules must not become its // build entries either. + const serverConsumer = isServerConsumer(name, (_config as any).consumer); const routes = - options.routers?.[name] || isServerConsumer(name, (_config as any).consumer) + options.routers?.[name] || serverConsumer ? await router.getRoutes() : stripHandlerRefs(await router.getRoutes()); @@ -264,7 +273,7 @@ export function fileRoutes(options: FileRoutesOptions = {}): PluginOption[] { for (const route of routes) { for (const [key, ref] of Object.entries(route)) { if (ref && key.startsWith("$") && !key.startsWith("$$") && !(ref as ModuleRef).eager) { - input.push(toModuleId(ref as ModuleRef)); + input.push(toModuleId(route, ref as ModuleRef, serverConsumer)); } } } @@ -297,23 +306,21 @@ export function fileRoutes(options: FileRoutesOptions = {}): PluginOption[] { const environmentName = this.environment.name; const router = getRouter(environmentName); let routes = (router ? await router.getRoutes() : []) ?? []; + const serverConsumer = isServerConsumer(environmentName, this.environment.config.consumer); // The shared router serves every environment, so the split is this // adapter's job: client consumers get the manifest without handler // refs. An explicit per-environment router already IS the split — // serve whatever it emits. - if ( - !options.routers?.[environmentName] && - !isServerConsumer(environmentName, this.environment.config.consumer) - ) { + if (!options.routers?.[environmentName] && !serverConsumer) { routes = stripHandlerRefs(routes); } - const serializeEntry = (entry: unknown) => + const serializeEntry = (entry: RouteManifestEntry) => JSON.stringify(entry, (key, value) => { if (value === undefined) return undefined; if (key.startsWith("$$")) { - const buildId = toModuleId(value); + const buildId = toModuleId(entry, value, serverConsumer); const refs: Record = {}; for (const pick of value.pick) { @@ -325,7 +332,7 @@ export function fileRoutes(options: FileRoutesOptions = {}): PluginOption[] { .join(", ")} })$_` }; } else if (key.startsWith("$")) { - const buildId = toModuleId(value); + const buildId = toModuleId(entry, value, serverConsumer); // With code splitting off — or a ref the convention marked // `eager` — the ref is delivered eagerly: a namespace import // (named imports would fail on the synthetic `$css` pick) diff --git a/test/vite.spec.ts b/test/vite.spec.ts index e7d05af..4b9c4e6 100644 --- a/test/vite.spec.ts +++ b/test/vite.spec.ts @@ -385,6 +385,21 @@ describe("fileRoutes vite plugin", () => { expect(code).toMatch(/import\('[^']*index\.tsx\?pick=default&pick=\$css&lang\.tsx'\)/); }); + it("loads a server page's module whole on the server, so it is instantiated once", async () => { + const directory = root(); + const code = await loadWith( + createPlugin(directory, { serverComponents: true }), + directory, + "ssr" + ); + + expect(code).toMatch(/import \* as routeModule\d from '[^']*\[id\]\.tsx';/); + expect(code).toMatch(/import { route as routeData\d } from '[^']*\[id\]\.tsx';/); + expect(code).not.toMatch(/\[id\]\.tsx\?pick=/); + // a client page on the server is still picked and split + expect(code).toMatch(/import\('[^']*index\.tsx\?pick=default&pick=\$css&lang\.tsx'\)/); + }); + it("is not a build input — the stub is inlined into the manifest", async () => { const plugin = createPlugin(root(), { serverComponents: true, buildInputs: "client" }); const input: string[] = (await plugin.configEnvironment("client", {}, { command: "build" }))