Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/server-page-single-instance.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 19 additions & 12 deletions src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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());

Expand All @@ -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));
}
}
}
Expand Down Expand Up @@ -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<string, string> = {};
for (const pick of value.pick) {
Expand All @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions test/vite.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }))
Expand Down
Loading