Skip to content
Closed
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/clean-page-route-paths.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 9 additions & 6 deletions src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)};
`;
Expand Down
52 changes: 49 additions & 3 deletions test/vite.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
});

Expand All @@ -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" \}\] \}\]/
Expand All @@ -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");
Expand Down Expand Up @@ -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 () => {
Expand Down