From 5a9af07368843f01eb7081fa9430a211db5b94fa Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 15:41:19 +0900 Subject: [PATCH 01/16] refactor(react-server): organize route tree utils --- .../react-server/src/entry/react-server.tsx | 4 +- .../react-server/src/features/router/tree.ts | 114 ++++++++++++++++++ packages/react-server/src/lib/router.test.ts | 6 +- packages/react-server/src/lib/router.tsx | 83 +++---------- 4 files changed, 134 insertions(+), 73 deletions(-) create mode 100644 packages/react-server/src/features/router/tree.ts diff --git a/packages/react-server/src/entry/react-server.tsx b/packages/react-server/src/entry/react-server.tsx index fa694efd5..4ed233023 100644 --- a/packages/react-server/src/entry/react-server.tsx +++ b/packages/react-server/src/entry/react-server.tsx @@ -28,7 +28,7 @@ import { createError, getErrorContext, } from "../lib/error"; -import { generateRouteTree, renderRouteMap } from "../lib/router"; +import { generateRouteModuleTree, renderRouteMap } from "../lib/router"; const debug = createDebug("react-server:rsc"); @@ -147,7 +147,7 @@ function createRouter() { eager: true, }, ); - const tree = generateRouteTree( + const tree = generateRouteModuleTree( objectMapKeys(glob, (_v, k) => k.slice("/src/routes".length)), ); diff --git a/packages/react-server/src/features/router/tree.ts b/packages/react-server/src/features/router/tree.ts new file mode 100644 index 000000000..853d9370b --- /dev/null +++ b/packages/react-server/src/features/router/tree.ts @@ -0,0 +1,114 @@ +import { sortBy, tinyassert } from "@hiogawa/utils"; +import { getPathPrefixes, normalizePathname } from "./utils"; + +export interface BaseRouteEntry { + page?: T; + layout?: T; + error?: T; +} + +// generate tree from glob entries such as generated by +// import.meta.glob("/**/(page|layout|error).(js|jsx|ts|tsx)") +export function generateRouteTree( + globEntries: Record, +): TreeNode> { + const entries: Record> = {}; + for (const [k, v] of Object.entries(globEntries)) { + const m = k.match(/^(.*)\/(page|layout|error)\.\w*$/); + tinyassert(m && 1 in m && 2 in m); + ((entries[m[1]] ??= {}) as any)[m[2]] = v; + } + + const flatTree = Object.entries(entries).map(([k, v]) => ({ + keys: k.split("/"), + value: v, + })); + const tree = createTree(flatTree); + + // sort to match static route first before dynamic route + sortDynamicRoutes(tree); + + return tree; +} + +function sortDynamicRoutes(tree: TreeNode) { + if (tree.children) { + tree.children = Object.fromEntries( + sortBy(Object.entries(tree.children), ([k]) => k.includes("[")), + ); + for (const v of Object.values(tree.children)) { + sortDynamicRoutes(v); + } + } +} + +// TODO: support +export function matchRouteTree(tree: TreeNode, pathname: string) { + pathname = normalizePathname(pathname); + const prefixes = getPathPrefixes(pathname); + + let node = tree; + let nodes: TreeNode[] = []; + let params: Record = {}; + for (const prefix of prefixes) { + const key = prefix.split("/").at(-1)!; + const next = matchRouteChild(key, node); + if (next?.child) { + node = next.child; + if (next.param) { + params = { ...params, [next.param]: key }; + } + } else { + node = initTreeNode(); + } + nodes.push(node); + } + return { nodes }; +} + +export function matchRouteChild(input: string, node: TreeNode) { + if (!node.children) { + return; + } + // TODO: sort to dynmaic one come last + // TODO: catch-all route + for (const [segment, child] of Object.entries(node.children)) { + const m = segment.match(/^\[(.*)\]$/); + if (m) { + tinyassert(1 in m); + return { param: m[1], child }; + } + if (segment === input) { + return { child }; + } + } + return; +} + +// +// general tree utils copied from vite-glob-routes +// https://github.com/hi-ogawa/vite-plugins/blob/c2d22f9436ef868fc413f05f243323686a7aa143/packages/vite-glob-routes/src/react-router/route-utils.ts#L15-L22 +// + +export type TreeNode = { + value?: T; + children?: Record>; +}; + +export function initTreeNode(): TreeNode { + return {}; +} + +function createTree(entries: { value: T; keys: string[] }[]): TreeNode { + const root = initTreeNode(); + + for (const e of entries) { + let node = root; + for (const key of e.keys) { + node = (node.children ??= {})[key] ??= initTreeNode(); + } + node.value = e.value; + } + + return root; +} diff --git a/packages/react-server/src/lib/router.test.ts b/packages/react-server/src/lib/router.test.ts index d599bc21d..b0ee078b6 100644 --- a/packages/react-server/src/lib/router.test.ts +++ b/packages/react-server/src/lib/router.test.ts @@ -1,9 +1,9 @@ import { range } from "@hiogawa/utils"; import { describe, expect, it } from "vitest"; import { getPathPrefixes } from "../features/router/utils"; -import { generateRouteTree, renderRouteMap } from "./router"; +import { generateRouteModuleTree, renderRouteMap } from "./router"; -describe(generateRouteTree, () => { +describe(generateRouteModuleTree, () => { it("basic", async () => { const files = [ "/layout.tsx", @@ -18,7 +18,7 @@ describe(generateRouteTree, () => { "/demo/page.tsx", ]; const input = Object.fromEntries(files.map((k) => [k, { default: k }])); - const tree = generateRouteTree(input); + const tree = generateRouteModuleTree(input); expect(tree).toMatchInlineSnapshot(` { "children": { diff --git a/packages/react-server/src/lib/router.tsx b/packages/react-server/src/lib/router.tsx index 5cf99d799..6c0a15077 100644 --- a/packages/react-server/src/lib/router.tsx +++ b/packages/react-server/src/lib/router.tsx @@ -1,5 +1,10 @@ -import { objectHas, sortBy, tinyassert } from "@hiogawa/utils"; +import { tinyassert } from "@hiogawa/utils"; import React from "react"; +import { + type TreeNode, + generateRouteTree, + initTreeNode, +} from "../features/router/tree"; import { getPathPrefixes, normalizePathname } from "../features/router/utils"; import { type ReactServerErrorContext, createError } from "./error"; @@ -19,40 +24,10 @@ interface RouteEntry { }; } -type RouteTreeNode = TreeNode; - -// generate component tree from glob import such as -// import.meta.glob("/**/(page|layout).(js|jsx|ts|tsx)" -export function generateRouteTree(globEntries: Record) { - const entries: Record = {}; - for (const [k, v] of Object.entries(globEntries)) { - const m = k.match(/^(.*)\/(page|layout|error)\.\w*$/); - tinyassert(m && 1 in m && 2 in m); - tinyassert(objectHas(v, "default"), `no deafult export found in '${k}'`); - ((entries[m[1]] ??= {}) as any)[m[2]] = v; - } - - const flatTree = Object.entries(entries).map(([k, v]) => ({ - keys: k.split("/"), - value: v, - })); - const tree = createTree(flatTree); - - // sort to match static route first before dynamic route - sortDynamicRoutes(tree); - - return tree; -} +type RouteModuleNode = TreeNode; -function sortDynamicRoutes(tree: TreeNode) { - if (tree.children) { - tree.children = Object.fromEntries( - sortBy(Object.entries(tree.children), ([k]) => k.includes("[")), - ); - for (const v of Object.values(tree.children)) { - sortDynamicRoutes(v); - } - } +export function generateRouteModuleTree(globEntries: Record) { + return generateRouteTree(globEntries) as RouteModuleNode; } // use own "use client" components as external @@ -60,13 +35,13 @@ function importRuntimeClient(): Promise { return import("@hiogawa/react-server/runtime-client" as string); } -function renderPage(node: RouteTreeNode, props: PageProps) { +function renderPage(node: RouteModuleNode, props: PageProps) { const Page = node.value?.page?.default ?? ThrowNotFound; return ; } async function renderLayout( - node: RouteTreeNode, + node: RouteModuleNode, props: PageProps, name: string, key?: string, @@ -95,7 +70,7 @@ async function renderLayout( } export async function renderRouteMap( - tree: RouteTreeNode, + tree: RouteModuleNode, request: Pick, ) { const url = serializeUrl(new URL(request.url)); @@ -125,7 +100,7 @@ export async function renderRouteMap( const props: BaseProps = { ...baseProps, params }; layouts[prefix] = await renderLayout(node, props, prefix); for (const prefix of prefixes.slice(i)) { - layouts[prefix] = await renderLayout(initNode(), props, prefix); + layouts[prefix] = await renderLayout(initTreeNode(), props, prefix); } pages[pathname] = renderPage(node, props); break; @@ -133,7 +108,7 @@ export async function renderRouteMap( params = { ...params, [next.param]: decodeURI(key) }; } } else { - node = initNode(); + node = initTreeNode(); } const props: BaseProps = { ...baseProps, params }; // re-mount subtree when dynamic segment changes @@ -194,7 +169,7 @@ export interface ErrorPageProps { const DYNAMIC_RE = /^\[(\w*)\]$/; const CATCH_ALL_RE = /^\[\.\.\.(\w*)\]$/; -function matchChild(input: string, node: RouteTreeNode) { +function matchChild(input: string, node: RouteModuleNode) { if (!node.children) { return; } @@ -215,31 +190,3 @@ function matchChild(input: string, node: RouteTreeNode) { } return; } - -// -// general tree utils copied from vite-glob-routes -// https://github.com/hi-ogawa/vite-plugins/blob/c2d22f9436ef868fc413f05f243323686a7aa143/packages/vite-glob-routes/src/react-router/route-utils.ts#L15-L22 -// - -type TreeNode = { - value?: T; - children?: Record>; -}; - -function initNode(): TreeNode { - return {}; -} - -function createTree(entries: { value: T; keys: string[] }[]): TreeNode { - const root = initNode(); - - for (const e of entries) { - let node = root; - for (const key of e.keys) { - node = (node.children ??= {})[key] ??= initNode(); - } - node.value = e.value; - } - - return root; -} From a53b30420c883c50d607dcc739e047a352b57a47 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 15:46:15 +0900 Subject: [PATCH 02/16] refactor: move more --- .../react-server/src/features/router/tree.ts | 15 +++++++--- packages/react-server/src/lib/router.tsx | 29 ++----------------- 2 files changed, 13 insertions(+), 31 deletions(-) diff --git a/packages/react-server/src/features/router/tree.ts b/packages/react-server/src/features/router/tree.ts index 853d9370b..0db675c52 100644 --- a/packages/react-server/src/features/router/tree.ts +++ b/packages/react-server/src/features/router/tree.ts @@ -42,7 +42,7 @@ function sortDynamicRoutes(tree: TreeNode) { } } -// TODO: support +// TODO: refactor with `renderRouteMap` export function matchRouteTree(tree: TreeNode, pathname: string) { pathname = normalizePathname(pathname); const prefixes = getPathPrefixes(pathname); @@ -52,6 +52,7 @@ export function matchRouteTree(tree: TreeNode, pathname: string) { let params: Record = {}; for (const prefix of prefixes) { const key = prefix.split("/").at(-1)!; + // TODO: catch-all route const next = matchRouteChild(key, node); if (next?.child) { node = next.child; @@ -66,14 +67,20 @@ export function matchRouteTree(tree: TreeNode, pathname: string) { return { nodes }; } +const DYNAMIC_RE = /^\[(\w*)\]$/; +const CATCH_ALL_RE = /^\[\.\.\.(\w*)\]$/; + export function matchRouteChild(input: string, node: TreeNode) { if (!node.children) { return; } - // TODO: sort to dynmaic one come last - // TODO: catch-all route for (const [segment, child] of Object.entries(node.children)) { - const m = segment.match(/^\[(.*)\]$/); + const mAll = segment.match(CATCH_ALL_RE); + if (mAll) { + tinyassert(1 in mAll); + return { param: mAll[1], child, catchAll: true }; + } + const m = segment.match(DYNAMIC_RE); if (m) { tinyassert(1 in m); return { param: m[1], child }; diff --git a/packages/react-server/src/lib/router.tsx b/packages/react-server/src/lib/router.tsx index 6c0a15077..349205ec7 100644 --- a/packages/react-server/src/lib/router.tsx +++ b/packages/react-server/src/lib/router.tsx @@ -1,9 +1,9 @@ -import { tinyassert } from "@hiogawa/utils"; import React from "react"; import { type TreeNode, generateRouteTree, initTreeNode, + matchRouteChild, } from "../features/router/tree"; import { getPathPrefixes, normalizePathname } from "../features/router/utils"; import { type ReactServerErrorContext, createError } from "./error"; @@ -91,7 +91,7 @@ export async function renderRouteMap( for (let i = 0; i < prefixes.length; i++) { const prefix = prefixes[i]!; const key = prefix.split("/").at(-1)!; - const next = matchChild(key, node); + const next = matchRouteChild(key, node); if (next?.child) { node = next.child; if (next?.catchAll) { @@ -165,28 +165,3 @@ export interface ErrorPageProps { serverError?: ReactServerErrorContext; reset: () => void; } - -const DYNAMIC_RE = /^\[(\w*)\]$/; -const CATCH_ALL_RE = /^\[\.\.\.(\w*)\]$/; - -function matchChild(input: string, node: RouteModuleNode) { - if (!node.children) { - return; - } - for (const [segment, child] of Object.entries(node.children)) { - const mAll = segment.match(CATCH_ALL_RE); - if (mAll) { - tinyassert(1 in mAll); - return { param: mAll[1], child, catchAll: true }; - } - const m = segment.match(DYNAMIC_RE); - if (m) { - tinyassert(1 in m); - return { param: m[1], child }; - } - if (segment === input) { - return { child }; - } - } - return; -} From 51c599b87495e00c172d3f02fd2a86b6fc2b0cff Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 15:47:38 +0900 Subject: [PATCH 03/16] chore: rename --- packages/react-server/src/features/router/tree.ts | 2 +- packages/react-server/src/lib/router.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-server/src/features/router/tree.ts b/packages/react-server/src/features/router/tree.ts index 0db675c52..7a45d058d 100644 --- a/packages/react-server/src/features/router/tree.ts +++ b/packages/react-server/src/features/router/tree.ts @@ -9,7 +9,7 @@ export interface BaseRouteEntry { // generate tree from glob entries such as generated by // import.meta.glob("/**/(page|layout|error).(js|jsx|ts|tsx)") -export function generateRouteTree( +export function createFsRouteTree( globEntries: Record, ): TreeNode> { const entries: Record> = {}; diff --git a/packages/react-server/src/lib/router.tsx b/packages/react-server/src/lib/router.tsx index 349205ec7..18e5520bb 100644 --- a/packages/react-server/src/lib/router.tsx +++ b/packages/react-server/src/lib/router.tsx @@ -1,7 +1,7 @@ import React from "react"; import { type TreeNode, - generateRouteTree, + createFsRouteTree, initTreeNode, matchRouteChild, } from "../features/router/tree"; @@ -27,7 +27,7 @@ interface RouteEntry { type RouteModuleNode = TreeNode; export function generateRouteModuleTree(globEntries: Record) { - return generateRouteTree(globEntries) as RouteModuleNode; + return createFsRouteTree(globEntries) as RouteModuleNode; } // use own "use client" components as external From 4c8701acd36c73a4e1d76427f4649c3e1193bab8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 15:52:05 +0900 Subject: [PATCH 04/16] chore: comment --- packages/react-server/src/features/router/tree.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-server/src/features/router/tree.ts b/packages/react-server/src/features/router/tree.ts index 7a45d058d..cefa4d62a 100644 --- a/packages/react-server/src/features/router/tree.ts +++ b/packages/react-server/src/features/router/tree.ts @@ -52,9 +52,10 @@ export function matchRouteTree(tree: TreeNode, pathname: string) { let params: Record = {}; for (const prefix of prefixes) { const key = prefix.split("/").at(-1)!; - // TODO: catch-all route const next = matchRouteChild(key, node); - if (next?.child) { + if (next?.catchAll) { + // TODO: catch-all route + } else if (next?.child) { node = next.child; if (next.param) { params = { ...params, [next.param]: key }; From 91810580c3d48aad163c3a2a5c0166b5ef2feca9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 16:04:31 +0900 Subject: [PATCH 05/16] test: snapshot --- .../src/lib/__snapshots__/router.test.ts.snap | 18 ++++++++++++------ packages/react-server/src/lib/router.test.ts | 11 ++++++++--- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/react-server/src/lib/__snapshots__/router.test.ts.snap b/packages/react-server/src/lib/__snapshots__/router.test.ts.snap index d74665ff0..906dd28ab 100644 --- a/packages/react-server/src/lib/__snapshots__/router.test.ts.snap +++ b/packages/react-server/src/lib/__snapshots__/router.test.ts.snap @@ -1,7 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`generateRouteTree > basic > (0) "/" 1`] = ` +exports[`generateRouteModuleTree > basic 2`] = ` { + "__pathname": "/", "layouts": { "/": basic > (0) "/" 1`] = ` } `; -exports[`generateRouteTree > basic > (1) "/other" 1`] = ` +exports[`generateRouteModuleTree > basic 3`] = ` { + "__pathname": "/other", "layouts": { "/": basic > (1) "/other" 1`] = ` } `; -exports[`generateRouteTree > basic > (2) "/not-found" 1`] = ` +exports[`generateRouteModuleTree > basic 4`] = ` { + "__pathname": "/not-found", "layouts": { "/": basic > (2) "/not-found" 1`] = ` } `; -exports[`generateRouteTree > basic > (3) "/test" 1`] = ` +exports[`generateRouteModuleTree > basic 5`] = ` { + "__pathname": "/test", "layouts": { "/": basic > (3) "/test" 1`] = ` } `; -exports[`generateRouteTree > basic > (4) "/test/other" 1`] = ` +exports[`generateRouteModuleTree > basic 6`] = ` { + "__pathname": "/test/other", "layouts": { "/": basic > (4) "/test/other" 1`] = ` } `; -exports[`generateRouteTree > basic > (5) "/test/not-found" 1`] = ` +exports[`generateRouteModuleTree > basic 7`] = ` { + "__pathname": "/test/not-found", "layouts": { "/": { } `); - function testMatch(pathname: string) { - return renderRouteMap(tree, { + async function testMatch(pathname: string) { + const match = await renderRouteMap(tree, { url: "https://test.local" + pathname, headers: new Headers(), }); + return { + // inject pathname for the ease of reading snapshot + __pathname: pathname, + ...match, + }; } const testCases = [ @@ -107,7 +112,7 @@ describe(generateRouteModuleTree, () => { ]; for (const i of range(testCases.length)) { const testCase = testCases[i]!; - expect(await testMatch(testCase)).matchSnapshot(`(${i}) "${testCase}"`); + expect(await testMatch(testCase)).matchSnapshot(); } }); }); From 5576d05b79c8af1afb5bbb280b94320415850dd7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 16:07:13 +0900 Subject: [PATCH 06/16] test: tweak --- .../src/lib/__snapshots__/router.test.ts.snap | 71 +++++++++++++++++++ packages/react-server/src/lib/router.test.ts | 71 +------------------ 2 files changed, 72 insertions(+), 70 deletions(-) diff --git a/packages/react-server/src/lib/__snapshots__/router.test.ts.snap b/packages/react-server/src/lib/__snapshots__/router.test.ts.snap index 906dd28ab..6d1264604 100644 --- a/packages/react-server/src/lib/__snapshots__/router.test.ts.snap +++ b/packages/react-server/src/lib/__snapshots__/router.test.ts.snap @@ -1,5 +1,76 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`generateRouteModuleTree > basic 1`] = ` +{ + "children": { + "": { + "children": { + "demo": { + "value": { + "layout": { + "default": "/demo/layout.tsx", + }, + "page": { + "default": "/demo/page.tsx", + }, + }, + }, + "other": { + "value": { + "page": { + "default": "/other/page.tsx", + }, + }, + }, + "test": { + "children": { + "[dynamic]": { + "children": { + "hello": { + "value": { + "page": { + "default": "/test/[dynamic]/hello/page.tsx", + }, + }, + }, + }, + "value": { + "page": { + "default": "/test/[dynamic]/page.tsx", + }, + }, + }, + "other": { + "value": { + "page": { + "default": "/test/other/page.tsx", + }, + }, + }, + }, + "value": { + "layout": { + "default": "/test/layout.tsx", + }, + "page": { + "default": "/test/page.tsx", + }, + }, + }, + }, + "value": { + "layout": { + "default": "/layout.tsx", + }, + "page": { + "default": "/page.tsx", + }, + }, + }, + }, +} +`; + exports[`generateRouteModuleTree > basic 2`] = ` { "__pathname": "/", diff --git a/packages/react-server/src/lib/router.test.ts b/packages/react-server/src/lib/router.test.ts index eb4410075..0ae118373 100644 --- a/packages/react-server/src/lib/router.test.ts +++ b/packages/react-server/src/lib/router.test.ts @@ -19,76 +19,7 @@ describe(generateRouteModuleTree, () => { ]; const input = Object.fromEntries(files.map((k) => [k, { default: k }])); const tree = generateRouteModuleTree(input); - expect(tree).toMatchInlineSnapshot(` - { - "children": { - "": { - "children": { - "demo": { - "value": { - "layout": { - "default": "/demo/layout.tsx", - }, - "page": { - "default": "/demo/page.tsx", - }, - }, - }, - "other": { - "value": { - "page": { - "default": "/other/page.tsx", - }, - }, - }, - "test": { - "children": { - "[dynamic]": { - "children": { - "hello": { - "value": { - "page": { - "default": "/test/[dynamic]/hello/page.tsx", - }, - }, - }, - }, - "value": { - "page": { - "default": "/test/[dynamic]/page.tsx", - }, - }, - }, - "other": { - "value": { - "page": { - "default": "/test/other/page.tsx", - }, - }, - }, - }, - "value": { - "layout": { - "default": "/test/layout.tsx", - }, - "page": { - "default": "/test/page.tsx", - }, - }, - }, - }, - "value": { - "layout": { - "default": "/layout.tsx", - }, - "page": { - "default": "/page.tsx", - }, - }, - }, - }, - } - `); + expect(tree).toMatchSnapshot(); async function testMatch(pathname: string) { const match = await renderRouteMap(tree, { From e9051a2d11e586229ce6ddebd9a10556abcede3f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 16:19:43 +0900 Subject: [PATCH 07/16] wip: matchRouteTree --- .../router/__snapshots__/tree.test.ts.snap | 462 ++++++++++++++++++ .../src/features/router/tree.test.ts | 42 ++ packages/react-server/src/lib/router.test.ts | 1 + packages/react-server/src/lib/router.tsx | 1 + 4 files changed, 506 insertions(+) create mode 100644 packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap create mode 100644 packages/react-server/src/features/router/tree.test.ts diff --git a/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap b/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap new file mode 100644 index 000000000..0bdab37d1 --- /dev/null +++ b/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap @@ -0,0 +1,462 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`createFsRouteTree > basic 1`] = ` +{ + "children": { + "": { + "children": { + "demo": { + "value": { + "layout": "/demo/layout.tsx", + "page": "/demo/page.tsx", + }, + }, + "other": { + "value": { + "page": "/other/page.tsx", + }, + }, + "test": { + "children": { + "[dynamic]": { + "children": { + "hello": { + "value": { + "page": "/test/[dynamic]/hello/page.tsx", + }, + }, + }, + "value": { + "page": "/test/[dynamic]/page.tsx", + }, + }, + "other": { + "value": { + "page": "/test/other/page.tsx", + }, + }, + }, + "value": { + "layout": "/test/layout.tsx", + "page": "/test/page.tsx", + }, + }, + }, + "value": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + }, + }, +} +`; + +exports[`createFsRouteTree > basic 2`] = ` +{ + "__pathname": "/", + "nodes": [ + { + "children": { + "demo": { + "value": { + "layout": "/demo/layout.tsx", + "page": "/demo/page.tsx", + }, + }, + "other": { + "value": { + "page": "/other/page.tsx", + }, + }, + "test": { + "children": { + "[dynamic]": { + "children": { + "hello": { + "value": { + "page": "/test/[dynamic]/hello/page.tsx", + }, + }, + }, + "value": { + "page": "/test/[dynamic]/page.tsx", + }, + }, + "other": { + "value": { + "page": "/test/other/page.tsx", + }, + }, + }, + "value": { + "layout": "/test/layout.tsx", + "page": "/test/page.tsx", + }, + }, + }, + "value": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + }, + ], +} +`; + +exports[`createFsRouteTree > basic 3`] = ` +{ + "__pathname": "/other", + "nodes": [ + { + "children": { + "demo": { + "value": { + "layout": "/demo/layout.tsx", + "page": "/demo/page.tsx", + }, + }, + "other": { + "value": { + "page": "/other/page.tsx", + }, + }, + "test": { + "children": { + "[dynamic]": { + "children": { + "hello": { + "value": { + "page": "/test/[dynamic]/hello/page.tsx", + }, + }, + }, + "value": { + "page": "/test/[dynamic]/page.tsx", + }, + }, + "other": { + "value": { + "page": "/test/other/page.tsx", + }, + }, + }, + "value": { + "layout": "/test/layout.tsx", + "page": "/test/page.tsx", + }, + }, + }, + "value": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + }, + { + "value": { + "page": "/other/page.tsx", + }, + }, + ], +} +`; + +exports[`createFsRouteTree > basic 4`] = ` +{ + "__pathname": "/not-found", + "nodes": [ + { + "children": { + "demo": { + "value": { + "layout": "/demo/layout.tsx", + "page": "/demo/page.tsx", + }, + }, + "other": { + "value": { + "page": "/other/page.tsx", + }, + }, + "test": { + "children": { + "[dynamic]": { + "children": { + "hello": { + "value": { + "page": "/test/[dynamic]/hello/page.tsx", + }, + }, + }, + "value": { + "page": "/test/[dynamic]/page.tsx", + }, + }, + "other": { + "value": { + "page": "/test/other/page.tsx", + }, + }, + }, + "value": { + "layout": "/test/layout.tsx", + "page": "/test/page.tsx", + }, + }, + }, + "value": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + }, + {}, + ], +} +`; + +exports[`createFsRouteTree > basic 5`] = ` +{ + "__pathname": "/test", + "nodes": [ + { + "children": { + "demo": { + "value": { + "layout": "/demo/layout.tsx", + "page": "/demo/page.tsx", + }, + }, + "other": { + "value": { + "page": "/other/page.tsx", + }, + }, + "test": { + "children": { + "[dynamic]": { + "children": { + "hello": { + "value": { + "page": "/test/[dynamic]/hello/page.tsx", + }, + }, + }, + "value": { + "page": "/test/[dynamic]/page.tsx", + }, + }, + "other": { + "value": { + "page": "/test/other/page.tsx", + }, + }, + }, + "value": { + "layout": "/test/layout.tsx", + "page": "/test/page.tsx", + }, + }, + }, + "value": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + }, + { + "children": { + "[dynamic]": { + "children": { + "hello": { + "value": { + "page": "/test/[dynamic]/hello/page.tsx", + }, + }, + }, + "value": { + "page": "/test/[dynamic]/page.tsx", + }, + }, + "other": { + "value": { + "page": "/test/other/page.tsx", + }, + }, + }, + "value": { + "layout": "/test/layout.tsx", + "page": "/test/page.tsx", + }, + }, + ], +} +`; + +exports[`createFsRouteTree > basic 6`] = ` +{ + "__pathname": "/test/other", + "nodes": [ + { + "children": { + "demo": { + "value": { + "layout": "/demo/layout.tsx", + "page": "/demo/page.tsx", + }, + }, + "other": { + "value": { + "page": "/other/page.tsx", + }, + }, + "test": { + "children": { + "[dynamic]": { + "children": { + "hello": { + "value": { + "page": "/test/[dynamic]/hello/page.tsx", + }, + }, + }, + "value": { + "page": "/test/[dynamic]/page.tsx", + }, + }, + "other": { + "value": { + "page": "/test/other/page.tsx", + }, + }, + }, + "value": { + "layout": "/test/layout.tsx", + "page": "/test/page.tsx", + }, + }, + }, + "value": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + }, + { + "children": { + "[dynamic]": { + "children": { + "hello": { + "value": { + "page": "/test/[dynamic]/hello/page.tsx", + }, + }, + }, + "value": { + "page": "/test/[dynamic]/page.tsx", + }, + }, + "other": { + "value": { + "page": "/test/other/page.tsx", + }, + }, + }, + "value": { + "layout": "/test/layout.tsx", + "page": "/test/page.tsx", + }, + }, + { + "value": { + "page": "/test/other/page.tsx", + }, + }, + ], +} +`; + +exports[`createFsRouteTree > basic 7`] = ` +{ + "__pathname": "/test/not-found", + "nodes": [ + { + "children": { + "demo": { + "value": { + "layout": "/demo/layout.tsx", + "page": "/demo/page.tsx", + }, + }, + "other": { + "value": { + "page": "/other/page.tsx", + }, + }, + "test": { + "children": { + "[dynamic]": { + "children": { + "hello": { + "value": { + "page": "/test/[dynamic]/hello/page.tsx", + }, + }, + }, + "value": { + "page": "/test/[dynamic]/page.tsx", + }, + }, + "other": { + "value": { + "page": "/test/other/page.tsx", + }, + }, + }, + "value": { + "layout": "/test/layout.tsx", + "page": "/test/page.tsx", + }, + }, + }, + "value": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + }, + { + "children": { + "[dynamic]": { + "children": { + "hello": { + "value": { + "page": "/test/[dynamic]/hello/page.tsx", + }, + }, + }, + "value": { + "page": "/test/[dynamic]/page.tsx", + }, + }, + "other": { + "value": { + "page": "/test/other/page.tsx", + }, + }, + }, + "value": { + "layout": "/test/layout.tsx", + "page": "/test/page.tsx", + }, + }, + { + "children": { + "hello": { + "value": { + "page": "/test/[dynamic]/hello/page.tsx", + }, + }, + }, + "value": { + "page": "/test/[dynamic]/page.tsx", + }, + }, + ], +} +`; diff --git a/packages/react-server/src/features/router/tree.test.ts b/packages/react-server/src/features/router/tree.test.ts new file mode 100644 index 000000000..b345d2614 --- /dev/null +++ b/packages/react-server/src/features/router/tree.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { createFsRouteTree, matchRouteTree } from "./tree"; + +describe(createFsRouteTree, () => { + it("basic", async () => { + const files = [ + "/layout.tsx", + "/page.tsx", + "/other/page.tsx", + "/test/layout.tsx", + "/test/page.tsx", + "/test/other/page.tsx", + "/test/[dynamic]/page.tsx", + "/test/[dynamic]/hello/page.tsx", + "/demo/layout.tsx", + "/demo/page.tsx", + ]; + const input = Object.fromEntries(files.map((k) => [k, k])); + const tree = createFsRouteTree(input); + expect(tree).toMatchSnapshot(); + + function testMatch(pathname: string) { + const match = matchRouteTree(tree, pathname); + return { + __pathname: pathname, + ...match, + }; + } + + const testCases = [ + "/", + "/other", + "/not-found", + "/test", + "/test/other", + "/test/not-found", + ]; + for (const e of testCases) { + expect(testMatch(e)).matchSnapshot(); + } + }); +}); diff --git a/packages/react-server/src/lib/router.test.ts b/packages/react-server/src/lib/router.test.ts index 0ae118373..1e08d5cf5 100644 --- a/packages/react-server/src/lib/router.test.ts +++ b/packages/react-server/src/lib/router.test.ts @@ -21,6 +21,7 @@ describe(generateRouteModuleTree, () => { const tree = generateRouteModuleTree(input); expect(tree).toMatchSnapshot(); + // TODO: test pure utility `matchRouteTree` async function testMatch(pathname: string) { const match = await renderRouteMap(tree, { url: "https://test.local" + pathname, diff --git a/packages/react-server/src/lib/router.tsx b/packages/react-server/src/lib/router.tsx index 18e5520bb..568735d09 100644 --- a/packages/react-server/src/lib/router.tsx +++ b/packages/react-server/src/lib/router.tsx @@ -69,6 +69,7 @@ async function renderLayout( return acc; } +// TODO: implement on top of matchRouteTree export async function renderRouteMap( tree: RouteModuleNode, request: Pick, From 563b071380e91392bece5feaf2c3b4cb25c635c6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 16:45:51 +0900 Subject: [PATCH 08/16] wip: matchRouteTree --- .../router/__snapshots__/tree.test.ts.snap | 383 +++--------------- .../src/features/router/tree.test.ts | 9 +- .../react-server/src/features/router/tree.ts | 29 +- 3 files changed, 81 insertions(+), 340 deletions(-) diff --git a/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap b/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap index 0bdab37d1..a2899071a 100644 --- a/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap +++ b/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap @@ -54,409 +54,134 @@ exports[`createFsRouteTree > basic 1`] = ` exports[`createFsRouteTree > basic 2`] = ` { "__pathname": "/", - "nodes": [ - { - "children": { - "demo": { - "value": { - "layout": "/demo/layout.tsx", - "page": "/demo/page.tsx", - }, - }, - "other": { - "value": { - "page": "/other/page.tsx", - }, - }, - "test": { - "children": { - "[dynamic]": { - "children": { - "hello": { - "value": { - "page": "/test/[dynamic]/hello/page.tsx", - }, - }, - }, - "value": { - "page": "/test/[dynamic]/page.tsx", - }, - }, - "other": { - "value": { - "page": "/test/other/page.tsx", - }, - }, - }, - "value": { - "layout": "/test/layout.tsx", - "page": "/test/page.tsx", - }, - }, - }, - "value": { + "match": { + "/": { + "nodeValue": { "layout": "/layout.tsx", "page": "/page.tsx", }, + "params": {}, }, - ], + }, } `; exports[`createFsRouteTree > basic 3`] = ` { "__pathname": "/other", - "nodes": [ - { - "children": { - "demo": { - "value": { - "layout": "/demo/layout.tsx", - "page": "/demo/page.tsx", - }, - }, - "other": { - "value": { - "page": "/other/page.tsx", - }, - }, - "test": { - "children": { - "[dynamic]": { - "children": { - "hello": { - "value": { - "page": "/test/[dynamic]/hello/page.tsx", - }, - }, - }, - "value": { - "page": "/test/[dynamic]/page.tsx", - }, - }, - "other": { - "value": { - "page": "/test/other/page.tsx", - }, - }, - }, - "value": { - "layout": "/test/layout.tsx", - "page": "/test/page.tsx", - }, - }, - }, - "value": { + "match": { + "/": { + "nodeValue": { "layout": "/layout.tsx", "page": "/page.tsx", }, + "params": {}, }, - { - "value": { + "/other": { + "nodeValue": { "page": "/other/page.tsx", }, + "params": {}, }, - ], + }, } `; exports[`createFsRouteTree > basic 4`] = ` { "__pathname": "/not-found", - "nodes": [ - { - "children": { - "demo": { - "value": { - "layout": "/demo/layout.tsx", - "page": "/demo/page.tsx", - }, - }, - "other": { - "value": { - "page": "/other/page.tsx", - }, - }, - "test": { - "children": { - "[dynamic]": { - "children": { - "hello": { - "value": { - "page": "/test/[dynamic]/hello/page.tsx", - }, - }, - }, - "value": { - "page": "/test/[dynamic]/page.tsx", - }, - }, - "other": { - "value": { - "page": "/test/other/page.tsx", - }, - }, - }, - "value": { - "layout": "/test/layout.tsx", - "page": "/test/page.tsx", - }, - }, - }, - "value": { + "match": { + "/": { + "nodeValue": { "layout": "/layout.tsx", "page": "/page.tsx", }, + "params": {}, + }, + "/not-found": { + "nodeValue": undefined, + "params": {}, }, - {}, - ], + }, } `; exports[`createFsRouteTree > basic 5`] = ` { "__pathname": "/test", - "nodes": [ - { - "children": { - "demo": { - "value": { - "layout": "/demo/layout.tsx", - "page": "/demo/page.tsx", - }, - }, - "other": { - "value": { - "page": "/other/page.tsx", - }, - }, - "test": { - "children": { - "[dynamic]": { - "children": { - "hello": { - "value": { - "page": "/test/[dynamic]/hello/page.tsx", - }, - }, - }, - "value": { - "page": "/test/[dynamic]/page.tsx", - }, - }, - "other": { - "value": { - "page": "/test/other/page.tsx", - }, - }, - }, - "value": { - "layout": "/test/layout.tsx", - "page": "/test/page.tsx", - }, - }, - }, - "value": { + "match": { + "/": { + "nodeValue": { "layout": "/layout.tsx", "page": "/page.tsx", }, + "params": {}, }, - { - "children": { - "[dynamic]": { - "children": { - "hello": { - "value": { - "page": "/test/[dynamic]/hello/page.tsx", - }, - }, - }, - "value": { - "page": "/test/[dynamic]/page.tsx", - }, - }, - "other": { - "value": { - "page": "/test/other/page.tsx", - }, - }, - }, - "value": { + "/test": { + "nodeValue": { "layout": "/test/layout.tsx", "page": "/test/page.tsx", }, + "params": {}, }, - ], + }, } `; exports[`createFsRouteTree > basic 6`] = ` { "__pathname": "/test/other", - "nodes": [ - { - "children": { - "demo": { - "value": { - "layout": "/demo/layout.tsx", - "page": "/demo/page.tsx", - }, - }, - "other": { - "value": { - "page": "/other/page.tsx", - }, - }, - "test": { - "children": { - "[dynamic]": { - "children": { - "hello": { - "value": { - "page": "/test/[dynamic]/hello/page.tsx", - }, - }, - }, - "value": { - "page": "/test/[dynamic]/page.tsx", - }, - }, - "other": { - "value": { - "page": "/test/other/page.tsx", - }, - }, - }, - "value": { - "layout": "/test/layout.tsx", - "page": "/test/page.tsx", - }, - }, - }, - "value": { + "match": { + "/": { + "nodeValue": { "layout": "/layout.tsx", "page": "/page.tsx", }, + "params": {}, }, - { - "children": { - "[dynamic]": { - "children": { - "hello": { - "value": { - "page": "/test/[dynamic]/hello/page.tsx", - }, - }, - }, - "value": { - "page": "/test/[dynamic]/page.tsx", - }, - }, - "other": { - "value": { - "page": "/test/other/page.tsx", - }, - }, - }, - "value": { + "/test": { + "nodeValue": { "layout": "/test/layout.tsx", "page": "/test/page.tsx", }, + "params": {}, }, - { - "value": { + "/test/other": { + "nodeValue": { "page": "/test/other/page.tsx", }, + "params": {}, }, - ], + }, } `; exports[`createFsRouteTree > basic 7`] = ` { "__pathname": "/test/not-found", - "nodes": [ - { - "children": { - "demo": { - "value": { - "layout": "/demo/layout.tsx", - "page": "/demo/page.tsx", - }, - }, - "other": { - "value": { - "page": "/other/page.tsx", - }, - }, - "test": { - "children": { - "[dynamic]": { - "children": { - "hello": { - "value": { - "page": "/test/[dynamic]/hello/page.tsx", - }, - }, - }, - "value": { - "page": "/test/[dynamic]/page.tsx", - }, - }, - "other": { - "value": { - "page": "/test/other/page.tsx", - }, - }, - }, - "value": { - "layout": "/test/layout.tsx", - "page": "/test/page.tsx", - }, - }, - }, - "value": { + "match": { + "/": { + "nodeValue": { "layout": "/layout.tsx", "page": "/page.tsx", }, + "params": {}, }, - { - "children": { - "[dynamic]": { - "children": { - "hello": { - "value": { - "page": "/test/[dynamic]/hello/page.tsx", - }, - }, - }, - "value": { - "page": "/test/[dynamic]/page.tsx", - }, - }, - "other": { - "value": { - "page": "/test/other/page.tsx", - }, - }, - }, - "value": { + "/test": { + "nodeValue": { "layout": "/test/layout.tsx", "page": "/test/page.tsx", }, + "params": {}, }, - { - "children": { - "hello": { - "value": { - "page": "/test/[dynamic]/hello/page.tsx", - }, - }, - }, - "value": { + "/test/not-found": { + "nodeValue": { "page": "/test/[dynamic]/page.tsx", }, + "params": { + "dynamic": "not-found", + }, }, - ], + }, } `; diff --git a/packages/react-server/src/features/router/tree.test.ts b/packages/react-server/src/features/router/tree.test.ts index b345d2614..14c8e120d 100644 --- a/packages/react-server/src/features/router/tree.test.ts +++ b/packages/react-server/src/features/router/tree.test.ts @@ -1,8 +1,10 @@ +import { objectMapValues } from "@hiogawa/utils"; import { describe, expect, it } from "vitest"; import { createFsRouteTree, matchRouteTree } from "./tree"; describe(createFsRouteTree, () => { it("basic", async () => { + // TODO: test [...catchall] const files = [ "/layout.tsx", "/page.tsx", @@ -20,10 +22,13 @@ describe(createFsRouteTree, () => { expect(tree).toMatchSnapshot(); function testMatch(pathname: string) { - const match = matchRouteTree(tree, pathname); + const result = matchRouteTree(tree, pathname); return { __pathname: pathname, - ...match, + match: objectMapValues(result.matches, (v) => ({ + nodeValue: v.node.value, + params: v.params, + })), }; } diff --git a/packages/react-server/src/features/router/tree.ts b/packages/react-server/src/features/router/tree.ts index cefa4d62a..c7c65c992 100644 --- a/packages/react-server/src/features/router/tree.ts +++ b/packages/react-server/src/features/router/tree.ts @@ -42,30 +42,41 @@ function sortDynamicRoutes(tree: TreeNode) { } } -// TODO: refactor with `renderRouteMap` +type MatchNodeEntry = { + node: TreeNode; + params: Record; +}; + +type MatchResult = { + matches: Record>; +}; + export function matchRouteTree(tree: TreeNode, pathname: string) { pathname = normalizePathname(pathname); const prefixes = getPathPrefixes(pathname); let node = tree; - let nodes: TreeNode[] = []; let params: Record = {}; - for (const prefix of prefixes) { + const result: MatchResult = { matches: {} }; + for (let i = 0; i < prefixes.length; i++) { + const prefix = prefixes[i]!; const key = prefix.split("/").at(-1)!; const next = matchRouteChild(key, node); - if (next?.catchAll) { - // TODO: catch-all route - } else if (next?.child) { + if (next?.child) { node = next.child; + if (next.catchAll) { + // TODO: catch-all route + break; + } if (next.param) { - params = { ...params, [next.param]: key }; + params = { ...params, [next.param]: decodeURI(key) }; } } else { node = initTreeNode(); } - nodes.push(node); + result.matches[prefix] = { node, params }; } - return { nodes }; + return result; } const DYNAMIC_RE = /^\[(\w*)\]$/; From 733b967512bf7260ffe2dcab2b836e5f55690459 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 17:02:36 +0900 Subject: [PATCH 09/16] feat: matchRouteTree --- .../router/__snapshots__/tree.test.ts.snap | 395 +++++++++++++++++- .../src/features/router/tree.test.ts | 24 +- .../react-server/src/features/router/tree.ts | 15 +- 3 files changed, 408 insertions(+), 26 deletions(-) diff --git a/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap b/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap index a2899071a..12360eada 100644 --- a/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap +++ b/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap @@ -5,10 +5,47 @@ exports[`createFsRouteTree > basic 1`] = ` "children": { "": { "children": { - "demo": { + "dynamic": { + "children": { + "[id]": { + "children": { + "[nested]": { + "value": { + "page": "/dynamic/[id]/[nested]/page.tsx", + }, + }, + }, + "value": { + "layout": "/dynamic/[id]/layout.tsx", + "page": "/dynamic/[id]/page.tsx", + }, + }, + "catchall": { + "children": { + "[...any]": { + "value": { + "page": "/dynamic/catchall/[...any]/page.tsx", + }, + }, + "static": { + "value": { + "page": "/dynamic/catchall/static/page.tsx", + }, + }, + }, + "value": { + "page": "/dynamic/catchall/page.tsx", + }, + }, + "static": { + "value": { + "page": "/dynamic/static/page.tsx", + }, + }, + }, "value": { - "layout": "/demo/layout.tsx", - "page": "/demo/page.tsx", + "layout": "/dynamic/layout.tsx", + "page": "/dynamic/page.tsx", }, }, "other": { @@ -18,18 +55,6 @@ exports[`createFsRouteTree > basic 1`] = ` }, "test": { "children": { - "[dynamic]": { - "children": { - "hello": { - "value": { - "page": "/test/[dynamic]/hello/page.tsx", - }, - }, - }, - "value": { - "page": "/test/[dynamic]/page.tsx", - }, - }, "other": { "value": { "page": "/test/other/page.tsx", @@ -175,11 +200,347 @@ exports[`createFsRouteTree > basic 7`] = ` "params": {}, }, "/test/not-found": { + "nodeValue": undefined, + "params": {}, + }, + }, +} +`; + +exports[`createFsRouteTree > basic 8`] = ` +{ + "__pathname": "/dynamic", + "match": { + "/": { + "nodeValue": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + "params": {}, + }, + "/dynamic": { + "nodeValue": { + "layout": "/dynamic/layout.tsx", + "page": "/dynamic/page.tsx", + }, + "params": {}, + }, + }, +} +`; + +exports[`createFsRouteTree > basic 9`] = ` +{ + "__pathname": "/dynamic/static", + "match": { + "/": { + "nodeValue": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + "params": {}, + }, + "/dynamic": { + "nodeValue": { + "layout": "/dynamic/layout.tsx", + "page": "/dynamic/page.tsx", + }, + "params": {}, + }, + "/dynamic/static": { + "nodeValue": { + "page": "/dynamic/static/page.tsx", + }, + "params": {}, + }, + }, +} +`; + +exports[`createFsRouteTree > basic 10`] = ` +{ + "__pathname": "/dynamic/abc", + "match": { + "/": { + "nodeValue": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + "params": {}, + }, + "/dynamic": { + "nodeValue": { + "layout": "/dynamic/layout.tsx", + "page": "/dynamic/page.tsx", + }, + "params": {}, + }, + "/dynamic/abc": { + "nodeValue": { + "layout": "/dynamic/[id]/layout.tsx", + "page": "/dynamic/[id]/page.tsx", + }, + "params": { + "id": "abc", + }, + }, + }, +} +`; + +exports[`createFsRouteTree > basic 11`] = ` +{ + "__pathname": "/dynamic/abc/def", + "match": { + "/": { + "nodeValue": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + "params": {}, + }, + "/dynamic": { + "nodeValue": { + "layout": "/dynamic/layout.tsx", + "page": "/dynamic/page.tsx", + }, + "params": {}, + }, + "/dynamic/abc": { + "nodeValue": { + "layout": "/dynamic/[id]/layout.tsx", + "page": "/dynamic/[id]/page.tsx", + }, + "params": { + "id": "abc", + }, + }, + "/dynamic/abc/def": { + "nodeValue": { + "page": "/dynamic/[id]/[nested]/page.tsx", + }, + "params": { + "id": "abc", + "nested": "def", + }, + }, + }, +} +`; + +exports[`createFsRouteTree > basic 12`] = ` +{ + "__pathname": "/dynamic/%E2%9C%85", + "match": { + "/": { + "nodeValue": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + "params": {}, + }, + "/dynamic": { + "nodeValue": { + "layout": "/dynamic/layout.tsx", + "page": "/dynamic/page.tsx", + }, + "params": {}, + }, + "/dynamic/%E2%9C%85": { + "nodeValue": { + "layout": "/dynamic/[id]/layout.tsx", + "page": "/dynamic/[id]/page.tsx", + }, + "params": { + "id": "✅", + }, + }, + }, +} +`; + +exports[`createFsRouteTree > basic 13`] = ` +{ + "__pathname": "/dynamic/catchall", + "match": { + "/": { + "nodeValue": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + "params": {}, + }, + "/dynamic": { + "nodeValue": { + "layout": "/dynamic/layout.tsx", + "page": "/dynamic/page.tsx", + }, + "params": {}, + }, + "/dynamic/catchall": { + "nodeValue": { + "page": "/dynamic/catchall/page.tsx", + }, + "params": {}, + }, + }, +} +`; + +exports[`createFsRouteTree > basic 14`] = ` +{ + "__pathname": "/dynamic/catchall/static", + "match": { + "/": { + "nodeValue": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + "params": {}, + }, + "/dynamic": { + "nodeValue": { + "layout": "/dynamic/layout.tsx", + "page": "/dynamic/page.tsx", + }, + "params": {}, + }, + "/dynamic/catchall": { + "nodeValue": { + "page": "/dynamic/catchall/page.tsx", + }, + "params": {}, + }, + "/dynamic/catchall/static": { + "nodeValue": { + "page": "/dynamic/catchall/static/page.tsx", + }, + "params": {}, + }, + }, +} +`; + +exports[`createFsRouteTree > basic 15`] = ` +{ + "__pathname": "/dynamic/catchall/x", + "match": { + "/": { + "nodeValue": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + "params": {}, + }, + "/dynamic": { + "nodeValue": { + "layout": "/dynamic/layout.tsx", + "page": "/dynamic/page.tsx", + }, + "params": {}, + }, + "/dynamic/catchall": { + "nodeValue": { + "page": "/dynamic/catchall/page.tsx", + }, + "params": {}, + }, + "/dynamic/catchall/x": { + "nodeValue": { + "page": "/dynamic/catchall/[...any]/page.tsx", + }, + "params": { + "any": "x", + }, + }, + }, +} +`; + +exports[`createFsRouteTree > basic 16`] = ` +{ + "__pathname": "/dynamic/catchall/x/y", + "match": { + "/": { + "nodeValue": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + "params": {}, + }, + "/dynamic": { + "nodeValue": { + "layout": "/dynamic/layout.tsx", + "page": "/dynamic/page.tsx", + }, + "params": {}, + }, + "/dynamic/catchall": { + "nodeValue": { + "page": "/dynamic/catchall/page.tsx", + }, + "params": {}, + }, + "/dynamic/catchall/x": { + "nodeValue": undefined, + "params": { + "any": "x/y", + }, + }, + "/dynamic/catchall/x/y": { + "nodeValue": { + "page": "/dynamic/catchall/[...any]/page.tsx", + }, + "params": { + "any": "x/y", + }, + }, + }, +} +`; + +exports[`createFsRouteTree > basic 17`] = ` +{ + "__pathname": "/dynamic/catchall/x/y/z", + "match": { + "/": { + "nodeValue": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + "params": {}, + }, + "/dynamic": { + "nodeValue": { + "layout": "/dynamic/layout.tsx", + "page": "/dynamic/page.tsx", + }, + "params": {}, + }, + "/dynamic/catchall": { + "nodeValue": { + "page": "/dynamic/catchall/page.tsx", + }, + "params": {}, + }, + "/dynamic/catchall/x": { + "nodeValue": undefined, + "params": { + "any": "x/y/z", + }, + }, + "/dynamic/catchall/x/y": { + "nodeValue": undefined, + "params": { + "any": "x/y/z", + }, + }, + "/dynamic/catchall/x/y/z": { "nodeValue": { - "page": "/test/[dynamic]/page.tsx", + "page": "/dynamic/catchall/[...any]/page.tsx", }, "params": { - "dynamic": "not-found", + "any": "x/y/z", }, }, }, diff --git a/packages/react-server/src/features/router/tree.test.ts b/packages/react-server/src/features/router/tree.test.ts index 14c8e120d..f01633df2 100644 --- a/packages/react-server/src/features/router/tree.test.ts +++ b/packages/react-server/src/features/router/tree.test.ts @@ -4,7 +4,6 @@ import { createFsRouteTree, matchRouteTree } from "./tree"; describe(createFsRouteTree, () => { it("basic", async () => { - // TODO: test [...catchall] const files = [ "/layout.tsx", "/page.tsx", @@ -12,10 +11,15 @@ describe(createFsRouteTree, () => { "/test/layout.tsx", "/test/page.tsx", "/test/other/page.tsx", - "/test/[dynamic]/page.tsx", - "/test/[dynamic]/hello/page.tsx", - "/demo/layout.tsx", - "/demo/page.tsx", + "/dynamic/page.tsx", + "/dynamic/layout.tsx", + "/dynamic/static/page.tsx", + "/dynamic/[id]/page.tsx", + "/dynamic/[id]/layout.tsx", + "/dynamic/[id]/[nested]/page.tsx", + "/dynamic/catchall/page.tsx", + "/dynamic/catchall/static/page.tsx", + "/dynamic/catchall/[...any]/page.tsx", ]; const input = Object.fromEntries(files.map((k) => [k, k])); const tree = createFsRouteTree(input); @@ -39,6 +43,16 @@ describe(createFsRouteTree, () => { "/test", "/test/other", "/test/not-found", + "/dynamic", + "/dynamic/static", + "/dynamic/abc", + "/dynamic/abc/def", + "/dynamic/%E2%9C%85", + "/dynamic/catchall", + "/dynamic/catchall/static", + "/dynamic/catchall/x", + "/dynamic/catchall/x/y", + "/dynamic/catchall/x/y/z", ]; for (const e of testCases) { expect(testMatch(e)).matchSnapshot(); diff --git a/packages/react-server/src/features/router/tree.ts b/packages/react-server/src/features/router/tree.ts index c7c65c992..b315aa7b3 100644 --- a/packages/react-server/src/features/router/tree.ts +++ b/packages/react-server/src/features/router/tree.ts @@ -52,6 +52,7 @@ type MatchResult = { }; export function matchRouteTree(tree: TreeNode, pathname: string) { + // TODO: normalize somewhere else? pathname = normalizePathname(pathname); const prefixes = getPathPrefixes(pathname); @@ -60,16 +61,22 @@ export function matchRouteTree(tree: TreeNode, pathname: string) { const result: MatchResult = { matches: {} }; for (let i = 0; i < prefixes.length; i++) { const prefix = prefixes[i]!; - const key = prefix.split("/").at(-1)!; - const next = matchRouteChild(key, node); + const segment = prefix.split("/").at(-1)!; + const next = matchRouteChild(segment, node); if (next?.child) { node = next.child; if (next.catchAll) { - // TODO: catch-all route + const rest = pathname.slice(prefixes[i - 1]!.length + 1); + params = { ...params, [next.param]: decodeURI(rest) }; + result.matches[prefix] = { node, params }; + for (const prefix of prefixes.slice(i)) { + result.matches[prefix] = { node: initTreeNode(), params }; + } + result.matches[pathname] = { node, params }; break; } if (next.param) { - params = { ...params, [next.param]: decodeURI(key) }; + params = { ...params, [next.param]: decodeURI(segment) }; } } else { node = initTreeNode(); From 17924f230c97f83aa5cf496e247ba3c63bd9c5e8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 17:07:10 +0900 Subject: [PATCH 10/16] test: tweak --- .../router/__snapshots__/tree.test.ts.snap | 44 ++++++++++++++----- .../src/features/router/tree.test.ts | 1 + .../react-server/src/features/router/tree.ts | 2 +- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap b/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap index 12360eada..f3ee29f5a 100644 --- a/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap +++ b/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap @@ -154,6 +154,28 @@ exports[`createFsRouteTree > basic 5`] = ` `; exports[`createFsRouteTree > basic 6`] = ` +{ + "__pathname": "/test/", + "match": { + "/": { + "nodeValue": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + "params": {}, + }, + "/test": { + "nodeValue": { + "layout": "/test/layout.tsx", + "page": "/test/page.tsx", + }, + "params": {}, + }, + }, +} +`; + +exports[`createFsRouteTree > basic 7`] = ` { "__pathname": "/test/other", "match": { @@ -181,7 +203,7 @@ exports[`createFsRouteTree > basic 6`] = ` } `; -exports[`createFsRouteTree > basic 7`] = ` +exports[`createFsRouteTree > basic 8`] = ` { "__pathname": "/test/not-found", "match": { @@ -207,7 +229,7 @@ exports[`createFsRouteTree > basic 7`] = ` } `; -exports[`createFsRouteTree > basic 8`] = ` +exports[`createFsRouteTree > basic 9`] = ` { "__pathname": "/dynamic", "match": { @@ -229,7 +251,7 @@ exports[`createFsRouteTree > basic 8`] = ` } `; -exports[`createFsRouteTree > basic 9`] = ` +exports[`createFsRouteTree > basic 10`] = ` { "__pathname": "/dynamic/static", "match": { @@ -257,7 +279,7 @@ exports[`createFsRouteTree > basic 9`] = ` } `; -exports[`createFsRouteTree > basic 10`] = ` +exports[`createFsRouteTree > basic 11`] = ` { "__pathname": "/dynamic/abc", "match": { @@ -288,7 +310,7 @@ exports[`createFsRouteTree > basic 10`] = ` } `; -exports[`createFsRouteTree > basic 11`] = ` +exports[`createFsRouteTree > basic 12`] = ` { "__pathname": "/dynamic/abc/def", "match": { @@ -328,7 +350,7 @@ exports[`createFsRouteTree > basic 11`] = ` } `; -exports[`createFsRouteTree > basic 12`] = ` +exports[`createFsRouteTree > basic 13`] = ` { "__pathname": "/dynamic/%E2%9C%85", "match": { @@ -359,7 +381,7 @@ exports[`createFsRouteTree > basic 12`] = ` } `; -exports[`createFsRouteTree > basic 13`] = ` +exports[`createFsRouteTree > basic 14`] = ` { "__pathname": "/dynamic/catchall", "match": { @@ -387,7 +409,7 @@ exports[`createFsRouteTree > basic 13`] = ` } `; -exports[`createFsRouteTree > basic 14`] = ` +exports[`createFsRouteTree > basic 15`] = ` { "__pathname": "/dynamic/catchall/static", "match": { @@ -421,7 +443,7 @@ exports[`createFsRouteTree > basic 14`] = ` } `; -exports[`createFsRouteTree > basic 15`] = ` +exports[`createFsRouteTree > basic 16`] = ` { "__pathname": "/dynamic/catchall/x", "match": { @@ -457,7 +479,7 @@ exports[`createFsRouteTree > basic 15`] = ` } `; -exports[`createFsRouteTree > basic 16`] = ` +exports[`createFsRouteTree > basic 17`] = ` { "__pathname": "/dynamic/catchall/x/y", "match": { @@ -499,7 +521,7 @@ exports[`createFsRouteTree > basic 16`] = ` } `; -exports[`createFsRouteTree > basic 17`] = ` +exports[`createFsRouteTree > basic 18`] = ` { "__pathname": "/dynamic/catchall/x/y/z", "match": { diff --git a/packages/react-server/src/features/router/tree.test.ts b/packages/react-server/src/features/router/tree.test.ts index f01633df2..71468dd2e 100644 --- a/packages/react-server/src/features/router/tree.test.ts +++ b/packages/react-server/src/features/router/tree.test.ts @@ -41,6 +41,7 @@ describe(createFsRouteTree, () => { "/other", "/not-found", "/test", + "/test/", "/test/other", "/test/not-found", "/dynamic", diff --git a/packages/react-server/src/features/router/tree.ts b/packages/react-server/src/features/router/tree.ts index b315aa7b3..009af6f8a 100644 --- a/packages/react-server/src/features/router/tree.ts +++ b/packages/react-server/src/features/router/tree.ts @@ -52,7 +52,7 @@ type MatchResult = { }; export function matchRouteTree(tree: TreeNode, pathname: string) { - // TODO: normalize somewhere else? + // TODO: more uniform handling of trailing slash pathname = normalizePathname(pathname); const prefixes = getPathPrefixes(pathname); From e7a9140969fbe9a705c2062bec0dd9eb76c9df15 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 17:55:07 +0900 Subject: [PATCH 11/16] wip: tweak matchRouteTree --- .../router/__snapshots__/tree.test.ts.snap | 555 +++++++++++++----- .../src/features/router/tree.test.ts | 6 +- .../react-server/src/features/router/tree.ts | 22 +- packages/react-server/src/lib/router.tsx | 2 + 4 files changed, 438 insertions(+), 147 deletions(-) diff --git a/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap b/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap index f3ee29f5a..c162c12e1 100644 --- a/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap +++ b/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap @@ -79,492 +79,771 @@ exports[`createFsRouteTree > basic 1`] = ` exports[`createFsRouteTree > basic 2`] = ` { "__pathname": "/", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - }, + { + "node": { + "layout": "/layout.tsx", + "page": "/page.tsx", + }, + "params": {}, + "prefix": "/", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 3`] = ` { "__pathname": "/other", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/other": { - "nodeValue": { + { + "node": { "page": "/other/page.tsx", }, "params": {}, + "prefix": "/other", + "type": "layout", }, - }, + { + "node": { + "page": "/other/page.tsx", + }, + "params": {}, + "prefix": "/other", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 4`] = ` { "__pathname": "/not-found", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/not-found": { - "nodeValue": undefined, + { + "node": undefined, "params": {}, + "prefix": "/not-found", + "type": "layout", }, - }, + { + "node": undefined, + "params": {}, + "prefix": "/not-found", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 5`] = ` { "__pathname": "/test", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/test": { - "nodeValue": { + { + "node": { "layout": "/test/layout.tsx", "page": "/test/page.tsx", }, "params": {}, + "prefix": "/test", + "type": "layout", }, - }, + { + "node": { + "layout": "/test/layout.tsx", + "page": "/test/page.tsx", + }, + "params": {}, + "prefix": "/test", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 6`] = ` { "__pathname": "/test/", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/test": { - "nodeValue": { + { + "node": { "layout": "/test/layout.tsx", "page": "/test/page.tsx", }, "params": {}, + "prefix": "/test", + "type": "layout", }, - }, + { + "node": { + "layout": "/test/layout.tsx", + "page": "/test/page.tsx", + }, + "params": {}, + "prefix": "/test", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 7`] = ` { "__pathname": "/test/other", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/test": { - "nodeValue": { + { + "node": { "layout": "/test/layout.tsx", "page": "/test/page.tsx", }, "params": {}, + "prefix": "/test", + "type": "layout", }, - "/test/other": { - "nodeValue": { + { + "node": { "page": "/test/other/page.tsx", }, "params": {}, + "prefix": "/test/other", + "type": "layout", }, - }, + { + "node": { + "page": "/test/other/page.tsx", + }, + "params": {}, + "prefix": "/test/other", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 8`] = ` { "__pathname": "/test/not-found", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/test": { - "nodeValue": { + { + "node": { "layout": "/test/layout.tsx", "page": "/test/page.tsx", }, "params": {}, + "prefix": "/test", + "type": "layout", }, - "/test/not-found": { - "nodeValue": undefined, + { + "node": undefined, "params": {}, + "prefix": "/test/not-found", + "type": "layout", }, - }, + { + "node": undefined, + "params": {}, + "prefix": "/test/not-found", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 9`] = ` { "__pathname": "/dynamic", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/dynamic": { - "nodeValue": { + { + "node": { "layout": "/dynamic/layout.tsx", "page": "/dynamic/page.tsx", }, "params": {}, + "prefix": "/dynamic", + "type": "layout", }, - }, + { + "node": { + "layout": "/dynamic/layout.tsx", + "page": "/dynamic/page.tsx", + }, + "params": {}, + "prefix": "/dynamic", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 10`] = ` { "__pathname": "/dynamic/static", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/dynamic": { - "nodeValue": { + { + "node": { "layout": "/dynamic/layout.tsx", "page": "/dynamic/page.tsx", }, "params": {}, + "prefix": "/dynamic", + "type": "layout", }, - "/dynamic/static": { - "nodeValue": { + { + "node": { "page": "/dynamic/static/page.tsx", }, "params": {}, + "prefix": "/dynamic/static", + "type": "layout", }, - }, + { + "node": { + "page": "/dynamic/static/page.tsx", + }, + "params": {}, + "prefix": "/dynamic/static", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 11`] = ` { "__pathname": "/dynamic/abc", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/dynamic": { - "nodeValue": { + { + "node": { "layout": "/dynamic/layout.tsx", "page": "/dynamic/page.tsx", }, "params": {}, + "prefix": "/dynamic", + "type": "layout", }, - "/dynamic/abc": { - "nodeValue": { + { + "node": { "layout": "/dynamic/[id]/layout.tsx", "page": "/dynamic/[id]/page.tsx", }, "params": { "id": "abc", }, + "prefix": "/dynamic/abc", + "type": "layout", }, - }, + { + "node": { + "layout": "/dynamic/[id]/layout.tsx", + "page": "/dynamic/[id]/page.tsx", + }, + "params": { + "id": "abc", + }, + "prefix": "/dynamic/abc", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 12`] = ` { "__pathname": "/dynamic/abc/def", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/dynamic": { - "nodeValue": { + { + "node": { "layout": "/dynamic/layout.tsx", "page": "/dynamic/page.tsx", }, "params": {}, + "prefix": "/dynamic", + "type": "layout", }, - "/dynamic/abc": { - "nodeValue": { + { + "node": { "layout": "/dynamic/[id]/layout.tsx", "page": "/dynamic/[id]/page.tsx", }, "params": { "id": "abc", }, + "prefix": "/dynamic/abc", + "type": "layout", }, - "/dynamic/abc/def": { - "nodeValue": { + { + "node": { "page": "/dynamic/[id]/[nested]/page.tsx", }, "params": { "id": "abc", "nested": "def", }, + "prefix": "/dynamic/abc/def", + "type": "layout", }, - }, + { + "node": { + "page": "/dynamic/[id]/[nested]/page.tsx", + }, + "params": { + "id": "abc", + "nested": "def", + }, + "prefix": "/dynamic/abc/def", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 13`] = ` { "__pathname": "/dynamic/%E2%9C%85", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/dynamic": { - "nodeValue": { + { + "node": { "layout": "/dynamic/layout.tsx", "page": "/dynamic/page.tsx", }, "params": {}, + "prefix": "/dynamic", + "type": "layout", }, - "/dynamic/%E2%9C%85": { - "nodeValue": { + { + "node": { "layout": "/dynamic/[id]/layout.tsx", "page": "/dynamic/[id]/page.tsx", }, "params": { "id": "✅", }, + "prefix": "/dynamic/%E2%9C%85", + "type": "layout", }, - }, + { + "node": { + "layout": "/dynamic/[id]/layout.tsx", + "page": "/dynamic/[id]/page.tsx", + }, + "params": { + "id": "✅", + }, + "prefix": "/dynamic/%E2%9C%85", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 14`] = ` { "__pathname": "/dynamic/catchall", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/dynamic": { - "nodeValue": { + { + "node": { "layout": "/dynamic/layout.tsx", "page": "/dynamic/page.tsx", }, "params": {}, + "prefix": "/dynamic", + "type": "layout", }, - "/dynamic/catchall": { - "nodeValue": { + { + "node": { "page": "/dynamic/catchall/page.tsx", }, "params": {}, + "prefix": "/dynamic/catchall", + "type": "layout", }, - }, + { + "node": { + "page": "/dynamic/catchall/page.tsx", + }, + "params": {}, + "prefix": "/dynamic/catchall", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 15`] = ` { "__pathname": "/dynamic/catchall/static", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/dynamic": { - "nodeValue": { + { + "node": { "layout": "/dynamic/layout.tsx", "page": "/dynamic/page.tsx", }, "params": {}, + "prefix": "/dynamic", + "type": "layout", }, - "/dynamic/catchall": { - "nodeValue": { + { + "node": { "page": "/dynamic/catchall/page.tsx", }, "params": {}, + "prefix": "/dynamic/catchall", + "type": "layout", }, - "/dynamic/catchall/static": { - "nodeValue": { + { + "node": { "page": "/dynamic/catchall/static/page.tsx", }, "params": {}, + "prefix": "/dynamic/catchall/static", + "type": "layout", }, - }, + { + "node": { + "page": "/dynamic/catchall/static/page.tsx", + }, + "params": {}, + "prefix": "/dynamic/catchall/static", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 16`] = ` { "__pathname": "/dynamic/catchall/x", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/dynamic": { - "nodeValue": { + { + "node": { "layout": "/dynamic/layout.tsx", "page": "/dynamic/page.tsx", }, "params": {}, + "prefix": "/dynamic", + "type": "layout", }, - "/dynamic/catchall": { - "nodeValue": { + { + "node": { "page": "/dynamic/catchall/page.tsx", }, "params": {}, + "prefix": "/dynamic/catchall", + "type": "layout", }, - "/dynamic/catchall/x": { - "nodeValue": { + { + "node": { "page": "/dynamic/catchall/[...any]/page.tsx", }, "params": { "any": "x", }, + "prefix": "/dynamic/catchall/x", + "type": "layout", }, - }, + { + "node": undefined, + "params": { + "any": "x", + }, + "prefix": "/dynamic/catchall/x", + "type": "layout", + }, + { + "node": { + "page": "/dynamic/catchall/[...any]/page.tsx", + }, + "params": { + "any": "x", + }, + "prefix": "/dynamic/catchall/x", + "type": "page", + }, + ], } `; exports[`createFsRouteTree > basic 17`] = ` { "__pathname": "/dynamic/catchall/x/y", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/dynamic": { - "nodeValue": { + { + "node": { "layout": "/dynamic/layout.tsx", "page": "/dynamic/page.tsx", }, "params": {}, + "prefix": "/dynamic", + "type": "layout", }, - "/dynamic/catchall": { - "nodeValue": { + { + "node": { "page": "/dynamic/catchall/page.tsx", }, "params": {}, + "prefix": "/dynamic/catchall", + "type": "layout", + }, + { + "node": { + "page": "/dynamic/catchall/[...any]/page.tsx", + }, + "params": { + "any": "x/y", + }, + "prefix": "/dynamic/catchall/x", + "type": "layout", + }, + { + "node": undefined, + "params": { + "any": "x/y", + }, + "prefix": "/dynamic/catchall/x", + "type": "layout", }, - "/dynamic/catchall/x": { - "nodeValue": undefined, + { + "node": undefined, "params": { "any": "x/y", }, + "prefix": "/dynamic/catchall/x/y", + "type": "layout", }, - "/dynamic/catchall/x/y": { - "nodeValue": { + { + "node": { "page": "/dynamic/catchall/[...any]/page.tsx", }, "params": { "any": "x/y", }, + "prefix": "/dynamic/catchall/x", + "type": "page", }, - }, + ], } `; exports[`createFsRouteTree > basic 18`] = ` { "__pathname": "/dynamic/catchall/x/y/z", - "match": { - "/": { - "nodeValue": { + "matches": [ + { + "node": { "layout": "/layout.tsx", "page": "/page.tsx", }, "params": {}, + "prefix": "/", + "type": "layout", }, - "/dynamic": { - "nodeValue": { + { + "node": { "layout": "/dynamic/layout.tsx", "page": "/dynamic/page.tsx", }, "params": {}, + "prefix": "/dynamic", + "type": "layout", }, - "/dynamic/catchall": { - "nodeValue": { + { + "node": { "page": "/dynamic/catchall/page.tsx", }, "params": {}, + "prefix": "/dynamic/catchall", + "type": "layout", + }, + { + "node": { + "page": "/dynamic/catchall/[...any]/page.tsx", + }, + "params": { + "any": "x/y/z", + }, + "prefix": "/dynamic/catchall/x", + "type": "layout", }, - "/dynamic/catchall/x": { - "nodeValue": undefined, + { + "node": undefined, "params": { "any": "x/y/z", }, + "prefix": "/dynamic/catchall/x", + "type": "layout", }, - "/dynamic/catchall/x/y": { - "nodeValue": undefined, + { + "node": undefined, "params": { "any": "x/y/z", }, + "prefix": "/dynamic/catchall/x/y", + "type": "layout", }, - "/dynamic/catchall/x/y/z": { - "nodeValue": { + { + "node": undefined, + "params": { + "any": "x/y/z", + }, + "prefix": "/dynamic/catchall/x/y/z", + "type": "layout", + }, + { + "node": { "page": "/dynamic/catchall/[...any]/page.tsx", }, "params": { "any": "x/y/z", }, + "prefix": "/dynamic/catchall/x", + "type": "page", }, - }, + ], } `; diff --git a/packages/react-server/src/features/router/tree.test.ts b/packages/react-server/src/features/router/tree.test.ts index 71468dd2e..8ab11f0df 100644 --- a/packages/react-server/src/features/router/tree.test.ts +++ b/packages/react-server/src/features/router/tree.test.ts @@ -29,9 +29,9 @@ describe(createFsRouteTree, () => { const result = matchRouteTree(tree, pathname); return { __pathname: pathname, - match: objectMapValues(result.matches, (v) => ({ - nodeValue: v.node.value, - params: v.params, + matches: result.matches.map((m) => ({ + ...m, + node: m.node.value, })), }; } diff --git a/packages/react-server/src/features/router/tree.ts b/packages/react-server/src/features/router/tree.ts index 009af6f8a..795e8d48d 100644 --- a/packages/react-server/src/features/router/tree.ts +++ b/packages/react-server/src/features/router/tree.ts @@ -43,12 +43,14 @@ function sortDynamicRoutes(tree: TreeNode) { } type MatchNodeEntry = { + prefix: string; + type: "layout" | "page"; node: TreeNode; params: Record; }; type MatchResult = { - matches: Record>; + matches: MatchNodeEntry[]; }; export function matchRouteTree(tree: TreeNode, pathname: string) { @@ -58,7 +60,7 @@ export function matchRouteTree(tree: TreeNode, pathname: string) { let node = tree; let params: Record = {}; - const result: MatchResult = { matches: {} }; + const result: MatchResult = { matches: [] }; for (let i = 0; i < prefixes.length; i++) { const prefix = prefixes[i]!; const segment = prefix.split("/").at(-1)!; @@ -68,11 +70,16 @@ export function matchRouteTree(tree: TreeNode, pathname: string) { if (next.catchAll) { const rest = pathname.slice(prefixes[i - 1]!.length + 1); params = { ...params, [next.param]: decodeURI(rest) }; - result.matches[prefix] = { node, params }; + result.matches.push({ prefix, type: "layout", node, params }); for (const prefix of prefixes.slice(i)) { - result.matches[prefix] = { node: initTreeNode(), params }; + result.matches.push({ + prefix, + type: "layout", + node: initTreeNode(), + params, + }); } - result.matches[pathname] = { node, params }; + result.matches.push({ prefix, type: "page", node, params }); break; } if (next.param) { @@ -81,7 +88,10 @@ export function matchRouteTree(tree: TreeNode, pathname: string) { } else { node = initTreeNode(); } - result.matches[prefix] = { node, params }; + result.matches.push({ prefix, type: "layout", node, params }); + if (prefix === pathname) { + result.matches.push({ prefix, type: "page", node, params }); + } } return result; } diff --git a/packages/react-server/src/lib/router.tsx b/packages/react-server/src/lib/router.tsx index 568735d09..a6077f509 100644 --- a/packages/react-server/src/lib/router.tsx +++ b/packages/react-server/src/lib/router.tsx @@ -4,6 +4,7 @@ import { createFsRouteTree, initTreeNode, matchRouteChild, + matchRouteTree, } from "../features/router/tree"; import { getPathPrefixes, normalizePathname } from "../features/router/utils"; import { type ReactServerErrorContext, createError } from "./error"; @@ -76,6 +77,7 @@ export async function renderRouteMap( ) { const url = serializeUrl(new URL(request.url)); const pathname = normalizePathname(url.pathname); + const result = matchRouteTree(tree, pathname); const prefixes = getPathPrefixes(pathname); const baseProps: Omit = { url, From 574277ea0e8bf8ff14bb406e6bdc787eeb34c110 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 18:05:48 +0900 Subject: [PATCH 12/16] refactor: implement renderRouteMap using matchRouteTree --- packages/react-server/src/lib/router.tsx | 47 +++++------------------- 1 file changed, 9 insertions(+), 38 deletions(-) diff --git a/packages/react-server/src/lib/router.tsx b/packages/react-server/src/lib/router.tsx index a6077f509..bf9d19379 100644 --- a/packages/react-server/src/lib/router.tsx +++ b/packages/react-server/src/lib/router.tsx @@ -2,11 +2,8 @@ import React from "react"; import { type TreeNode, createFsRouteTree, - initTreeNode, - matchRouteChild, matchRouteTree, } from "../features/router/tree"; -import { getPathPrefixes, normalizePathname } from "../features/router/utils"; import { type ReactServerErrorContext, createError } from "./error"; // TODO: move to features/router/react-server @@ -45,6 +42,7 @@ async function renderLayout( node: RouteModuleNode, props: PageProps, name: string, + // TODO: key can be just prefix? key?: string, ) { const { ErrorBoundary, RedirectBoundary, LayoutContent } = @@ -70,15 +68,11 @@ async function renderLayout( return acc; } -// TODO: implement on top of matchRouteTree export async function renderRouteMap( tree: RouteModuleNode, request: Pick, ) { const url = serializeUrl(new URL(request.url)); - const pathname = normalizePathname(url.pathname); - const result = matchRouteTree(tree, pathname); - const prefixes = getPathPrefixes(pathname); const baseProps: Omit = { url, request: { @@ -86,42 +80,19 @@ export async function renderRouteMap( headers: serializeHeaders(request.headers), }, }; - - let node = tree; - let params: BaseProps["params"] = {}; const pages: Record = {}; const layouts: Record = {}; - for (let i = 0; i < prefixes.length; i++) { - const prefix = prefixes[i]!; - const key = prefix.split("/").at(-1)!; - const next = matchRouteChild(key, node); - if (next?.child) { - node = next.child; - if (next?.catchAll) { - const rest = pathname.slice(prefixes[i - 1]!.length + 1); - params = { ...params, [next.param]: decodeURI(rest) }; - const props: BaseProps = { ...baseProps, params }; - layouts[prefix] = await renderLayout(node, props, prefix); - for (const prefix of prefixes.slice(i)) { - layouts[prefix] = await renderLayout(initTreeNode(), props, prefix); - } - pages[pathname] = renderPage(node, props); - break; - } else if (next.param) { - params = { ...params, [next.param]: decodeURI(key) }; - } + const result = matchRouteTree(tree, url.pathname); + for (const m of result.matches) { + const props: BaseProps = { ...baseProps, params: m.params }; + if (m.type === "layout") { + layouts[m.prefix] = await renderLayout(m.node, props, m.prefix); + } else if (m.type === "page") { + pages[m.prefix] = renderPage(m.node, props); } else { - node = initTreeNode(); - } - const props: BaseProps = { ...baseProps, params }; - // re-mount subtree when dynamic segment changes - const cacheKey = [i, next?.param ?? "", key].join(""); - layouts[prefix] = await renderLayout(node, props, prefix, cacheKey); - if (prefix === pathname) { - pages[prefix] = renderPage(node, props); + m.type satisfies never; } } - return { pages, layouts }; } From bd11cc747c2eb7d8e61ff4e0f393942050dea4ee Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 18:07:50 +0900 Subject: [PATCH 13/16] chore: unused --- packages/react-server/src/features/router/tree.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-server/src/features/router/tree.test.ts b/packages/react-server/src/features/router/tree.test.ts index 8ab11f0df..fac4fffc6 100644 --- a/packages/react-server/src/features/router/tree.test.ts +++ b/packages/react-server/src/features/router/tree.test.ts @@ -1,4 +1,3 @@ -import { objectMapValues } from "@hiogawa/utils"; import { describe, expect, it } from "vitest"; import { createFsRouteTree, matchRouteTree } from "./tree"; From 750a9003cd3cce436b46b82ff4aaa2a1fa619de6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 18:09:36 +0900 Subject: [PATCH 14/16] fix: fix renderLayout key --- packages/react-server/src/lib/router.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/react-server/src/lib/router.tsx b/packages/react-server/src/lib/router.tsx index bf9d19379..4d2ea1ce5 100644 --- a/packages/react-server/src/lib/router.tsx +++ b/packages/react-server/src/lib/router.tsx @@ -41,14 +41,12 @@ function renderPage(node: RouteModuleNode, props: PageProps) { async function renderLayout( node: RouteModuleNode, props: PageProps, - name: string, - // TODO: key can be just prefix? - key?: string, + prefix: string, ) { const { ErrorBoundary, RedirectBoundary, LayoutContent } = await importRuntimeClient(); - let acc = ; + let acc = ; acc = {acc}; const ErrorPage = node.value?.error?.default; @@ -58,12 +56,12 @@ async function renderLayout( const Layout = node.value?.layout?.default; if (Layout) { acc = ( - + {acc} ); } else { - acc = {acc}; + acc = {acc}; } return acc; } From 4cd694d427b3124c77a0759a0618be47b7f9c84e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 18:36:50 +0900 Subject: [PATCH 15/16] fix: fix catchall --- .../router/__snapshots__/tree.test.ts.snap | 28 ++----------------- .../react-server/src/features/router/tree.ts | 4 +-- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap b/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap index c162c12e1..74f290716 100644 --- a/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap +++ b/packages/react-server/src/features/router/__snapshots__/tree.test.ts.snap @@ -678,14 +678,6 @@ exports[`createFsRouteTree > basic 16`] = ` "prefix": "/dynamic/catchall/x", "type": "layout", }, - { - "node": undefined, - "params": { - "any": "x", - }, - "prefix": "/dynamic/catchall/x", - "type": "layout", - }, { "node": { "page": "/dynamic/catchall/[...any]/page.tsx", @@ -740,14 +732,6 @@ exports[`createFsRouteTree > basic 17`] = ` "prefix": "/dynamic/catchall/x", "type": "layout", }, - { - "node": undefined, - "params": { - "any": "x/y", - }, - "prefix": "/dynamic/catchall/x", - "type": "layout", - }, { "node": undefined, "params": { @@ -763,7 +747,7 @@ exports[`createFsRouteTree > basic 17`] = ` "params": { "any": "x/y", }, - "prefix": "/dynamic/catchall/x", + "prefix": "/dynamic/catchall/x/y", "type": "page", }, ], @@ -810,14 +794,6 @@ exports[`createFsRouteTree > basic 18`] = ` "prefix": "/dynamic/catchall/x", "type": "layout", }, - { - "node": undefined, - "params": { - "any": "x/y/z", - }, - "prefix": "/dynamic/catchall/x", - "type": "layout", - }, { "node": undefined, "params": { @@ -841,7 +817,7 @@ exports[`createFsRouteTree > basic 18`] = ` "params": { "any": "x/y/z", }, - "prefix": "/dynamic/catchall/x", + "prefix": "/dynamic/catchall/x/y/z", "type": "page", }, ], diff --git a/packages/react-server/src/features/router/tree.ts b/packages/react-server/src/features/router/tree.ts index 795e8d48d..ee6f08ca3 100644 --- a/packages/react-server/src/features/router/tree.ts +++ b/packages/react-server/src/features/router/tree.ts @@ -71,7 +71,7 @@ export function matchRouteTree(tree: TreeNode, pathname: string) { const rest = pathname.slice(prefixes[i - 1]!.length + 1); params = { ...params, [next.param]: decodeURI(rest) }; result.matches.push({ prefix, type: "layout", node, params }); - for (const prefix of prefixes.slice(i)) { + for (const prefix of prefixes.slice(i + 1)) { result.matches.push({ prefix, type: "layout", @@ -79,7 +79,7 @@ export function matchRouteTree(tree: TreeNode, pathname: string) { params, }); } - result.matches.push({ prefix, type: "page", node, params }); + result.matches.push({ prefix: pathname, type: "page", node, params }); break; } if (next.param) { From 4f9bc1ea248dfee9d80446239a936f58a300f795 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 3 Jun 2024 18:49:13 +0900 Subject: [PATCH 16/16] test: update --- packages/react-server/examples/basic/e2e/basic.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/react-server/examples/basic/e2e/basic.test.ts b/packages/react-server/examples/basic/e2e/basic.test.ts index 9422605e1..ddb86b2dc 100644 --- a/packages/react-server/examples/basic/e2e/basic.test.ts +++ b/packages/react-server/examples/basic/e2e/basic.test.ts @@ -870,7 +870,7 @@ testNoJs("catch-all routes @nojs", async ({ page }) => { await testCatchallRoute(page, { js: false }); }); -async function testCatchallRoute(page: Page, options: { js: boolean }) { +async function testCatchallRoute(page: Page, _options: { js: boolean }) { await page .getByRole("link", { name: "• /test/dynamic/catchall/static" }) .click(); @@ -904,10 +904,8 @@ async function testCatchallRoute(page: Page, options: { js: boolean }) { .click(); await page.getByText("file: /test/dynamic/catchall").click(); await page.getByText('params: {"any":"x/y/w"}').click(); - // state is preserved - await expect(page.getByLabel("test state")).toBeChecked({ - checked: options.js, - }); + // state is not preserved + await expect(page.getByLabel("test state")).not.toBeChecked(); } test("full client route", async ({ page }) => {