From b2992c4c1933fd18fbd2ee5f98e0edd69c22c6d6 Mon Sep 17 00:00:00 2001 From: kavhnr Date: Wed, 2 Sep 2026 17:34:42 -0600 Subject: [PATCH] fix: serialize each route view path once --- .changeset/clean-page-route-paths.md | 5 +++ src/vite/index.ts | 15 ++++---- test/vite.spec.ts | 52 ++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 .changeset/clean-page-route-paths.md diff --git a/.changeset/clean-page-route-paths.md b/.changeset/clean-page-route-paths.md new file mode 100644 index 0000000..9ef9004 --- /dev/null +++ b/.changeset/clean-page-route-paths.md @@ -0,0 +1,5 @@ +--- +"filesystem-routing": patch +--- + +Serialize flat and nested route paths separately so generated page routes do not overwrite manifest paths from shared fields. diff --git a/src/vite/index.ts b/src/vite/index.ts index 4279b50..f3826b5 100644 --- a/src/vite/index.ts +++ b/src/vite/index.ts @@ -334,11 +334,12 @@ export function fileRoutes(options: FileRoutesOptions = {}): PluginOption[] { .replaceAll('"_$(', "(") .replaceAll(')$_"', ")"); - // Entries are emitted once and both views reference them, so the - // nested view costs its own paths and nothing else. - const bindings = routes.map( - (route, index) => `const route${index} = ${serializeEntry(route)};` - ); + // Emit the fields that both views share once. Each view adds its own + // path, so the nested view does not overwrite the flat manifest path. + const bindings = routes.map((route, index) => { + const { path: _path, ...fields } = route; + return `const route${index} = ${serializeEntry(fields)};`; + }); const tree = buildRouteTree( routes @@ -361,7 +362,9 @@ export function fileRoutes(options: FileRoutesOptions = {}): PluginOption[] { return `${js.getImportStatements()} ${bindings.join("\n")} -const routes = [${routes.map((_, index) => `route${index}`).join(", ")}]; +const routes = [${routes + .map((route, index) => `{ path: ${JSON.stringify(route.path)}, ...route${index} }`) + .join(", ")}]; export default routes; export const pageRoutes = ${serializeTree(tree)}; `; diff --git a/test/vite.spec.ts b/test/vite.spec.ts index d0af478..96ccbb7 100644 --- a/test/vite.spec.ts +++ b/test/vite.spec.ts @@ -62,7 +62,7 @@ describe("fileRoutes vite plugin", () => { expect(code).toMatch( /import { route as routeData0 } from '[^']*\[id\]\.tsx\?pick=route&lang\.tsx';/ ); - expect(code).toContain(`"path":"/blog/:id"`); + expect(code).toContain(`path: "/blog/:id"`); expect(code).toContain(`'route': routeData0`); }); @@ -76,7 +76,7 @@ describe("fileRoutes vite plugin", () => { const code = await loadVirtualModule(root); // the layout keeps its group segment in the flat view... - expect(code).toContain(`"path":"/(app)"`); + expect(code).toContain(`path: "/(app)"`); // ...and loses it in the nested one, where the child is relative to it expect(code).toMatch( /export const pageRoutes = \[\{ \.\.\.route\d, id: "\/\(app\)", path: "\/", children: \[\{ \.\.\.route\d, id: "\/dashboard", path: "\/dashboard" \}\] \}\]/ @@ -85,6 +85,52 @@ describe("fileRoutes vite plugin", () => { expect(code.match(/pick=default&pick=\$css&lang\.tsx'\)/g)?.length).toBe(2); }); + it("assigns each route view its own path once", async () => { + const root = createRouteTree({}); + const router = { + getRoutes: async () => [ + { path: "/(app)", page: true, metadata: { level: 0 } }, + { path: "/(app)/dashboard", page: true, metadata: { level: 1 } }, + { path: "/(app)/dashboard/settings", page: true, metadata: { level: 2 } } + ] + } as unknown as PageFileSystemRouter; + + const code = await loadWith(createPlugin(root, { router }), root); + const sharedEntries = [...code.matchAll(/^const route\d+ = (\{.*\});$/gm)].map(match => + JSON.parse(match[1]) + ); + + // A shared entry must not contain either view's path. Otherwise the + // pageRoutes spread assigns the flat path before it assigns the tree path. + expect(sharedEntries).toHaveLength(3); + expect(sharedEntries.every(entry => !Object.hasOwn(entry, "path"))).toBe(true); + + const module = await import( + `data:text/javascript;base64,${Buffer.from(code).toString("base64")}` + ); + + expect(module.default.map((route: { path: string }) => route.path)).toEqual([ + "/(app)", + "/(app)/dashboard", + "/(app)/dashboard/settings" + ]); + expect(module.pageRoutes).toMatchObject([ + { + id: "/(app)", + path: "/", + children: [ + { + id: "/dashboard", + path: "/dashboard", + children: [{ id: "/settings", path: "/settings" }] + } + ] + } + ]); + // Both views still share the non-path fields and their nested values. + expect(module.default[0].metadata).toBe(module.pageRoutes[0].metadata); + }); + it("resolves only the virtual module id", async () => { const [plugin] = fileRoutes() as any[]; expect(moduleId).toBe("virtual:file-routes"); @@ -229,7 +275,7 @@ describe("fileRoutes vite plugin", () => { expect(code).not.toContain("/api/users"); // the page half of a page+handler module stays routable expect(code).toMatch(/import\('[^']*users\.tsx\?pick=default&pick=\$css&lang\.tsx'\)/); - expect(code).toContain('"path":"/users"'); + expect(code).toContain('path: "/users"'); }); it("splits by consumer, not by name, when the environment declares one", async () => {