@warlock.js/web 5.1.0 → 5.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +182 -0
- package/README.md +126 -12
- package/esm/build/contribution.d.mts +10 -13
- package/esm/build/contribution.mjs +45 -57
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +104 -44
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-client-registry.mjs +46 -12
- package/esm/build/generate-client-registry.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs +54 -12
- package/esm/build/generate-pages-barrel.mjs.map +1 -1
- package/esm/build/page-default-export.mjs +48 -0
- package/esm/build/page-default-export.mjs.map +1 -0
- package/esm/build/page-routes-manifest.mjs +21 -0
- package/esm/build/page-routes-manifest.mjs.map +1 -0
- package/esm/build/public-files.mjs +45 -0
- package/esm/build/public-files.mjs.map +1 -0
- package/esm/build/read-route-exports.mjs.map +1 -1
- package/esm/client/build-hydrated-tree.mjs +39 -8
- package/esm/client/build-hydrated-tree.mjs.map +1 -1
- package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
- package/esm/client/navigation/prefetch.mjs.map +1 -1
- package/esm/client/runtime/index.d.mts +2 -1
- package/esm/client/runtime/index.mjs +2 -1
- package/esm/client/runtime/manifest.mjs +2 -1
- package/esm/client/runtime/manifest.mjs.map +1 -1
- package/esm/client/runtime/matcher.d.mts +12 -0
- package/esm/client/runtime/matcher.mjs +12 -0
- package/esm/client/runtime/matcher.mjs.map +1 -1
- package/esm/client/runtime/types.d.mts +14 -1
- package/esm/components/document-context.d.mts +39 -1
- package/esm/components/document-context.mjs.map +1 -1
- package/esm/components/link.mjs.map +1 -1
- package/esm/components/scripts.mjs +3 -1
- package/esm/components/scripts.mjs.map +1 -1
- package/esm/context.d.mts +5 -101
- package/esm/hydration-payload.d.mts +1 -1
- package/esm/hydration-payload.mjs +36 -7
- package/esm/hydration-payload.mjs.map +1 -1
- package/esm/index.d.mts +3 -2
- package/esm/loaders.d.mts +6 -35
- package/esm/props.d.mts +15 -7
- package/esm/route.d.mts +1 -16
- package/esm/routing/filesystem-route.mjs +45 -0
- package/esm/routing/filesystem-route.mjs.map +1 -0
- package/esm/routing/layout-policy.mjs.map +1 -1
- package/esm/routing/query-string.mjs.map +1 -1
- package/esm/routing/route-table.mjs.map +1 -1
- package/esm/runtime/register-modules.d.mts +19 -0
- package/esm/runtime/register-modules.mjs +22 -0
- package/esm/runtime/register-modules.mjs.map +1 -0
- package/esm/server/build-hydration-payload.mjs +1 -0
- package/esm/server/build-hydration-payload.mjs.map +1 -1
- package/esm/server/create-page-module-loader.mjs +1 -0
- package/esm/server/create-page-module-loader.mjs.map +1 -1
- package/esm/server/create-page-route-handler.d.mts +30 -5
- package/esm/server/create-page-route-handler.mjs +127 -42
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/dev-server.mjs +1 -12
- package/esm/server/dev-server.mjs.map +1 -1
- package/esm/server/error-page.d.mts +14 -0
- package/esm/server/error-page.mjs +39 -0
- package/esm/server/error-page.mjs.map +1 -0
- package/esm/server/execute-page-request.d.mts +6 -3
- package/esm/server/execute-page-request.mjs +78 -79
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/execute-page-request.types.d.mts +32 -91
- package/esm/server/hydration-client-url.mjs +1 -1
- package/esm/server/index.d.mts +5 -5
- package/esm/server/index.mjs +3 -4
- package/esm/server/install-page-routes-from-manifest.d.mts +16 -6
- package/esm/server/install-page-routes-from-manifest.mjs +70 -9
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +31 -14
- package/esm/server/install-page-routes.mjs +90 -38
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/install-production-page-routes.mjs +2 -35
- package/esm/server/install-production-page-routes.mjs.map +1 -1
- package/esm/server/match-page-route.mjs +1 -1
- package/esm/server/match-page-route.mjs.map +1 -1
- package/esm/server/not-found-page.mjs +1 -1
- package/esm/server/not-found-page.mjs.map +1 -1
- package/esm/server/page-context.d.mts +2 -8
- package/esm/server/page-context.mjs +2 -15
- package/esm/server/page-context.mjs.map +1 -1
- package/esm/server/page-file-change.mjs +77 -0
- package/esm/server/page-file-change.mjs.map +1 -0
- package/esm/server/page-manifest.d.mts +24 -9
- package/esm/server/page-manifest.mjs.map +1 -1
- package/esm/server/page-render-bundle.mjs +38 -0
- package/esm/server/page-render-bundle.mjs.map +1 -0
- package/esm/server/page-route-reload.mjs +54 -0
- package/esm/server/page-route-reload.mjs.map +1 -0
- package/esm/server/register-production-public-files.mjs +40 -0
- package/esm/server/register-production-public-files.mjs.map +1 -0
- package/esm/server/render-page.d.mts +10 -13
- package/esm/server/render-page.mjs +153 -27
- package/esm/server/render-page.mjs.map +1 -1
- package/esm/server/resolve-page-metadata.mjs +1 -1
- package/esm/server/resolve-page-metadata.mjs.map +1 -1
- package/esm/server/settle-page-response.d.mts +58 -1
- package/esm/server/settle-page-response.mjs +110 -45
- package/esm/server/settle-page-response.mjs.map +1 -1
- package/esm/server/stylesheet-urls.d.mts +40 -29
- package/esm/server/stylesheet-urls.mjs +136 -51
- package/esm/server/stylesheet-urls.mjs.map +1 -1
- package/esm/server/unregistered-pages.mjs +42 -0
- package/esm/server/unregistered-pages.mjs.map +1 -0
- package/esm/server/web-connector-factory.d.mts +1 -2
- package/esm/server/web-connector-factory.mjs +6 -5
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.mjs +137 -17
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/shared.d.mts +17 -2
- package/esm/shared.mjs +2 -6
- package/esm/shared.mjs.map +1 -1
- package/esm/vite/build-client.mjs +1 -1
- package/esm/vite/build-client.mjs.map +1 -1
- package/esm/vite/gate-a-resolve.mjs +66 -12
- package/esm/vite/gate-a-resolve.mjs.map +1 -1
- package/esm/vite/gate-b-secrets.mjs +34 -16
- package/esm/vite/gate-b-secrets.mjs.map +1 -1
- package/esm/vite/gate-c-verify.mjs +5 -5
- package/esm/vite/gate-c-verify.mjs.map +1 -1
- package/esm/vite/hydration-entries.mjs +1 -1
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/index.d.mts +8 -5
- package/esm/vite/index.mjs +123 -10
- package/esm/vite/index.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.d.mts +21 -2
- package/esm/vite/page-registry-plugin.mjs +47 -13
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/esm/vite/projection.d.mts +1 -1
- package/esm/vite/projection.mjs +37 -14
- package/esm/vite/projection.mjs.map +1 -1
- package/llms-full.txt +220 -56
- package/llms.txt +5 -3
- package/package.json +3 -3
- package/skills/add-web-to-an-app/SKILL.md +3 -3
- package/skills/create-a-page/SKILL.md +117 -10
- package/skills/load-page-data/SKILL.md +92 -12
- package/skills/navigate-on-the-client/SKILL.md +1 -1
- package/skills/serve-styles/SKILL.md +13 -12
- package/skills/use-layouts/SKILL.md +14 -8
- package/skills/write-the-root/SKILL.md +3 -1
- package/esm/server/buffered-response.d.mts +0 -58
- package/esm/server/buffered-response.mjs +0 -115
- package/esm/server/buffered-response.mjs.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-hydrated-tree.mjs","names":[],"sources":["../../../../../../../web/src/client/build-hydrated-tree.ts"],"sourcesContent":["/**\n * The hydration COMPOSER: payload + page registry -> the ReactNode to mount.\n *\n * It takes the registry as an ARGUMENT and touches no browser global, which is\n * the whole point of it living apart from `index.ts`: every rule below is\n * testable with a hand-built registry, no bundler, no virtual module, no DOM.\n *\n * LOOKUP BY NAME, NEVER BY MATCH. `payload.name` is the identity of the entry\n * the SERVER matched for this exact request (document-context.ts's `name`\n * field). Re-deriving it from `location.pathname` with `matchClientRoute`\n * would be a second implementation of route semantics running against the one\n * request it is hydrating, free to disagree with the server that produced the\n * markup. `matchClientRoute` is for client-side NAVIGATION, where no server\n * answer exists yet.\n */\nimport { createElement, type ComponentType, type ReactNode } from \"react\";\nimport type { HydrationDocumentPayloadSource } from \"../hydration-payload\";\nimport { loadClientRouteComposition } from \"./runtime\";\nimport type { ClientPageEntry, ClientProjectedModule } from \"./runtime/types\";\n\n/** What every composed level receives — the shape `render-page.ts` uses server-side. */\ntype HydratedLevelProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly children?: ReactNode;\n};\n\nfunction describeKnownNames(knownPageNames: readonly string[]): string {\n if (knownPageNames.length === 0) return \"The client page registry is empty.\";\n\n return `The registry knows: ${knownPageNames.map((name) => JSON.stringify(name)).join(\", \")}.`;\n}\n\n/**\n * The THIRD hydration failure case, beside an absent and a malformed payload.\n *\n * It fails CLOSED — no default entry, no nearest-path fallback, no silent\n * no-op. A registry that quietly substitutes a page produces a browser showing\n * one page's markup running another page's code, which is precisely the defect\n * this entry point was rewritten to remove; a fallback would reintroduce it\n * wearing a recovery costume. Throwing leaves the server-rendered markup on\n * screen and un-hydrated, which is degraded but honest.\n */\nexport class UnknownHydrationPageNameError extends Error {\n public constructor(\n public readonly pageName: string,\n public readonly knownPageNames: readonly string[],\n ) {\n super(\n `Warlock hydration aborted: the payload names page ${JSON.stringify(pageName)}, which is ` +\n `not in the client page registry. ${describeKnownNames(knownPageNames)} The server ` +\n \"rendered a page this browser bundle does not carry, so the server and client were \" +\n \"built from different page graphs. To fix: rebuild the client bundle, or check that \" +\n \"the page's file still exports a `route` discovery can see.\",\n );\n this.name = \"UnknownHydrationPageNameError\";\n }\n}\n\nfunction findEntryByName(\n pages: readonly ClientPageEntry[],\n name: string,\n): ClientPageEntry {\n const entry = pages.find((candidate) => candidate.name === name);\n\n if (entry === undefined) {\n throw new UnknownHydrationPageNameError(\n name,\n pages.map((candidate) => candidate.name),\n );\n }\n\n return entry;\n}\n\n/**\n * A level's component, or undefined when the module exports no default.\n *\n * Undefined is NOT an error: `render-page.ts:258` and `:279` treat a missing\n * default as a passthrough server-side, and the client tree has to match the\n * markup React is hydrating against — introducing a level here that the server\n * did not render is a hydration mismatch, not a repair.\n */\nfunction componentOf(\n module: ClientProjectedModule,\n): ComponentType<HydratedLevelProps> | undefined {\n const component = module.default;\n\n return typeof component === \"function\"\n ? (component as ComponentType<HydratedLevelProps>)\n : undefined;\n}\n\nfunction wrap(\n module: ClientProjectedModule,\n data: unknown,\n shared: unknown,\n children: ReactNode,\n): ReactNode {\n const Component = componentOf(module);\n\n if (Component === undefined) return children;\n\n return createElement(Component, { data, shared, children });\n}\n\n/**\n * Compose the tree the server rendered inside `#root`: ordered layouts wrapping\n * the Page, layouts OUTERMOST FIRST as `ClientRouteComposition` declares them,\n * each level receiving `{ data, shared }` and every wrapper additionally\n * `children`.\n *\n * ── THE APP LEVEL IS DELIBERATELY ABSENT, AND MUST STAY ABSENT ──────────────\n * `ClientRouteComposition.App` and `payload.appData` still exist and are still\n * carried; they are contracts owned elsewhere. They are simply not part of THIS\n * tree, because App is not part of the markup this tree hydrates against:\n *\n * - Server-side, `render-page.ts`'s `wrapRootward` wraps the page leaf in\n * `[\"layout\", \"app\"]` (`render-page.ts:274`), so the document React renders\n * is `App( Layout( Page ) )`.\n * - The app root is the level that owns `<html>`/`<body>` and renders\n * `<div id=\"root\">{children}</div>` inside the body. So App CONTAINS the\n * mount point — the markup actually inside `#root` is `Layout( Page )`.\n * - `hydrate-page.tsx` mounts at `#root` and nowhere else.\n *\n * Composing App here would therefore hydrate a whole `<html>` document inside a\n * `<div>` the server filled with a layout: a guaranteed hydration mismatch. If\n * you arrived here from the optional `App?` on the composition type and are\n * about to \"complete\" the tree with it — that would be the defect, not the\n * omission.\n *\n * `load()` is awaited exactly ONCE per hydration and its result reused for all\n * levels — the composition arrives whole, so calling it per layout would be\n * one network waterfall per level for no new information.\n */\nexport async function buildHydratedTree(\n pages: readonly ClientPageEntry[],\n payload: HydrationDocumentPayloadSource,\n): Promise<ReactNode> {\n const entry = findEntryByName(pages, payload.name);\n const composition = await loadClientRouteComposition(entry);\n const { shared } = payload;\n\n const Page = componentOf(composition.Page);\n let element: ReactNode =\n Page === undefined ? null : createElement(Page, { data: payload.pageData, shared });\n\n // Innermost layout wraps the page, so walk the outermost-first list backwards.\n for (let index = composition.layouts.length - 1; index >= 0; index -= 1) {\n element = wrap(composition.layouts[index]!, payload.layoutData, shared, element);\n }\n\n return element;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA2BA,SAAS,mBAAmB,gBAA2C;CACrE,IAAI,eAAe,WAAW,GAAG,OAAO;CAExC,OAAO,uBAAuB,eAAe,KAAK,SAAS,KAAK,UAAU,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE;AAC9F;;;;;;;;;;;AAYA,IAAa,gCAAb,cAAmD,MAAM;CAErC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,gBAChB;EACA,MACE,qDAAqD,KAAK,UAAU,QAAQ,EAAE,8CACxC,mBAAmB,cAAc,EAAE,8OAI3E;EATgB;EACA;EAShB,KAAK,OAAO;CACd;AACF;AAEA,SAAS,gBACP,OACA,MACiB;CACjB,MAAM,QAAQ,MAAM,MAAM,cAAc,UAAU,SAAS,IAAI;CAE/D,IAAI,UAAU,QACZ,MAAM,IAAI,8BACR,MACA,MAAM,KAAK,cAAc,UAAU,IAAI,CACzC;CAGF,OAAO;AACT;;;;;;;;;AAUA,SAAS,YACP,QAC+C;CAC/C,MAAM,YAAY,OAAO;CAEzB,OAAO,OAAO,cAAc,aACvB,YACD;AACN;AAEA,SAAS,KACP,QACA,MACA,QACA,UACW;CACX,MAAM,YAAY,YAAY,MAAM;CAEpC,IAAI,cAAc,QAAW,OAAO;CAEpC,OAAO,cAAc,WAAW;EAAE;EAAM;EAAQ;CAAS,CAAC;AAC5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,eAAsB,kBACpB,OACA,SACoB;CAEpB,MAAM,cAAc,MAAM,2BADZ,gBAAgB,OAAO,QAAQ,IACY,CAAC;CAC1D,MAAM,EAAE,WAAW;CAEnB,MAAM,OAAO,YAAY,YAAY,IAAI;CACzC,IAAI,UACF,SAAS,SAAY,OAAO,cAAc,MAAM;EAAE,MAAM,QAAQ;EAAU;CAAO,CAAC;CAGpF,KAAK,IAAI,QAAQ,YAAY,QAAQ,SAAS,GAAG,SAAS,GAAG,SAAS,GACpE,UAAU,KAAK,YAAY,QAAQ,QAAS,QAAQ,YAAY,QAAQ,OAAO;CAGjF,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"build-hydrated-tree.mjs","names":[],"sources":["../../../../../../../web/src/client/build-hydrated-tree.ts"],"sourcesContent":["/**\n * The hydration COMPOSER: payload + page registry -> the ReactNode to mount.\n *\n * It takes the registry as an ARGUMENT and touches no browser global, which is\n * the whole point of it living apart from `index.ts`: every rule below is\n * testable with a hand-built registry, no bundler, no virtual module, no DOM.\n *\n * LOOKUP BY NAME, NEVER BY MATCH. `payload.name` is the identity of the entry\n * the SERVER matched for this exact request (document-context.ts's `name`\n * field). Re-deriving it from `location.pathname` with `matchClientRoute`\n * would be a second implementation of route semantics running against the one\n * request it is hydrating, free to disagree with the server that produced the\n * markup. `matchClientRoute` is for client-side NAVIGATION, where no server\n * answer exists yet.\n */\nimport { createElement, type ComponentType, type ReactNode } from \"react\";\nimport type {\n HydrationDocumentPayloadSource,\n SerializedErrorPageProps,\n} from \"../hydration-payload\";\nimport { registerModules } from \"../runtime/register-modules\";\nimport { loadClientRouteComposition } from \"./runtime\";\nimport type { ClientPageEntry, ClientProjectedModule } from \"./runtime/types\";\n\n/** What every composed level receives — the shape `render-page.ts` uses server-side. */\ntype HydratedLevelProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly children?: ReactNode;\n};\n\n/** The ordinary page leaf alone receives params from the server's match. */\ntype HydratedPageProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly params: Readonly<Record<string, string>>;\n};\n\nfunction describeKnownNames(knownPageNames: readonly string[]): string {\n if (knownPageNames.length === 0) return \"The client page registry is empty.\";\n\n return `The registry knows: ${knownPageNames.map((name) => JSON.stringify(name)).join(\", \")}.`;\n}\n\n/**\n * The THIRD hydration failure case, beside an absent and a malformed payload.\n *\n * It fails CLOSED — no default entry, no nearest-path fallback, no silent\n * no-op. A registry that quietly substitutes a page produces a browser showing\n * one page's markup running another page's code, which is precisely the defect\n * this entry point was rewritten to remove; a fallback would reintroduce it\n * wearing a recovery costume. Throwing leaves the server-rendered markup on\n * screen and un-hydrated, which is degraded but honest.\n */\nexport class UnknownHydrationPageNameError extends Error {\n public constructor(\n public readonly pageName: string,\n public readonly knownPageNames: readonly string[],\n ) {\n super(\n `Warlock hydration aborted: the payload names page ${JSON.stringify(pageName)}, which is ` +\n `not in the client page registry. ${describeKnownNames(knownPageNames)} The server ` +\n \"rendered a page this browser bundle does not carry, so the server and client were \" +\n \"built from different page graphs. To fix: rebuild the client bundle, or check that \" +\n \"the page's file still exports a `route` discovery can see.\",\n );\n this.name = \"UnknownHydrationPageNameError\";\n }\n}\n\n/**\n * The server selected an app error page, but this browser graph cannot load it.\n * Substituting the ordinary page would execute the component that already\n * failed and hydrate markup the server did not render, so this path fails\n * closed just like an unknown route name.\n */\nexport class MissingHydrationErrorPageError extends Error {\n public constructor(public readonly pageName: string) {\n super(\n `Warlock hydration aborted: the server selected error.page.tsx for route ` +\n `${JSON.stringify(pageName)}, but that route's client composition has no ErrorPage ` +\n \"module. Rebuild the client page registry so it projects the discovered error page.\",\n );\n this.name = \"MissingHydrationErrorPageError\";\n }\n}\n\nfunction findEntryByName(\n pages: readonly ClientPageEntry[],\n name: string,\n): ClientPageEntry {\n const entry = pages.find((candidate) => candidate.name === name);\n\n if (entry === undefined) {\n throw new UnknownHydrationPageNameError(\n name,\n pages.map((candidate) => candidate.name),\n );\n }\n\n return entry;\n}\n\n/**\n * A level's component, or undefined when the module exports no default.\n *\n * Undefined is NOT an error: `render-page.ts:258` and `:279` treat a missing\n * default as a passthrough server-side, and the client tree has to match the\n * markup React is hydrating against — introducing a level here that the server\n * did not render is a hydration mismatch, not a repair.\n */\nfunction componentOf<Props extends object>(\n module: ClientProjectedModule,\n): ComponentType<Props> | undefined {\n const component = module.default;\n\n return typeof component === \"function\"\n ? (component as ComponentType<Props>)\n : undefined;\n}\n\nfunction wrap(\n module: ClientProjectedModule,\n data: unknown,\n shared: unknown,\n children: ReactNode,\n): ReactNode {\n const Component = componentOf<HydratedLevelProps>(module);\n\n if (Component === undefined) return children;\n\n return createElement(Component, { data, shared, children });\n}\n\n/**\n * Compose the tree the server rendered inside `#root`: ordered layouts wrapping\n * the selected Page or ErrorPage leaf, layouts OUTERMOST FIRST as\n * `ClientRouteComposition` declares them. Ordinary levels receive\n * `{ data, shared }`; the error leaf receives the serialized `{ error, status\n * }` payload shape.\n *\n * ── THE APP LEVEL IS DELIBERATELY ABSENT, AND MUST STAY ABSENT ──────────────\n * `ClientRouteComposition.App` and `payload.appData` still exist and are still\n * carried; they are contracts owned elsewhere. They are simply not part of THIS\n * tree, because App is not part of the markup this tree hydrates against:\n *\n * - Server-side, `render-page.ts`'s `wrapRootward` wraps the page leaf in\n * `[\"layout\", \"app\"]` (`render-page.ts:274`), so the document React renders\n * is `App( Layout( Page ) )`.\n * - The app root is the level that owns `<html>`/`<body>` and renders\n * `<div id=\"root\">{children}</div>` inside the body. So App CONTAINS the\n * mount point — the markup actually inside `#root` is `Layout( Page )`.\n * - `hydrate-page.tsx` mounts at `#root` and nowhere else.\n *\n * Composing App here would therefore hydrate a whole `<html>` document inside a\n * `<div>` the server filled with a layout: a guaranteed hydration mismatch. If\n * you arrived here from the optional `App?` on the composition type and are\n * about to \"complete\" the tree with it — that would be the defect, not the\n * omission.\n *\n * `load()` is awaited exactly ONCE per hydration and its result reused for all\n * levels — the composition arrives whole, so calling it per layout would be\n * one network waterfall per level for no new information.\n */\nexport async function buildHydratedTree(\n pages: readonly ClientPageEntry[],\n payload: HydrationDocumentPayloadSource,\n): Promise<ReactNode> {\n const entry = findEntryByName(pages, payload.name);\n const composition = await loadClientRouteComposition(entry);\n const errorPageProps = payload.errorPage;\n const selectedPageModule =\n errorPageProps === undefined ? composition.Page : composition.ErrorPage;\n\n if (selectedPageModule === undefined) {\n throw new MissingHydrationErrorPageError(payload.name);\n }\n\n // Registration is the first lifecycle action after the real namespaces have\n // loaded. Keep server order: root/App, layouts outermost-to-innermost, page.\n // On the error path the selected error module replaces the ordinary Page in\n // that order; registering Page as well would run code the server did not run.\n // Component extraction and React element creation intentionally happen only\n // after every registration hook has completed synchronously.\n registerModules([\n ...(composition.App === undefined ? [] : [composition.App]),\n ...composition.layouts,\n selectedPageModule,\n ]);\n\n const { shared } = payload;\n let element: ReactNode;\n\n if (errorPageProps === undefined) {\n const Page = componentOf<HydratedPageProps>(selectedPageModule);\n element =\n Page === undefined\n ? null\n : createElement(Page, {\n data: payload.pageData,\n shared,\n params: payload.params ?? {},\n });\n } else {\n const ErrorPage = componentOf<SerializedErrorPageProps>(selectedPageModule);\n element =\n ErrorPage === undefined ? null : createElement(ErrorPage, errorPageProps);\n }\n\n // Innermost layout wraps the page, so walk the outermost-first list backwards.\n for (let index = composition.layouts.length - 1; index >= 0; index -= 1) {\n element = wrap(\n composition.layouts[index]!,\n payload.layoutData,\n shared,\n element,\n );\n }\n\n return element;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAsCA,SAAS,mBAAmB,gBAA2C;CACrE,IAAI,eAAe,WAAW,GAAG,OAAO;CAExC,OAAO,uBAAuB,eAAe,KAAK,SAAS,KAAK,UAAU,IAAI,CAAC,EAAE,KAAK,IAAI,EAAE;AAC9F;;;;;;;;;;;AAYA,IAAa,gCAAb,cAAmD,MAAM;CAErC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,gBAChB;EACA,MACE,qDAAqD,KAAK,UAAU,QAAQ,EAAE,8CACxC,mBAAmB,cAAc,EAAE,8OAI3E;EATgB;EACA;EAShB,KAAK,OAAO;CACd;AACF;;;;;;;AAQA,IAAa,iCAAb,cAAoD,MAAM;CACrB;CAAnC,AAAO,YAAY,AAAgB,UAAkB;EACnD,MACE,2EACK,KAAK,UAAU,QAAQ,EAAE,0IAEhC;EALiC;EAMjC,KAAK,OAAO;CACd;AACF;AAEA,SAAS,gBACP,OACA,MACiB;CACjB,MAAM,QAAQ,MAAM,MAAM,cAAc,UAAU,SAAS,IAAI;CAE/D,IAAI,UAAU,QACZ,MAAM,IAAI,8BACR,MACA,MAAM,KAAK,cAAc,UAAU,IAAI,CACzC;CAGF,OAAO;AACT;;;;;;;;;AAUA,SAAS,YACP,QACkC;CAClC,MAAM,YAAY,OAAO;CAEzB,OAAO,OAAO,cAAc,aACvB,YACD;AACN;AAEA,SAAS,KACP,QACA,MACA,QACA,UACW;CACX,MAAM,YAAY,YAAgC,MAAM;CAExD,IAAI,cAAc,QAAW,OAAO;CAEpC,OAAO,cAAc,WAAW;EAAE;EAAM;EAAQ;CAAS,CAAC;AAC5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,eAAsB,kBACpB,OACA,SACoB;CAEpB,MAAM,cAAc,MAAM,2BADZ,gBAAgB,OAAO,QAAQ,IACY,CAAC;CAC1D,MAAM,iBAAiB,QAAQ;CAC/B,MAAM,qBACJ,mBAAmB,SAAY,YAAY,OAAO,YAAY;CAEhE,IAAI,uBAAuB,QACzB,MAAM,IAAI,+BAA+B,QAAQ,IAAI;CASvD,gBAAgB;EACd,GAAI,YAAY,QAAQ,SAAY,CAAC,IAAI,CAAC,YAAY,GAAG;EACzD,GAAG,YAAY;EACf;CACF,CAAC;CAED,MAAM,EAAE,WAAW;CACnB,IAAI;CAEJ,IAAI,mBAAmB,QAAW;EAChC,MAAM,OAAO,YAA+B,kBAAkB;EAC9D,UACE,SAAS,SACL,OACA,cAAc,MAAM;GAClB,MAAM,QAAQ;GACd;GACA,QAAQ,QAAQ,UAAU,CAAC;EAC7B,CAAC;CACT,OAAO;EACL,MAAM,YAAY,YAAsC,kBAAkB;EAC1E,UACE,cAAc,SAAY,OAAO,cAAc,WAAW,cAAc;CAC5E;CAGA,KAAK,IAAI,QAAQ,YAAY,QAAQ,SAAS,GAAG,SAAS,GAAG,SAAS,GACpE,UAAU,KACR,YAAY,QAAQ,QACpB,QAAQ,YACR,QACA,OACF;CAGF,OAAO;AACT"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetch-page-data.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/fetch-page-data.ts"],"sourcesContent":["/**\n * Ask the server for a URL's page data instead of its document.\n *\n * This is the browser half of the representation split: the same route the user\n * would have loaded, asked for as JSON via the `x-warlock-data` marker. What\n * comes back is exactly the payload a full page load embeds, so the caller can\n * rebuild the tree from it with no second code path.\n *\n * ## Every failure degrades to a REAL navigation, never to an error screen\n *\n * A client navigation is an OPTIMISATION over what the browser already does\n * perfectly well. So nothing here reports a failure to the user — it reports\n * `hard-navigate`, and the caller hands the URL back to the browser. The user\n * gets the page; they just get it the slow way.\n *\n * That is what makes the whole feature safe to add: the worst case of a bug in\n * this file is the behaviour we had before the file existed. Rendering our own\n * \"navigation failed\" state would be strictly worse than the fallback we\n * already have, and would turn every unhandled edge — an auth redirect to an\n * external IdP, a maintenance page, a proxy that strips the header, a deploy\n * that changed the payload shape mid-session — into a dead end.\n */\nimport {\n DATA_RESPONSE_CONTENT_TYPE,\n WARLOCK_DATA_REQUEST_HEADER,\n WARLOCK_DATA_REQUEST_VALUE,\n} from \"../../routing/data-request\";\nimport type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\n\nexport type PageDataResult =\n | {\n type: \"payload\";\n /**\n * The payload to rebuild the tree from.\n */\n payload: HydrationDocumentPayloadSource;\n /**\n * The URL the response actually came from — NOT the one requested. A\n * redirect is followed by `fetch` transparently, so a login-required page\n * answers from `/login`, and pushing the requested URL into history would\n * leave the address bar lying about what is on screen.\n */\n url: string;\n }\n | {\n type: \"hard-navigate\";\n url: string;\n /** Why, for a console warning — never shown to the user. */\n reason: string;\n };\n\n/**\n * Whether the body is the payload we asked for.\n *\n * Checked rather than assumed because a 200 does not mean \"this came from the\n * page pipeline\": a captive portal, an SSO interstitial or a proxy error page\n * all answer 200 with HTML. Parsing that as JSON would throw; treating a\n * successful parse of *something else* as a payload would render garbage.\n */\nfunction isPayloadResponse(response: Response): boolean {\n return (response.headers.get(\"content-type\") ?? \"\").includes(DATA_RESPONSE_CONTENT_TYPE);\n}\n\n/**\n * The shape check, kept deliberately narrow: `name` is the only field the tree\n * builder cannot proceed without — it selects the page. The data fields are\n * page-defined and may legitimately be anything, including `null`.\n */\nfunction isPayloadShape(value: unknown): value is HydrationDocumentPayloadSource {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as { name?: unknown }).name === \"string\"\n );\n}\n\nexport async function fetchPageData(url: string): Promise<PageDataResult> {\n let response: Response;\n\n try {\n response = await fetch(url, {\n headers: {\n [WARLOCK_DATA_REQUEST_HEADER]: WARLOCK_DATA_REQUEST_VALUE,\n accept: DATA_RESPONSE_CONTENT_TYPE,\n },\n // Same-origin credentials so a navigation carries the session exactly as\n // a document request would. Without this a client navigation could be\n // logged out while a full load of the same URL is not.\n credentials: \"same-origin\",\n // Redirects are FOLLOWED, not intercepted: the marker header is re-sent,\n // so the destination answers with a payload too, and `response.url` tells\n // us where we ended up. Handling redirects ourselves would mean\n // re-implementing the rules the browser already has.\n redirect: \"follow\",\n });\n } catch (error) {\n // Offline, DNS, CORS, an aborted connection. The browser can render its own\n // network error far better than we can fake one.\n return { type: \"hard-navigate\", url, reason: `request failed: ${String(error)}` };\n }\n\n if (!response.ok) {\n // 404, 500, 403 — all of these have a real page the server renders. Letting\n // the browser load it gets the correct status AND the correct document,\n // rather than us inventing a client-side error state that the server's own\n // error page already covers.\n return { type: \"hard-navigate\", url, reason: `status ${response.status}` };\n }\n\n if (!isPayloadResponse(response)) {\n return {\n type: \"hard-navigate\",\n url,\n reason: `unexpected content-type \"${response.headers.get(\"content-type\") ?? \"none\"}\"`,\n };\n }\n\n let parsed: unknown;\n\n try {\n parsed = await response.json();\n } catch (error) {\n return { type: \"hard-navigate\", url, reason: `malformed JSON: ${String(error)}` };\n }\n\n if (!isPayloadShape(parsed)) {\n return { type: \"hard-navigate\", url, reason: \"payload has no route name\" };\n }\n\n // `response.url` is absolute and reflects any redirect that was followed.\n // Falling back to the requested URL keeps this working under test doubles\n // that do not set it.\n return { type: \"payload\", payload: parsed, url: response.url || url };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2DA,SAAS,kBAAkB,UAA6B;CACtD,QAAQ,SAAS,QAAQ,IAAI,cAAc,KAAK,
|
|
1
|
+
{"version":3,"file":"fetch-page-data.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/fetch-page-data.ts"],"sourcesContent":["/**\n * Ask the server for a URL's page data instead of its document.\n *\n * This is the browser half of the representation split: the same route the user\n * would have loaded, asked for as JSON via the `x-warlock-data` marker. What\n * comes back is exactly the payload a full page load embeds, so the caller can\n * rebuild the tree from it with no second code path.\n *\n * ## Every failure degrades to a REAL navigation, never to an error screen\n *\n * A client navigation is an OPTIMISATION over what the browser already does\n * perfectly well. So nothing here reports a failure to the user — it reports\n * `hard-navigate`, and the caller hands the URL back to the browser. The user\n * gets the page; they just get it the slow way.\n *\n * That is what makes the whole feature safe to add: the worst case of a bug in\n * this file is the behaviour we had before the file existed. Rendering our own\n * \"navigation failed\" state would be strictly worse than the fallback we\n * already have, and would turn every unhandled edge — an auth redirect to an\n * external IdP, a maintenance page, a proxy that strips the header, a deploy\n * that changed the payload shape mid-session — into a dead end.\n */\nimport {\n DATA_RESPONSE_CONTENT_TYPE,\n WARLOCK_DATA_REQUEST_HEADER,\n WARLOCK_DATA_REQUEST_VALUE,\n} from \"../../routing/data-request\";\nimport type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\n\nexport type PageDataResult =\n | {\n type: \"payload\";\n /**\n * The payload to rebuild the tree from.\n */\n payload: HydrationDocumentPayloadSource;\n /**\n * The URL the response actually came from — NOT the one requested. A\n * redirect is followed by `fetch` transparently, so a login-required page\n * answers from `/login`, and pushing the requested URL into history would\n * leave the address bar lying about what is on screen.\n */\n url: string;\n }\n | {\n type: \"hard-navigate\";\n url: string;\n /** Why, for a console warning — never shown to the user. */\n reason: string;\n };\n\n/**\n * Whether the body is the payload we asked for.\n *\n * Checked rather than assumed because a 200 does not mean \"this came from the\n * page pipeline\": a captive portal, an SSO interstitial or a proxy error page\n * all answer 200 with HTML. Parsing that as JSON would throw; treating a\n * successful parse of *something else* as a payload would render garbage.\n */\nfunction isPayloadResponse(response: Response): boolean {\n return (response.headers.get(\"content-type\") ?? \"\").includes(DATA_RESPONSE_CONTENT_TYPE);\n}\n\n/**\n * The shape check, kept deliberately narrow: `name` is the only field the tree\n * builder cannot proceed without — it selects the page. The data fields are\n * page-defined and may legitimately be anything, including `null`.\n */\nfunction isPayloadShape(value: unknown): value is HydrationDocumentPayloadSource {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as { name?: unknown }).name === \"string\"\n );\n}\n\nexport async function fetchPageData(url: string): Promise<PageDataResult> {\n let response: Response;\n\n try {\n response = await fetch(url, {\n headers: {\n [WARLOCK_DATA_REQUEST_HEADER]: WARLOCK_DATA_REQUEST_VALUE,\n accept: DATA_RESPONSE_CONTENT_TYPE,\n },\n // Same-origin credentials so a navigation carries the session exactly as\n // a document request would. Without this a client navigation could be\n // logged out while a full load of the same URL is not.\n credentials: \"same-origin\",\n // Redirects are FOLLOWED, not intercepted: the marker header is re-sent,\n // so the destination answers with a payload too, and `response.url` tells\n // us where we ended up. Handling redirects ourselves would mean\n // re-implementing the rules the browser already has.\n redirect: \"follow\",\n });\n } catch (error) {\n // Offline, DNS, CORS, an aborted connection. The browser can render its own\n // network error far better than we can fake one.\n return { type: \"hard-navigate\", url, reason: `request failed: ${String(error)}` };\n }\n\n if (!response.ok) {\n // 404, 500, 403 — all of these have a real page the server renders. Letting\n // the browser load it gets the correct status AND the correct document,\n // rather than us inventing a client-side error state that the server's own\n // error page already covers.\n return { type: \"hard-navigate\", url, reason: `status ${response.status}` };\n }\n\n if (!isPayloadResponse(response)) {\n return {\n type: \"hard-navigate\",\n url,\n reason: `unexpected content-type \"${response.headers.get(\"content-type\") ?? \"none\"}\"`,\n };\n }\n\n let parsed: unknown;\n\n try {\n parsed = await response.json();\n } catch (error) {\n return { type: \"hard-navigate\", url, reason: `malformed JSON: ${String(error)}` };\n }\n\n if (!isPayloadShape(parsed)) {\n return { type: \"hard-navigate\", url, reason: \"payload has no route name\" };\n }\n\n // `response.url` is absolute and reflects any redirect that was followed.\n // Falling back to the requested URL keeps this working under test doubles\n // that do not set it.\n return { type: \"payload\", payload: parsed, url: response.url || url };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2DA,SAAS,kBAAkB,UAA6B;CACtD,QAAQ,SAAS,QAAQ,IAAI,cAAc,KAAK,IAAI,SAAS,0BAA0B;AACzF;;;;;;AAOA,SAAS,eAAe,OAAyD;CAC/E,OACE,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAA6B,SAAS;AAElD;AAEA,eAAsB,cAAc,KAAsC;CACxE,IAAI;CAEJ,IAAI;EACF,WAAW,MAAM,MAAM,KAAK;GAC1B,SAAS;KACN;IACD,QAAQ;GACV;GAIA,aAAa;GAKb,UAAU;EACZ,CAAC;CACH,SAAS,OAAO;EAGd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,SAAS,IAKZ,OAAO;EAAE,MAAM;EAAiB;EAAK,QAAQ,UAAU,SAAS;CAAS;CAG3E,IAAI,CAAC,kBAAkB,QAAQ,GAC7B,OAAO;EACL,MAAM;EACN;EACA,QAAQ,4BAA4B,SAAS,QAAQ,IAAI,cAAc,KAAK,OAAO;CACrF;CAGF,IAAI;CAEJ,IAAI;EACF,SAAS,MAAM,SAAS,KAAK;CAC/B,SAAS,OAAO;EACd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,eAAe,MAAM,GACxB,OAAO;EAAE,MAAM;EAAiB;EAAK,QAAQ;CAA4B;CAM3E,OAAO;EAAE,MAAM;EAAW,SAAS;EAAQ,KAAK,SAAS,OAAO;CAAI;AACtE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prefetch.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/prefetch.ts"],"sourcesContent":["/**\n * Speculative page data, fetched on hover and spent on the click that follows.\n *\n * A prefetch is a GUESS. The user pointed at a link; they may never click it.\n * Everything about this module follows from that one fact:\n *\n * - it never reports a failure — nobody asked for this request, so nobody may\n * be told it failed. A failed prefetch leaves the cache empty and the click\n * fetches for real, which is the behaviour we had before this file existed.\n * - it never delays a click. `prefetchPageData` is fire-and-forget; the\n * navigation path reads the cache synchronously and does not wait on it.\n * - it is BOUNDED and it EXPIRES. A cached payload is a copy of a page that\n * may already have changed, so it may only be served while it is very\n * probably still true.\n *\n * ## The read side is deliberately separate from the write side\n *\n * `<Link>` writes (on hover); the navigation runtime reads (on click). Neither\n * knows about the other — they share a URL, and this module is the only thing\n * between them. That is what lets prefetch be added without touching the\n * navigation state machine.\n */\nimport type { PageDataResult } from \"./fetch-page-data\";\nimport { fetchPageData } from \"./fetch-page-data\";\n\n/** A prefetch result worth keeping — only ever a successful payload. */\nexport type PrefetchedPageData = Extract<PageDataResult, { type: \"payload\" }>;\n\n/**\n * How many pages may be held at once.\n *\n * Ten, because the working set this serves is \"links the pointer has crossed in\n * the last few seconds\", which is small by construction — a nav bar, a card\n * grid the user is scanning. A page payload is loader data, not markup, but it\n * is still measured in tens of kilobytes, so an unbounded map on a long-lived\n * SPA session is a slow leak with no upper edge. Ten covers scanning a menu and\n * costs at most a few hundred kilobytes in the worst case.\n *\n * Eviction is by INSERTION ORDER, not by recency: an entry here is written once\n * and read at most once, so there is no recency to track — the oldest guess is\n * always the one least likely to be spent.\n */\nexport const PREFETCH_CACHE_LIMIT = 10;\n\n/**\n * How long a cached payload may be served, in milliseconds.\n *\n * Thirty seconds. This is a SAFETY bound and not a performance knob: the\n * interval this feature exists to cover is hover-to-click, which is well under\n * a second, and every millisecond beyond that is pure staleness risk. A payload\n * served after the user has changed the data behind it renders a wrong page —\n * silently, and with no way for them to tell. Thirty seconds is long enough\n * that a hesitant click still hits, and short enough that no realistic\n * \"navigate away, mutate something, come back\" flow can complete inside it.\n */\nexport const PREFETCH_TTL_MS = 30_000;\n\ntype CacheEntry = {\n result: PrefetchedPageData;\n /** `Date.now()` at which this entry stops being servable. */\n expiresAt: number;\n};\n\n/**\n * Insertion-ordered by `Map` contract, which is what makes eviction a `keys()\n * .next()` and not a bookkeeping structure.\n */\nconst cache = new Map<string, CacheEntry>();\n\n/**\n * URLs with a request already in the air. Repeated `mouseenter` events on the\n * same anchor are the norm, not the exception — a pointer crossing a link fires\n * as the user's hand settles — and without this each one would be its own\n * request for the same page.\n */\nconst inFlight = new Set<string>();\n\n/**\n * Whether there is a browser to prefetch from.\n *\n * Called rather than assumed because `<Link>` is universal: the same module\n * graph is evaluated during a server render, where a speculative request would\n * be a request the server makes to itself for a page nobody is looking at.\n */\nfunction isBrowser(): boolean {\n return typeof window !== \"undefined\";\n}\n\nfunction evictOldest(): void {\n const oldest = cache.keys().next();\n\n if (oldest.done !== true) cache.delete(oldest.value);\n}\n\n/**\n * Fetch a URL's page data ahead of the click, and cache it.\n *\n * NEVER REJECTS and never reports. The returned promise exists so a test can\n * await the speculative work; callers in the component tree discard it\n * (`void prefetchPageData(url)`) and must not await it — a click that waited on\n * a guess would be slower than one that never made it.\n *\n * The caller decides WHETHER a URL may be prefetched. This function does not\n * re-derive that: it has a URL and no way to tell an in-app path from a\n * cross-origin one it must never touch. `<Link>` gates on `isInApp`.\n */\nexport async function prefetchPageData(url: string): Promise<void> {\n if (!isBrowser()) return;\n\n if (inFlight.has(url)) return;\n\n // A fresh entry means the answer is already here; an expired one is dropped\n // now so the fetch below can replace it.\n const cached = cache.get(url);\n\n if (cached !== undefined) {\n if (cached.expiresAt > Date.now()) return;\n\n cache.delete(url);\n }\n\n inFlight.add(url);\n\n try {\n const result = await fetchPageData(url);\n\n /*\n `hard-navigate` is NOT cached. It is the signal that this URL needs a full\n page load — a 404, a redirect to an interstitial, a proxy that stripped\n the marker header. Caching it would mean the click either replays a\n failure or, worse, consults an entry that cannot be rendered. The click\n re-asks and gets the same answer, correctly, through the navigation path\n that already knows how to degrade.\n */\n if (result.type !== \"payload\") return;\n\n if (cache.size >= PREFETCH_CACHE_LIMIT) evictOldest();\n\n cache.set(url, { result, expiresAt: Date.now() + PREFETCH_TTL_MS });\n } catch {\n /*\n `fetchPageData` already converts every network failure into a\n `hard-navigate`, so reaching here means something unforeseen. It is\n swallowed anyway, deliberately and without a log: this request was\n speculative, the user never asked for it, and a console full of warnings\n about requests nobody made is how a helpful optimisation becomes noise\n that hides real errors.\n */\n } finally {\n // In `finally` so a failed attempt does not poison the URL for the rest of\n // the session — the next hover is allowed to try again.\n inFlight.delete(url);\n }\n}\n\n/**\n * Take the prefetched payload for a URL, if there is a live one.\n *\n * CONSUMING: the entry is removed whether or not the caller ends up using it.\n * One hover buys one saved round trip. Serving the same payload to a second\n * navigation would double the window in which it can be wrong, in exchange for\n * a saving the user did not notice the first time.\n *\n * @returns the cached result, or `undefined` — in which case the caller fetches\n * exactly as it did before this module existed.\n */\nexport function takePrefetchedPageData(url: string): PrefetchedPageData | undefined {\n const entry = cache.get(url);\n\n if (entry === undefined) return undefined;\n\n cache.delete(url);\n\n // Expiry is checked on READ, not on a timer: a timer would keep a\n // long-lived page waking up to clean a cache that is already bounded, and an\n // entry nobody reads costs nothing but the slot it is evicted from anyway.\n return entry.expiresAt > Date.now() ? entry.result : undefined;\n}\n\n/**\n * Drop everything. For tests, and for any caller that knows the cached pages\n * are now wrong — the module-level cache would otherwise outlive a suite and\n * leak into the next one.\n */\nexport function resetPrefetchCache(): void {\n cache.clear();\n inFlight.clear();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AA0CA,MAAa,uBAAuB;;;;;;;;;;;;AAapC,MAAa,kBAAkB;;;;;AAY/B,MAAM,wBAAQ,IAAI,IAAwB;;;;;;;AAQ1C,MAAM,2BAAW,IAAI,IAAY;;;;;;;;AASjC,SAAS,YAAqB;CAC5B,OAAO,OAAO,WAAW;AAC3B;AAEA,SAAS,cAAoB;CAC3B,MAAM,SAAS,MAAM,KAAK,
|
|
1
|
+
{"version":3,"file":"prefetch.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/prefetch.ts"],"sourcesContent":["/**\n * Speculative page data, fetched on hover and spent on the click that follows.\n *\n * A prefetch is a GUESS. The user pointed at a link; they may never click it.\n * Everything about this module follows from that one fact:\n *\n * - it never reports a failure — nobody asked for this request, so nobody may\n * be told it failed. A failed prefetch leaves the cache empty and the click\n * fetches for real, which is the behaviour we had before this file existed.\n * - it never delays a click. `prefetchPageData` is fire-and-forget; the\n * navigation path reads the cache synchronously and does not wait on it.\n * - it is BOUNDED and it EXPIRES. A cached payload is a copy of a page that\n * may already have changed, so it may only be served while it is very\n * probably still true.\n *\n * ## The read side is deliberately separate from the write side\n *\n * `<Link>` writes (on hover); the navigation runtime reads (on click). Neither\n * knows about the other — they share a URL, and this module is the only thing\n * between them. That is what lets prefetch be added without touching the\n * navigation state machine.\n */\nimport type { PageDataResult } from \"./fetch-page-data\";\nimport { fetchPageData } from \"./fetch-page-data\";\n\n/** A prefetch result worth keeping — only ever a successful payload. */\nexport type PrefetchedPageData = Extract<PageDataResult, { type: \"payload\" }>;\n\n/**\n * How many pages may be held at once.\n *\n * Ten, because the working set this serves is \"links the pointer has crossed in\n * the last few seconds\", which is small by construction — a nav bar, a card\n * grid the user is scanning. A page payload is loader data, not markup, but it\n * is still measured in tens of kilobytes, so an unbounded map on a long-lived\n * SPA session is a slow leak with no upper edge. Ten covers scanning a menu and\n * costs at most a few hundred kilobytes in the worst case.\n *\n * Eviction is by INSERTION ORDER, not by recency: an entry here is written once\n * and read at most once, so there is no recency to track — the oldest guess is\n * always the one least likely to be spent.\n */\nexport const PREFETCH_CACHE_LIMIT = 10;\n\n/**\n * How long a cached payload may be served, in milliseconds.\n *\n * Thirty seconds. This is a SAFETY bound and not a performance knob: the\n * interval this feature exists to cover is hover-to-click, which is well under\n * a second, and every millisecond beyond that is pure staleness risk. A payload\n * served after the user has changed the data behind it renders a wrong page —\n * silently, and with no way for them to tell. Thirty seconds is long enough\n * that a hesitant click still hits, and short enough that no realistic\n * \"navigate away, mutate something, come back\" flow can complete inside it.\n */\nexport const PREFETCH_TTL_MS = 30_000;\n\ntype CacheEntry = {\n result: PrefetchedPageData;\n /** `Date.now()` at which this entry stops being servable. */\n expiresAt: number;\n};\n\n/**\n * Insertion-ordered by `Map` contract, which is what makes eviction a `keys()\n * .next()` and not a bookkeeping structure.\n */\nconst cache = new Map<string, CacheEntry>();\n\n/**\n * URLs with a request already in the air. Repeated `mouseenter` events on the\n * same anchor are the norm, not the exception — a pointer crossing a link fires\n * as the user's hand settles — and without this each one would be its own\n * request for the same page.\n */\nconst inFlight = new Set<string>();\n\n/**\n * Whether there is a browser to prefetch from.\n *\n * Called rather than assumed because `<Link>` is universal: the same module\n * graph is evaluated during a server render, where a speculative request would\n * be a request the server makes to itself for a page nobody is looking at.\n */\nfunction isBrowser(): boolean {\n return typeof window !== \"undefined\";\n}\n\nfunction evictOldest(): void {\n const oldest = cache.keys().next();\n\n if (oldest.done !== true) cache.delete(oldest.value);\n}\n\n/**\n * Fetch a URL's page data ahead of the click, and cache it.\n *\n * NEVER REJECTS and never reports. The returned promise exists so a test can\n * await the speculative work; callers in the component tree discard it\n * (`void prefetchPageData(url)`) and must not await it — a click that waited on\n * a guess would be slower than one that never made it.\n *\n * The caller decides WHETHER a URL may be prefetched. This function does not\n * re-derive that: it has a URL and no way to tell an in-app path from a\n * cross-origin one it must never touch. `<Link>` gates on `isInApp`.\n */\nexport async function prefetchPageData(url: string): Promise<void> {\n if (!isBrowser()) return;\n\n if (inFlight.has(url)) return;\n\n // A fresh entry means the answer is already here; an expired one is dropped\n // now so the fetch below can replace it.\n const cached = cache.get(url);\n\n if (cached !== undefined) {\n if (cached.expiresAt > Date.now()) return;\n\n cache.delete(url);\n }\n\n inFlight.add(url);\n\n try {\n const result = await fetchPageData(url);\n\n /*\n `hard-navigate` is NOT cached. It is the signal that this URL needs a full\n page load — a 404, a redirect to an interstitial, a proxy that stripped\n the marker header. Caching it would mean the click either replays a\n failure or, worse, consults an entry that cannot be rendered. The click\n re-asks and gets the same answer, correctly, through the navigation path\n that already knows how to degrade.\n */\n if (result.type !== \"payload\") return;\n\n if (cache.size >= PREFETCH_CACHE_LIMIT) evictOldest();\n\n cache.set(url, { result, expiresAt: Date.now() + PREFETCH_TTL_MS });\n } catch {\n /*\n `fetchPageData` already converts every network failure into a\n `hard-navigate`, so reaching here means something unforeseen. It is\n swallowed anyway, deliberately and without a log: this request was\n speculative, the user never asked for it, and a console full of warnings\n about requests nobody made is how a helpful optimisation becomes noise\n that hides real errors.\n */\n } finally {\n // In `finally` so a failed attempt does not poison the URL for the rest of\n // the session — the next hover is allowed to try again.\n inFlight.delete(url);\n }\n}\n\n/**\n * Take the prefetched payload for a URL, if there is a live one.\n *\n * CONSUMING: the entry is removed whether or not the caller ends up using it.\n * One hover buys one saved round trip. Serving the same payload to a second\n * navigation would double the window in which it can be wrong, in exchange for\n * a saving the user did not notice the first time.\n *\n * @returns the cached result, or `undefined` — in which case the caller fetches\n * exactly as it did before this module existed.\n */\nexport function takePrefetchedPageData(url: string): PrefetchedPageData | undefined {\n const entry = cache.get(url);\n\n if (entry === undefined) return undefined;\n\n cache.delete(url);\n\n // Expiry is checked on READ, not on a timer: a timer would keep a\n // long-lived page waking up to clean a cache that is already bounded, and an\n // entry nobody reads costs nothing but the slot it is evicted from anyway.\n return entry.expiresAt > Date.now() ? entry.result : undefined;\n}\n\n/**\n * Drop everything. For tests, and for any caller that knows the cached pages\n * are now wrong — the module-level cache would otherwise outlive a suite and\n * leak into the next one.\n */\nexport function resetPrefetchCache(): void {\n cache.clear();\n inFlight.clear();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AA0CA,MAAa,uBAAuB;;;;;;;;;;;;AAapC,MAAa,kBAAkB;;;;;AAY/B,MAAM,wBAAQ,IAAI,IAAwB;;;;;;;AAQ1C,MAAM,2BAAW,IAAI,IAAY;;;;;;;;AASjC,SAAS,YAAqB;CAC5B,OAAO,OAAO,WAAW;AAC3B;AAEA,SAAS,cAAoB;CAC3B,MAAM,SAAS,MAAM,KAAK,EAAE,KAAK;CAEjC,IAAI,OAAO,SAAS,MAAM,MAAM,OAAO,OAAO,KAAK;AACrD;;;;;;;;;;;;;AAcA,eAAsB,iBAAiB,KAA4B;CACjE,IAAI,CAAC,UAAU,GAAG;CAElB,IAAI,SAAS,IAAI,GAAG,GAAG;CAIvB,MAAM,SAAS,MAAM,IAAI,GAAG;CAE5B,IAAI,WAAW,QAAW;EACxB,IAAI,OAAO,YAAY,KAAK,IAAI,GAAG;EAEnC,MAAM,OAAO,GAAG;CAClB;CAEA,SAAS,IAAI,GAAG;CAEhB,IAAI;EACF,MAAM,SAAS,MAAM,cAAc,GAAG;EAUtC,IAAI,OAAO,SAAS,WAAW;EAE/B,IAAI,MAAM,YAA8B,YAAY;EAEpD,MAAM,IAAI,KAAK;GAAE;GAAQ,WAAW,KAAK,IAAI,IAAI;EAAgB,CAAC;CACpE,QAAQ,CASR,UAAU;EAGR,SAAS,OAAO,GAAG;CACrB;AACF;;;;;;;;;;;;AAaA,SAAgB,uBAAuB,KAA6C;CAClF,MAAM,QAAQ,MAAM,IAAI,GAAG;CAE3B,IAAI,UAAU,QAAW,OAAO;CAEhC,MAAM,OAAO,GAAG;CAKhB,OAAO,MAAM,YAAY,KAAK,IAAI,IAAI,MAAM,SAAS;AACvD"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
+
import { registerModules } from "../../runtime/register-modules.mjs";
|
|
1
2
|
import { ClientPageEntry, ClientProjectedModule, ClientRouteComposition, ClientRouteLoad, ClientRouteMatch, ClientRouteParams } from "./types.mjs";
|
|
2
3
|
import { loadClientRouteComposition, validateClientRouteManifest } from "./manifest.mjs";
|
|
3
4
|
import { matchClientRoute } from "./matcher.mjs";
|
|
4
|
-
export { type ClientPageEntry, type ClientProjectedModule, type ClientRouteComposition, type ClientRouteLoad, type ClientRouteMatch, type ClientRouteParams, loadClientRouteComposition, matchClientRoute, validateClientRouteManifest };
|
|
5
|
+
export { type ClientPageEntry, type ClientProjectedModule, type ClientRouteComposition, type ClientRouteLoad, type ClientRouteMatch, type ClientRouteParams, loadClientRouteComposition, matchClientRoute, registerModules, validateClientRouteManifest };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
+
import { registerModules } from "../../runtime/register-modules.mjs";
|
|
1
2
|
import { loadClientRouteComposition, validateClientRouteManifest } from "./manifest.mjs";
|
|
2
3
|
import { matchClientRoute } from "./matcher.mjs";
|
|
3
4
|
|
|
4
|
-
export { loadClientRouteComposition, matchClientRoute, validateClientRouteManifest };
|
|
5
|
+
export { loadClientRouteComposition, matchClientRoute, registerModules, validateClientRouteManifest };
|
|
@@ -6,7 +6,7 @@ const ENTRY_KEYS = [
|
|
|
6
6
|
"load"
|
|
7
7
|
];
|
|
8
8
|
const COMPOSITION_REQUIRED_KEYS = ["Page", "layouts"];
|
|
9
|
-
const COMPOSITION_OPTIONAL_KEYS = ["App"];
|
|
9
|
+
const COMPOSITION_OPTIONAL_KEYS = ["App", "ErrorPage"];
|
|
10
10
|
function isNonArrayObject(value) {
|
|
11
11
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
12
|
}
|
|
@@ -53,6 +53,7 @@ function validateComposition(input) {
|
|
|
53
53
|
validateProjectedModule(layout, `${label} layout at index ${index}`);
|
|
54
54
|
});
|
|
55
55
|
if (Object.prototype.hasOwnProperty.call(input, "App")) validateProjectedModule(input.App, `${label} App`);
|
|
56
|
+
if (Object.prototype.hasOwnProperty.call(input, "ErrorPage")) validateProjectedModule(input.ErrorPage, `${label} ErrorPage`);
|
|
56
57
|
return input;
|
|
57
58
|
}
|
|
58
59
|
function validateClientRouteManifest(input) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest.mjs","names":[],"sources":["../../../../../../../../web/src/client/runtime/manifest.ts"],"sourcesContent":["import type {\n ClientPageEntry,\n ClientProjectedModule,\n ClientRouteComposition,\n} from \"./types\";\n\nconst ENTRY_KEYS = [\"type\", \"name\", \"path\", \"load\"] as const;\nconst COMPOSITION_REQUIRED_KEYS = [\"Page\", \"layouts\"] as const;\nconst COMPOSITION_OPTIONAL_KEYS = [\"App\"] as const;\n\ntype DataRecord = Record<PropertyKey, unknown>;\n\nfunction isNonArrayObject(value: unknown): value is DataRecord {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction printableValue(value: unknown): string {\n if (typeof value === \"string\") return JSON.stringify(value);\n\n try {\n return String(value);\n } catch {\n return \"<unprintable>\";\n }\n}\n\nfunction assertExactDataKeys(\n value: DataRecord,\n requiredKeys: readonly string[],\n optionalKeys: readonly string[],\n label: string,\n): void {\n const allowedKeys = new Set([...requiredKeys, ...optionalKeys]);\n const descriptors = Object.getOwnPropertyDescriptors(value);\n const ownKeys = Reflect.ownKeys(value);\n\n for (const key of ownKeys) {\n if (typeof key !== \"string\" || !allowedKeys.has(key)) {\n throw new TypeError(`${label} has unexpected own key ${JSON.stringify(String(key))}.`);\n }\n }\n\n for (const key of requiredKeys) {\n if (!Object.prototype.hasOwnProperty.call(descriptors, key)) {\n throw new TypeError(`${label} is missing own key ${JSON.stringify(key)}.`);\n }\n }\n\n for (const key of ownKeys) {\n const descriptor = Object.getOwnPropertyDescriptor(value, key);\n if (descriptor && !(\"value\" in descriptor)) {\n throw new TypeError(\n `${label} key ${JSON.stringify(String(key))} must be an own data property.`,\n );\n }\n }\n}\n\nfunction validateEntry(input: unknown, index: number): ClientPageEntry {\n const label = `Client route manifest entry at index ${index}`;\n\n if (!isNonArrayObject(input)) {\n throw new TypeError(`${label} must be a non-array object.`);\n }\n\n assertExactDataKeys(input, ENTRY_KEYS, [], label);\n\n if (input.type !== \"page\") {\n throw new TypeError(\n `${label} has unknown type ${printableValue(input.type)}; expected \"page\".`,\n );\n }\n\n if (typeof input.name !== \"string\" || input.name.trim().length === 0) {\n throw new TypeError(`${label} name must be a non-empty string.`);\n }\n\n if (typeof input.path !== \"string\" || input.path.trim().length === 0) {\n throw new TypeError(`${label} path must be a non-empty string.`);\n }\n\n if (typeof input.load !== \"function\") {\n throw new TypeError(`${label} load must be callable.`);\n }\n\n return input as ClientPageEntry;\n}\n\nfunction validateProjectedModule(input: unknown, label: string): ClientProjectedModule {\n if (!isNonArrayObject(input)) {\n throw new TypeError(`${label} must be a non-array module object.`);\n }\n\n return input;\n}\n\nfunction validateComposition(input: unknown): ClientRouteComposition {\n const label = \"Loaded client route composition\";\n\n if (!isNonArrayObject(input)) {\n throw new TypeError(`${label} must be a non-array object.`);\n }\n\n assertExactDataKeys(\n input,\n COMPOSITION_REQUIRED_KEYS,\n COMPOSITION_OPTIONAL_KEYS,\n label,\n );\n validateProjectedModule(input.Page, `${label} Page`);\n\n if (!Array.isArray(input.layouts)) {\n throw new TypeError(`${label} layouts must be an array.`);\n }\n\n input.layouts.forEach((layout, index) => {\n validateProjectedModule(layout, `${label} layout at index ${index}`);\n });\n\n if (Object.prototype.hasOwnProperty.call(input, \"App\")) {\n validateProjectedModule(input.App, `${label} App`);\n }\n\n return input as ClientRouteComposition;\n}\n\nexport function validateClientRouteManifest(input: unknown): readonly ClientPageEntry[] {\n if (!Array.isArray(input)) {\n throw new TypeError(\"Client route manifest must be an array.\");\n }\n\n const names = new Set<string>();\n const paths = new Set<string>();\n\n return input.map((candidate, index) => {\n const entry = validateEntry(candidate, index);\n\n if (names.has(entry.name)) {\n throw new TypeError(\n `Client route manifest has duplicate name ${JSON.stringify(entry.name)}.`,\n );\n }\n\n if (paths.has(entry.path)) {\n throw new TypeError(\n `Client route manifest has duplicate path ${JSON.stringify(entry.path)}.`,\n );\n }\n\n names.add(entry.name);\n paths.add(entry.path);\n return entry;\n });\n}\n\nexport async function loadClientRouteComposition(\n entry: ClientPageEntry,\n): Promise<ClientRouteComposition> {\n const loaded = await entry.load();\n return validateComposition(loaded);\n}\n"],"mappings":";AAMA,MAAM,aAAa;CAAC;CAAQ;CAAQ;CAAQ;AAAM;AAClD,MAAM,4BAA4B,CAAC,QAAQ,SAAS;AACpD,MAAM,4BAA4B,CAAC,
|
|
1
|
+
{"version":3,"file":"manifest.mjs","names":[],"sources":["../../../../../../../../web/src/client/runtime/manifest.ts"],"sourcesContent":["import type {\n ClientPageEntry,\n ClientProjectedModule,\n ClientRouteComposition,\n} from \"./types\";\n\nconst ENTRY_KEYS = [\"type\", \"name\", \"path\", \"load\"] as const;\nconst COMPOSITION_REQUIRED_KEYS = [\"Page\", \"layouts\"] as const;\nconst COMPOSITION_OPTIONAL_KEYS = [\"App\", \"ErrorPage\"] as const;\n\ntype DataRecord = Record<PropertyKey, unknown>;\n\nfunction isNonArrayObject(value: unknown): value is DataRecord {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction printableValue(value: unknown): string {\n if (typeof value === \"string\") return JSON.stringify(value);\n\n try {\n return String(value);\n } catch {\n return \"<unprintable>\";\n }\n}\n\nfunction assertExactDataKeys(\n value: DataRecord,\n requiredKeys: readonly string[],\n optionalKeys: readonly string[],\n label: string,\n): void {\n const allowedKeys = new Set([...requiredKeys, ...optionalKeys]);\n const descriptors = Object.getOwnPropertyDescriptors(value);\n const ownKeys = Reflect.ownKeys(value);\n\n for (const key of ownKeys) {\n if (typeof key !== \"string\" || !allowedKeys.has(key)) {\n throw new TypeError(`${label} has unexpected own key ${JSON.stringify(String(key))}.`);\n }\n }\n\n for (const key of requiredKeys) {\n if (!Object.prototype.hasOwnProperty.call(descriptors, key)) {\n throw new TypeError(`${label} is missing own key ${JSON.stringify(key)}.`);\n }\n }\n\n for (const key of ownKeys) {\n const descriptor = Object.getOwnPropertyDescriptor(value, key);\n if (descriptor && !(\"value\" in descriptor)) {\n throw new TypeError(\n `${label} key ${JSON.stringify(String(key))} must be an own data property.`,\n );\n }\n }\n}\n\nfunction validateEntry(input: unknown, index: number): ClientPageEntry {\n const label = `Client route manifest entry at index ${index}`;\n\n if (!isNonArrayObject(input)) {\n throw new TypeError(`${label} must be a non-array object.`);\n }\n\n assertExactDataKeys(input, ENTRY_KEYS, [], label);\n\n if (input.type !== \"page\") {\n throw new TypeError(\n `${label} has unknown type ${printableValue(input.type)}; expected \"page\".`,\n );\n }\n\n if (typeof input.name !== \"string\" || input.name.trim().length === 0) {\n throw new TypeError(`${label} name must be a non-empty string.`);\n }\n\n if (typeof input.path !== \"string\" || input.path.trim().length === 0) {\n throw new TypeError(`${label} path must be a non-empty string.`);\n }\n\n if (typeof input.load !== \"function\") {\n throw new TypeError(`${label} load must be callable.`);\n }\n\n return input as ClientPageEntry;\n}\n\nfunction validateProjectedModule(input: unknown, label: string): ClientProjectedModule {\n if (!isNonArrayObject(input)) {\n throw new TypeError(`${label} must be a non-array module object.`);\n }\n\n return input;\n}\n\nfunction validateComposition(input: unknown): ClientRouteComposition {\n const label = \"Loaded client route composition\";\n\n if (!isNonArrayObject(input)) {\n throw new TypeError(`${label} must be a non-array object.`);\n }\n\n assertExactDataKeys(\n input,\n COMPOSITION_REQUIRED_KEYS,\n COMPOSITION_OPTIONAL_KEYS,\n label,\n );\n validateProjectedModule(input.Page, `${label} Page`);\n\n if (!Array.isArray(input.layouts)) {\n throw new TypeError(`${label} layouts must be an array.`);\n }\n\n input.layouts.forEach((layout, index) => {\n validateProjectedModule(layout, `${label} layout at index ${index}`);\n });\n\n if (Object.prototype.hasOwnProperty.call(input, \"App\")) {\n validateProjectedModule(input.App, `${label} App`);\n }\n\n if (Object.prototype.hasOwnProperty.call(input, \"ErrorPage\")) {\n validateProjectedModule(input.ErrorPage, `${label} ErrorPage`);\n }\n\n return input as ClientRouteComposition;\n}\n\nexport function validateClientRouteManifest(input: unknown): readonly ClientPageEntry[] {\n if (!Array.isArray(input)) {\n throw new TypeError(\"Client route manifest must be an array.\");\n }\n\n const names = new Set<string>();\n const paths = new Set<string>();\n\n return input.map((candidate, index) => {\n const entry = validateEntry(candidate, index);\n\n if (names.has(entry.name)) {\n throw new TypeError(\n `Client route manifest has duplicate name ${JSON.stringify(entry.name)}.`,\n );\n }\n\n if (paths.has(entry.path)) {\n throw new TypeError(\n `Client route manifest has duplicate path ${JSON.stringify(entry.path)}.`,\n );\n }\n\n names.add(entry.name);\n paths.add(entry.path);\n return entry;\n });\n}\n\nexport async function loadClientRouteComposition(\n entry: ClientPageEntry,\n): Promise<ClientRouteComposition> {\n const loaded = await entry.load();\n return validateComposition(loaded);\n}\n"],"mappings":"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"}
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { ClientPageEntry, ClientRouteMatch } from "./types.mjs";
|
|
2
2
|
|
|
3
3
|
//#region ../web/src/client/runtime/matcher.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* @deprecated Do not adopt for new code. This client-side matcher duplicates the
|
|
6
|
+
* route grammar the server already evaluates, and divergence between the two is
|
|
7
|
+
* silent (wrong page, not an error). It is superseded by navigation consuming the
|
|
8
|
+
* server-returned page composition/page swap: the client requests loader data and
|
|
9
|
+
* the matched page's identity rides back on that same response.
|
|
10
|
+
*
|
|
11
|
+
* Delete only after the server-answered page swap is proven working in production
|
|
12
|
+
* use — not before. Deleting earlier leaves neither implementation in place.
|
|
13
|
+
* Removing this export (and the `@warlock.js/web/client/runtime` re-export) is a
|
|
14
|
+
* breaking change to a published subpath and must be announced as one.
|
|
15
|
+
*/
|
|
4
16
|
declare function matchClientRoute(entries: readonly ClientPageEntry[], pathname: string): ClientRouteMatch | null;
|
|
5
17
|
//#endregion
|
|
6
18
|
export { matchClientRoute };
|
|
@@ -144,6 +144,18 @@ function compileRoutes(entries) {
|
|
|
144
144
|
return route;
|
|
145
145
|
}).sort(compareSpecificity);
|
|
146
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* @deprecated Do not adopt for new code. This client-side matcher duplicates the
|
|
149
|
+
* route grammar the server already evaluates, and divergence between the two is
|
|
150
|
+
* silent (wrong page, not an error). It is superseded by navigation consuming the
|
|
151
|
+
* server-returned page composition/page swap: the client requests loader data and
|
|
152
|
+
* the matched page's identity rides back on that same response.
|
|
153
|
+
*
|
|
154
|
+
* Delete only after the server-answered page swap is proven working in production
|
|
155
|
+
* use — not before. Deleting earlier leaves neither implementation in place.
|
|
156
|
+
* Removing this export (and the `@warlock.js/web/client/runtime` re-export) is a
|
|
157
|
+
* breaking change to a published subpath and must be announced as one.
|
|
158
|
+
*/
|
|
147
159
|
function matchClientRoute(entries, pathname) {
|
|
148
160
|
const routes = compileRoutes(entries);
|
|
149
161
|
const sanitized = sanitizePathname(pathname);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"matcher.mjs","names":[],"sources":["../../../../../../../../web/src/client/runtime/matcher.ts"],"sourcesContent":["import type { ClientPageEntry, ClientRouteMatch } from \"./types\";\n\ntype RouteToken =\n | { readonly type: \"static\"; readonly value: string }\n | { readonly type: \"parameter\"; readonly name: string }\n | { readonly type: \"catch-all\" };\n\ntype CompiledRoute = {\n readonly entry: ClientPageEntry;\n readonly tokens: readonly RouteToken[];\n readonly parameterNames: readonly string[];\n readonly expression: RegExp;\n readonly collisionKey: string;\n};\n\ntype SanitizedPath = {\n readonly path: string;\n readonly shouldDecodeParameters: boolean;\n};\n\nconst PARAMETER_NAME = /^[A-Za-z0-9_]+$/;\nconst REGEXP_SPECIAL = /[.*+?^${}()|[\\]\\\\]/g;\n\nfunction escapeRegExp(value: string): string {\n return value.replace(REGEXP_SPECIAL, \"\\\\$&\");\n}\n\nfunction decodeReservedCharacter(high: string, low: string): string | null {\n const pair = `${high}${low}`.toUpperCase();\n const reserved: Readonly<Record<string, string>> = {\n \"23\": \"#\",\n \"24\": \"$\",\n \"25\": \"%\",\n \"26\": \"&\",\n \"2B\": \"+\",\n \"2C\": \",\",\n \"2F\": \"/\",\n \"3A\": \":\",\n \"3B\": \";\",\n \"3D\": \"=\",\n \"3F\": \"?\",\n \"40\": \"@\",\n };\n\n return reserved[pair] ?? null;\n}\n\nfunction sanitizePathname(pathname: string): SanitizedPath | null {\n if (typeof pathname !== \"string\" || !pathname.startsWith(\"/\")) {\n throw new Error(\"Client route pathname must start with '/'\");\n }\n\n let path = pathname;\n let shouldDecode = false;\n let shouldDecodeParameters = false;\n\n for (let index = 1; index < path.length; index++) {\n if (path[index] !== \"%\") continue;\n\n const high = path[index + 1] ?? \"\";\n const low = path[index + 2] ?? \"\";\n const reserved = decodeReservedCharacter(high, low);\n\n if (reserved === null) {\n shouldDecode = true;\n continue;\n }\n\n shouldDecodeParameters = true;\n if (reserved === \"%\") {\n path = `${path.slice(0, index + 1)}25${path.slice(index + 1)}`;\n shouldDecode = true;\n index += 2;\n }\n index += 2;\n }\n\n try {\n if (shouldDecode) path = decodeURI(path);\n } catch {\n return null;\n }\n\n if (path.length > 1 && path.endsWith(\"/\")) path = path.slice(0, -1);\n\n return { path, shouldDecodeParameters };\n}\n\nfunction decodeParameter(value: string): string {\n let decoded = \"\";\n\n for (let index = 0; index < value.length; index++) {\n if (value[index] !== \"%\") {\n decoded += value[index];\n continue;\n }\n\n const reserved = decodeReservedCharacter(value[index + 1] ?? \"\", value[index + 2] ?? \"\");\n if (reserved === null) return value;\n\n decoded += reserved;\n index += 2;\n }\n\n return decoded;\n}\n\nfunction parsePattern(entry: ClientPageEntry): CompiledRoute {\n const original = entry.path;\n const isExactRootCatchAll = original === \"*\";\n if (!isExactRootCatchAll && !original.startsWith(\"/\")) {\n throw new Error(`Client route pattern '${original}' must start with '/'`);\n }\n\n const pattern = original.length > 1 && original.endsWith(\"/\")\n ? original.slice(0, -1)\n : original;\n const segments = isExactRootCatchAll\n ? [\"*\"]\n : pattern === \"/\"\n ? []\n : pattern.slice(1).split(\"/\");\n const tokens: RouteToken[] = [];\n const parameterNames: string[] = [];\n\n for (let index = 0; index < segments.length; index++) {\n const segment = segments[index];\n if (!segment) {\n throw new Error(`Client route pattern '${original}' contains an empty segment`);\n }\n\n if (segment === \"*\") {\n if (index !== segments.length - 1) {\n throw new Error(`Client route pattern '${original}' has a non-terminal catch-all`);\n }\n tokens.push({ type: \"catch-all\" });\n parameterNames.push(\"*\");\n continue;\n }\n\n if (segment.startsWith(\":\")) {\n const name = segment.slice(1);\n if (!PARAMETER_NAME.test(name)) {\n throw new Error(`Client route pattern '${original}' has an unsupported parameter segment`);\n }\n if (parameterNames.includes(name)) {\n throw new Error(`Client route pattern '${original}' repeats parameter '${name}'`);\n }\n tokens.push({ type: \"parameter\", name });\n parameterNames.push(name);\n continue;\n }\n\n if (segment.includes(\":\") || segment.includes(\"*\") || segment.includes(\"?\") || segment.includes(\"%\")) {\n throw new Error(`Client route pattern '${original}' contains unsupported syntax`);\n }\n tokens.push({ type: \"static\", value: segment });\n }\n\n let source = \"^\";\n for (let index = 0; index < tokens.length; index++) {\n const token = tokens[index];\n if (token.type === \"static\") source += `/${escapeRegExp(token.value)}`;\n if (token.type === \"parameter\") source += \"/([^/]{1,100})\";\n if (token.type === \"catch-all\") {\n source += isExactRootCatchAll ? \"(.*)\" : index === 0 ? \"/(.*)\" : \"/(.+)\";\n }\n }\n if (tokens.length === 0) source += \"/\";\n source += \"$\";\n\n const collisionKey = tokens\n .map((token) => {\n if (token.type === \"static\") return `s:${token.value.toLowerCase()}`;\n if (token.type === \"parameter\") return \"p\";\n return \"w\";\n })\n .join(\"/\");\n\n return {\n entry,\n tokens,\n parameterNames,\n expression: new RegExp(source, \"i\"),\n collisionKey,\n };\n}\n\nfunction compareSpecificity(left: CompiledRoute, right: CompiledRoute): number {\n const rank = (token: RouteToken | undefined): number => {\n if (!token || token.type === \"static\") return 3;\n if (token.type === \"parameter\") return 2;\n return 1;\n };\n\n const length = Math.max(left.tokens.length, right.tokens.length);\n for (let index = 0; index < length; index++) {\n const difference = rank(right.tokens[index]) - rank(left.tokens[index]);\n if (difference !== 0) return difference;\n }\n return 0;\n}\n\nfunction compileRoutes(entries: readonly ClientPageEntry[]): readonly CompiledRoute[] {\n const collisions = new Map<string, ClientPageEntry>();\n const routes = entries.map((entry) => {\n const route = parsePattern(entry);\n const existing = collisions.get(route.collisionKey);\n if (existing) {\n throw new Error(\n `Client route patterns '${existing.path}' and '${entry.path}' collide under server matching`,\n );\n }\n collisions.set(route.collisionKey, entry);\n return route;\n });\n\n return routes.sort(compareSpecificity);\n}\n\nexport function matchClientRoute(\n entries: readonly ClientPageEntry[],\n pathname: string,\n): ClientRouteMatch | null {\n const routes = compileRoutes(entries);\n const sanitized = sanitizePathname(pathname);\n if (!sanitized) return null;\n\n for (const route of routes) {\n const match = route.expression.exec(sanitized.path);\n if (!match) continue;\n\n const params: Record<string, string> = {};\n for (let index = 0; index < route.parameterNames.length; index++) {\n const value = match[index + 1];\n params[route.parameterNames[index]] = sanitized.shouldDecodeParameters\n ? decodeParameter(value)\n : value;\n }\n return { entry: route.entry, params };\n }\n\n return null;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"matcher.mjs","names":[],"sources":["../../../../../../../../web/src/client/runtime/matcher.ts"],"sourcesContent":["import type { ClientPageEntry, ClientRouteMatch } from \"./types\";\n\ntype RouteToken =\n | { readonly type: \"static\"; readonly value: string }\n | { readonly type: \"parameter\"; readonly name: string }\n | { readonly type: \"catch-all\" };\n\ntype CompiledRoute = {\n readonly entry: ClientPageEntry;\n readonly tokens: readonly RouteToken[];\n readonly parameterNames: readonly string[];\n readonly expression: RegExp;\n readonly collisionKey: string;\n};\n\ntype SanitizedPath = {\n readonly path: string;\n readonly shouldDecodeParameters: boolean;\n};\n\nconst PARAMETER_NAME = /^[A-Za-z0-9_]+$/;\nconst REGEXP_SPECIAL = /[.*+?^${}()|[\\]\\\\]/g;\n\nfunction escapeRegExp(value: string): string {\n return value.replace(REGEXP_SPECIAL, \"\\\\$&\");\n}\n\nfunction decodeReservedCharacter(high: string, low: string): string | null {\n const pair = `${high}${low}`.toUpperCase();\n const reserved: Readonly<Record<string, string>> = {\n \"23\": \"#\",\n \"24\": \"$\",\n \"25\": \"%\",\n \"26\": \"&\",\n \"2B\": \"+\",\n \"2C\": \",\",\n \"2F\": \"/\",\n \"3A\": \":\",\n \"3B\": \";\",\n \"3D\": \"=\",\n \"3F\": \"?\",\n \"40\": \"@\",\n };\n\n return reserved[pair] ?? null;\n}\n\nfunction sanitizePathname(pathname: string): SanitizedPath | null {\n if (typeof pathname !== \"string\" || !pathname.startsWith(\"/\")) {\n throw new Error(\"Client route pathname must start with '/'\");\n }\n\n let path = pathname;\n let shouldDecode = false;\n let shouldDecodeParameters = false;\n\n for (let index = 1; index < path.length; index++) {\n if (path[index] !== \"%\") continue;\n\n const high = path[index + 1] ?? \"\";\n const low = path[index + 2] ?? \"\";\n const reserved = decodeReservedCharacter(high, low);\n\n if (reserved === null) {\n shouldDecode = true;\n continue;\n }\n\n shouldDecodeParameters = true;\n if (reserved === \"%\") {\n path = `${path.slice(0, index + 1)}25${path.slice(index + 1)}`;\n shouldDecode = true;\n index += 2;\n }\n index += 2;\n }\n\n try {\n if (shouldDecode) path = decodeURI(path);\n } catch {\n return null;\n }\n\n if (path.length > 1 && path.endsWith(\"/\")) path = path.slice(0, -1);\n\n return { path, shouldDecodeParameters };\n}\n\nfunction decodeParameter(value: string): string {\n let decoded = \"\";\n\n for (let index = 0; index < value.length; index++) {\n if (value[index] !== \"%\") {\n decoded += value[index];\n continue;\n }\n\n const reserved = decodeReservedCharacter(value[index + 1] ?? \"\", value[index + 2] ?? \"\");\n if (reserved === null) return value;\n\n decoded += reserved;\n index += 2;\n }\n\n return decoded;\n}\n\nfunction parsePattern(entry: ClientPageEntry): CompiledRoute {\n const original = entry.path;\n const isExactRootCatchAll = original === \"*\";\n if (!isExactRootCatchAll && !original.startsWith(\"/\")) {\n throw new Error(`Client route pattern '${original}' must start with '/'`);\n }\n\n const pattern = original.length > 1 && original.endsWith(\"/\")\n ? original.slice(0, -1)\n : original;\n const segments = isExactRootCatchAll\n ? [\"*\"]\n : pattern === \"/\"\n ? []\n : pattern.slice(1).split(\"/\");\n const tokens: RouteToken[] = [];\n const parameterNames: string[] = [];\n\n for (let index = 0; index < segments.length; index++) {\n const segment = segments[index];\n if (!segment) {\n throw new Error(`Client route pattern '${original}' contains an empty segment`);\n }\n\n if (segment === \"*\") {\n if (index !== segments.length - 1) {\n throw new Error(`Client route pattern '${original}' has a non-terminal catch-all`);\n }\n tokens.push({ type: \"catch-all\" });\n parameterNames.push(\"*\");\n continue;\n }\n\n if (segment.startsWith(\":\")) {\n const name = segment.slice(1);\n if (!PARAMETER_NAME.test(name)) {\n throw new Error(`Client route pattern '${original}' has an unsupported parameter segment`);\n }\n if (parameterNames.includes(name)) {\n throw new Error(`Client route pattern '${original}' repeats parameter '${name}'`);\n }\n tokens.push({ type: \"parameter\", name });\n parameterNames.push(name);\n continue;\n }\n\n if (segment.includes(\":\") || segment.includes(\"*\") || segment.includes(\"?\") || segment.includes(\"%\")) {\n throw new Error(`Client route pattern '${original}' contains unsupported syntax`);\n }\n tokens.push({ type: \"static\", value: segment });\n }\n\n let source = \"^\";\n for (let index = 0; index < tokens.length; index++) {\n const token = tokens[index];\n if (token.type === \"static\") source += `/${escapeRegExp(token.value)}`;\n if (token.type === \"parameter\") source += \"/([^/]{1,100})\";\n if (token.type === \"catch-all\") {\n source += isExactRootCatchAll ? \"(.*)\" : index === 0 ? \"/(.*)\" : \"/(.+)\";\n }\n }\n if (tokens.length === 0) source += \"/\";\n source += \"$\";\n\n const collisionKey = tokens\n .map((token) => {\n if (token.type === \"static\") return `s:${token.value.toLowerCase()}`;\n if (token.type === \"parameter\") return \"p\";\n return \"w\";\n })\n .join(\"/\");\n\n return {\n entry,\n tokens,\n parameterNames,\n expression: new RegExp(source, \"i\"),\n collisionKey,\n };\n}\n\nfunction compareSpecificity(left: CompiledRoute, right: CompiledRoute): number {\n const rank = (token: RouteToken | undefined): number => {\n if (!token || token.type === \"static\") return 3;\n if (token.type === \"parameter\") return 2;\n return 1;\n };\n\n const length = Math.max(left.tokens.length, right.tokens.length);\n for (let index = 0; index < length; index++) {\n const difference = rank(right.tokens[index]) - rank(left.tokens[index]);\n if (difference !== 0) return difference;\n }\n return 0;\n}\n\nfunction compileRoutes(entries: readonly ClientPageEntry[]): readonly CompiledRoute[] {\n const collisions = new Map<string, ClientPageEntry>();\n const routes = entries.map((entry) => {\n const route = parsePattern(entry);\n const existing = collisions.get(route.collisionKey);\n if (existing) {\n throw new Error(\n `Client route patterns '${existing.path}' and '${entry.path}' collide under server matching`,\n );\n }\n collisions.set(route.collisionKey, entry);\n return route;\n });\n\n return routes.sort(compareSpecificity);\n}\n\n/**\n * @deprecated Do not adopt for new code. This client-side matcher duplicates the\n * route grammar the server already evaluates, and divergence between the two is\n * silent (wrong page, not an error). It is superseded by navigation consuming the\n * server-returned page composition/page swap: the client requests loader data and\n * the matched page's identity rides back on that same response.\n *\n * Delete only after the server-answered page swap is proven working in production\n * use — not before. Deleting earlier leaves neither implementation in place.\n * Removing this export (and the `@warlock.js/web/client/runtime` re-export) is a\n * breaking change to a published subpath and must be announced as one.\n */\nexport function matchClientRoute(\n entries: readonly ClientPageEntry[],\n pathname: string,\n): ClientRouteMatch | null {\n const routes = compileRoutes(entries);\n const sanitized = sanitizePathname(pathname);\n if (!sanitized) return null;\n\n for (const route of routes) {\n const match = route.expression.exec(sanitized.path);\n if (!match) continue;\n\n const params: Record<string, string> = {};\n for (let index = 0; index < route.parameterNames.length; index++) {\n const value = match[index + 1];\n params[route.parameterNames[index]] = sanitized.shouldDecodeParameters\n ? decodeParameter(value)\n : value;\n }\n return { entry: route.entry, params };\n }\n\n return null;\n}\n"],"mappings":"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"}
|
|
@@ -1,9 +1,22 @@
|
|
|
1
|
+
import { RegisterableModuleNamespace } from "../../runtime/register-modules.mjs";
|
|
2
|
+
|
|
1
3
|
//#region ../web/src/client/runtime/types.d.ts
|
|
2
|
-
|
|
4
|
+
/**
|
|
5
|
+
* A real ESM namespace returned by dynamic import(), retained so universal
|
|
6
|
+
* lifecycle exports and the default React component remain available together.
|
|
7
|
+
*/
|
|
8
|
+
type ClientProjectedModule = Readonly<Record<string, unknown>> & RegisterableModuleNamespace;
|
|
3
9
|
type ClientRouteComposition = {
|
|
4
10
|
readonly Page: ClientProjectedModule;
|
|
5
11
|
readonly layouts: readonly ClientProjectedModule[];
|
|
6
12
|
readonly App?: ClientProjectedModule;
|
|
13
|
+
/**
|
|
14
|
+
* The app-owned error leaf projected for this route. Optional so registries
|
|
15
|
+
* produced before `error.page.tsx` support remain valid byte-for-shape.
|
|
16
|
+
* Presence does not create a route; the hydration payload decides whether
|
|
17
|
+
* the server rendered this module instead of `Page`.
|
|
18
|
+
*/
|
|
19
|
+
readonly ErrorPage?: ClientProjectedModule;
|
|
7
20
|
};
|
|
8
21
|
type ClientRouteLoad = () => ClientRouteComposition | Promise<ClientRouteComposition>;
|
|
9
22
|
type ClientPageEntry = {
|
|
@@ -1,7 +1,45 @@
|
|
|
1
1
|
//#region ../web/src/components/document-context.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The JSON-safe error shape carried from the server document to browser
|
|
4
|
+
* hydration.
|
|
5
|
+
*
|
|
6
|
+
* This is deliberately NOT the original thrown object. Error prototypes,
|
|
7
|
+
* identity, non-enumerable fields and arbitrary custom values do not survive a
|
|
8
|
+
* JSON boundary reliably. The server renders `ErrorPageProps` with the
|
|
9
|
+
* original value, then normalizes it to this lossy representation only for the
|
|
10
|
+
* hydration payload. Normalization also owns disclosure: `stack` is optional
|
|
11
|
+
* and must be omitted or redacted when server internals are not safe to expose
|
|
12
|
+
* to the browser.
|
|
13
|
+
*/
|
|
14
|
+
type SerializedPageError = {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly message: string;
|
|
17
|
+
readonly stack?: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Props received by the application-owned `error.page.tsx` during SSR.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately preserve the thrown value here. An application can use its own
|
|
23
|
+
* error classes, symbols, or structured values while rendering on the server;
|
|
24
|
+
* this public component contract is not a JSON boundary.
|
|
25
|
+
*/
|
|
26
|
+
type ErrorPageProps = {
|
|
27
|
+
readonly error: unknown;
|
|
28
|
+
readonly status: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The JSON-safe counterpart of {@link ErrorPageProps}, used only after the
|
|
32
|
+
* document crosses from SSR into browser hydration. Keeping this distinct
|
|
33
|
+
* prevents a serialized approximation from being mistaken for the original
|
|
34
|
+
* thrown value available to the server render.
|
|
35
|
+
*/
|
|
36
|
+
type SerializedErrorPageProps = {
|
|
37
|
+
readonly error: SerializedPageError;
|
|
38
|
+
readonly status: number;
|
|
39
|
+
};
|
|
2
40
|
declare const PAYLOAD_SCRIPT_ID = "__WARLOCK_DATA__";
|
|
3
41
|
/** Escape JSON text for raw insertion into an application/json script. */
|
|
4
42
|
declare function escapePayload(json: string): string;
|
|
5
43
|
//#endregion
|
|
6
|
-
export { PAYLOAD_SCRIPT_ID, escapePayload };
|
|
44
|
+
export { ErrorPageProps, PAYLOAD_SCRIPT_ID, SerializedErrorPageProps, SerializedPageError, escapePayload };
|
|
7
45
|
//# sourceMappingURL=document-context.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"document-context.mjs","names":[],"sources":["../../../../../../../web/src/components/document-context.ts"],"sourcesContent":["import { createContext, useContext } from \"react\";\nimport type { MetadataOutput } from \"../metadata\";\n\nexport type HydrationDocumentPayloadSource = {\n readonly appData: unknown;\n readonly layoutData: unknown;\n readonly pageData: unknown;\n readonly shared: unknown;\n /**\n * The matched page manifest entry's stable `name` — the same field the\n * manifest entry contract `{ type, name, path, load }` declares. It is on\n * the wire so the browser can look up WHICH page the server rendered\n * instead of re-matching `location.pathname` itself: re-matching is a\n * second implementation of route semantics, and it can disagree with the\n * server on the very request it is hydrating.\n */\n readonly name: string;\n /**\n * The params the SERVER matched for this request — `bundle.route.params`\n * (`server/execute-page-request.ts:288`), carried untransformed. Same reason\n * `name` is here: the browser must not re-derive them from\n * `location.pathname`, because deriving them IS a second matcher.\n *\n * OPTIONAL, and ungated on purpose — see {@link metadata} below for the rule\n * both new keys share. The server always emits it (`{}` for a route with no\n * dynamic segments), so absence means the payload came from a producer that\n * predates this key; `currentRoute()` then reports `{}` rather than failing a\n * page over an accessor.\n */\n readonly params?: Readonly<Record<string, string>>;\n /**\n * The page metadata the server resolved at stage 8, carried WHOLE — the same\n * `MetadataOutput` `<Head/>` rendered into the document on the first request.\n *\n * Why it has to be on the wire at all: `<Head/>` renders inside the App\n * level, and the App level is not part of the hydrated tree (the client\n * mounts at `#root`, which App contains). So on a client navigation there is\n * no React render that can reach `<head>` — without this key the browser\n * never learns the new page's title and the tab keeps the old one.\n *\n * OPTIONAL, deliberately: `bundle.metadata` is itself optional\n * (`server/execute-page-request.ts:296`) — a page that exports no `metadata`\n * produces none, and a loader short-circuit skips stage 8 entirely. Gating a\n * key the server is right not to produce would make `readHydrationPayload`\n * throw on a valid page. Present-but-not-an-object is still MALFORMED and\n * still throws; only ABSENT is accepted.\n */\n readonly metadata?: MetadataOutput;\n};\n\nexport const PAYLOAD_SCRIPT_ID = \"__WARLOCK_DATA__\";\n\nconst LINE_SEPARATOR = String.fromCharCode(0x2028);\nconst PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);\n\n/** Escape JSON text for raw insertion into an application/json script. */\nexport function escapePayload(json: string): string {\n return json\n .split(\"<\")\n .join(\"\\\\u003c\")\n .split(\">\")\n .join(\"\\\\u003e\")\n .split(LINE_SEPARATOR)\n .join(\"\\\\u2028\")\n .split(PARAGRAPH_SEPARATOR)\n .join(\"\\\\u2029\");\n}\n\n/**\n * What `<Head/>`/`<Scripts/>` need to render real elements instead of the\n * framework injecting them by string surgery post-render (Suki, room seq\n * 1205): the resolved page metadata and the exact payload the hydration\n * script will read back. Provided once, around the root element, before\n * `renderToString` runs (`render-page.ts`'s stage 9 — the bundle is already\n * complete by then). Universal: no server-only imports, so the client's\n * hydration entry (a later slice) can provide the same shape from the parsed\n * payload script.\n */\nexport type DocumentContextValue = {\n metadata: MetadataOutput | undefined;\n payload: HydrationDocumentPayloadSource;\n /**\n * The nonce/lang/dir SLOTS: fed by the render provider from CORE request\n * fields — request nonce, request locale — never from app-owned `shared`\n * keys, which an app can overwrite. The provider-side\n * wiring is a separate slice, so these are absent at runtime until it\n * lands; every reader must treat them as optional.\n */\n nonce?: string;\n lang?: string;\n dir?: string;\n};\n\nexport const DocumentContext = createContext<DocumentContextValue | undefined>(undefined);\n\n/**\n * Require the page pipeline's universal document state. The payload id and\n * escaping helpers remain exported above for the existing server seam.\n */\nexport function useDocumentContext(componentName: string): DocumentContextValue {\n const value = useContext(DocumentContext);\n\n if (!value) {\n throw new Error(\n `<${componentName}/> was rendered outside the page pipeline's document context ` +\n \"(web/src/components/document-context.ts). Fix: only render it inside \" +\n \"an App/Layout/Page component tree the pipeline itself renders.\",\n );\n }\n\n return value;\n}\n"],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"document-context.mjs","names":[],"sources":["../../../../../../../web/src/components/document-context.ts"],"sourcesContent":["import { createContext, useContext } from \"react\";\nimport type { MetadataOutput } from \"../metadata\";\n\n/**\n * The JSON-safe error shape carried from the server document to browser\n * hydration.\n *\n * This is deliberately NOT the original thrown object. Error prototypes,\n * identity, non-enumerable fields and arbitrary custom values do not survive a\n * JSON boundary reliably. The server renders `ErrorPageProps` with the\n * original value, then normalizes it to this lossy representation only for the\n * hydration payload. Normalization also owns disclosure: `stack` is optional\n * and must be omitted or redacted when server internals are not safe to expose\n * to the browser.\n */\nexport type SerializedPageError = {\n readonly name: string;\n readonly message: string;\n readonly stack?: string;\n};\n\n/**\n * Props received by the application-owned `error.page.tsx` during SSR.\n *\n * Deliberately preserve the thrown value here. An application can use its own\n * error classes, symbols, or structured values while rendering on the server;\n * this public component contract is not a JSON boundary.\n */\nexport type ErrorPageProps = {\n readonly error: unknown;\n readonly status: number;\n};\n\n/**\n * The JSON-safe counterpart of {@link ErrorPageProps}, used only after the\n * document crosses from SSR into browser hydration. Keeping this distinct\n * prevents a serialized approximation from being mistaken for the original\n * thrown value available to the server render.\n */\nexport type SerializedErrorPageProps = {\n readonly error: SerializedPageError;\n readonly status: number;\n};\n\nexport type HydrationDocumentPayloadSource = {\n readonly appData: unknown;\n readonly layoutData: unknown;\n readonly pageData: unknown;\n readonly shared: unknown;\n /**\n * The matched page manifest entry's stable `name` — the same field the\n * manifest entry contract `{ type, name, path, load }` declares. It is on\n * the wire so the browser can look up WHICH page the server rendered\n * instead of re-matching `location.pathname` itself: re-matching is a\n * second implementation of route semantics, and it can disagree with the\n * server on the very request it is hydrating.\n */\n readonly name: string;\n /**\n * The params the SERVER matched for this request — `bundle.route.params`\n * (`server/execute-page-request.ts:288`), carried untransformed. Same reason\n * `name` is here: the browser must not re-derive them from\n * `location.pathname`, because deriving them IS a second matcher.\n *\n * OPTIONAL, and ungated on purpose — see {@link metadata} below for the rule\n * both new keys share. The server always emits it (`{}` for a route with no\n * dynamic segments), so absence means the payload came from a producer that\n * predates this key; `currentRoute()` then reports `{}` rather than failing a\n * page over an accessor.\n */\n readonly params?: Readonly<Record<string, string>>;\n /**\n * The page metadata the server resolved at stage 8, carried WHOLE — the same\n * `MetadataOutput` `<Head/>` rendered into the document on the first request.\n *\n * Why it has to be on the wire at all: `<Head/>` renders inside the App\n * level, and the App level is not part of the hydrated tree (the client\n * mounts at `#root`, which App contains). So on a client navigation there is\n * no React render that can reach `<head>` — without this key the browser\n * never learns the new page's title and the tab keeps the old one.\n *\n * OPTIONAL, deliberately: `bundle.metadata` is itself optional\n * (`server/execute-page-request.ts:296`) — a page that exports no `metadata`\n * produces none, and a loader short-circuit skips stage 8 entirely. Gating a\n * key the server is right not to produce would make `readHydrationPayload`\n * throw on a valid page. Present-but-not-an-object is still MALFORMED and\n * still throws; only ABSENT is accepted.\n */\n readonly metadata?: MetadataOutput;\n /**\n * Present only when the server selected the application-owned error page for\n * this response. Atomic rather than two independently optional top-level\n * fields: a status without an error (or the reverse) cannot describe a tree\n * the browser can hydrate.\n *\n * `name` above intentionally remains the ORIGINAL matched route. This field\n * selects the `ErrorPage` module projected into that route's client\n * composition; it does not turn the error page into a second browsable route.\n */\n readonly errorPage?: SerializedErrorPageProps;\n};\n\nexport const PAYLOAD_SCRIPT_ID = \"__WARLOCK_DATA__\";\n\nconst LINE_SEPARATOR = String.fromCharCode(0x2028);\nconst PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);\n\n/** Escape JSON text for raw insertion into an application/json script. */\nexport function escapePayload(json: string): string {\n return json\n .split(\"<\")\n .join(\"\\\\u003c\")\n .split(\">\")\n .join(\"\\\\u003e\")\n .split(LINE_SEPARATOR)\n .join(\"\\\\u2028\")\n .split(PARAGRAPH_SEPARATOR)\n .join(\"\\\\u2029\");\n}\n\n/**\n * What `<Head/>`/`<Scripts/>` need to render real elements instead of the\n * framework injecting them by string surgery post-render (Suki, room seq\n * 1205): the resolved page metadata and the exact payload the hydration\n * script will read back. Provided once, around the root element, before\n * `renderToString` runs (`render-page.ts`'s stage 9 — the bundle is already\n * complete by then). Universal: no server-only imports, so the client's\n * hydration entry (a later slice) can provide the same shape from the parsed\n * payload script.\n */\nexport type DocumentContextValue = {\n metadata: MetadataOutput | undefined;\n payload: HydrationDocumentPayloadSource;\n /**\n * The nonce/lang/dir SLOTS: fed by the render provider from CORE request\n * fields — request nonce, request locale — never from app-owned `shared`\n * keys, which an app can overwrite. The provider-side\n * wiring is a separate slice, so these are absent at runtime until it\n * lands; every reader must treat them as optional.\n */\n nonce?: string;\n lang?: string;\n dir?: string;\n};\n\nexport const DocumentContext = createContext<DocumentContextValue | undefined>(undefined);\n\n/**\n * Require the page pipeline's universal document state. The payload id and\n * escaping helpers remain exported above for the existing server seam.\n */\nexport function useDocumentContext(componentName: string): DocumentContextValue {\n const value = useContext(DocumentContext);\n\n if (!value) {\n throw new Error(\n `<${componentName}/> was rendered outside the page pipeline's document context ` +\n \"(web/src/components/document-context.ts). Fix: only render it inside \" +\n \"an App/Layout/Page component tree the pipeline itself renders.\",\n );\n }\n\n return value;\n}\n"],"mappings":";;;AAsGA,MAAa,oBAAoB;AAEjC,MAAM,iBAAiB,OAAO,aAAa,IAAM;AACjD,MAAM,sBAAsB,OAAO,aAAa,IAAM;;AAGtD,SAAgB,cAAc,MAAsB;CAClD,OAAO,KACJ,MAAM,GAAG,EACT,KAAK,SAAS,EACd,MAAM,GAAG,EACT,KAAK,SAAS,EACd,MAAM,cAAc,EACpB,KAAK,SAAS,EACd,MAAM,mBAAmB,EACzB,KAAK,SAAS;AACnB;AA2BA,MAAa,kBAAkB,cAAgD,MAAS;;;;;AAMxF,SAAgB,mBAAmB,eAA6C;CAC9E,MAAM,QAAQ,WAAW,eAAe;CAExC,IAAI,CAAC,OACH,MAAM,IAAI,MACR,IAAI,cAAc,iMAGpB;CAGF,OAAO;AACT"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"link.mjs","names":[],"sources":["../../../../../../../web/src/components/link.ts"],"sourcesContent":["import { createElement } from \"react\";\nimport type {\n AnchorHTMLAttributes,\n ComponentType,\n FocusEvent,\n MouseEvent,\n ReactElement,\n} from \"react\";\nimport { prefetchPageData } from \"../client/navigation/prefetch\";\nimport { currentNavigator } from \"../routing/navigator\";\nimport { href, knownRouteNames } from \"../routing/route-table\";\n\n/**\n * `<Link>` is SUGAR over `href()`, and deliberately thin.\n *\n * `href(name, params, query)` is the durable primitive — it serves emails,\n * redirects, `Location` headers and every non-React caller, none of which can\n * render a component. This file adds one thing to it: an anchor element.\n *\n * It renders a real `<a href>`. Client-side interception is a later slice of\n * the navigation runtime and lands here without changing this API, which is the\n * point of routing everything through `href` first: navigation becomes a\n * BEHAVIOUR change, not an API change.\n *\n * ── Parity with `@mongez/react-router` ───────────────────────────────────────\n * `href`, `newTab`, `email`, `tel`, `component` and `prefetch` are spelled\n * exactly as MRR spells them, so a component moved across keeps compiling.\n * `params` and `query` are ours and have no MRR equivalent: they pair with the\n * typed `href()` helper, which is what makes a route NAME — rather than a URL —\n * the thing a call site names.\n *\n * ── The semantic divergence this file bridges ────────────────────────────────\n * MRR's `to` is a PATH. Ours was a route NAME, and only a name — which meant a\n * component moved across from MRR compiled and then threw at render, because\n * `\"/products\"` is not the name of anything. The two packages disagreed about\n * what the most-used prop in either of them MEANS.\n *\n * Since 2026-08-24 (owner ruling) `to`/`href` accept BOTH, discriminated by\n * SHAPE — see {@link isLiteralUrl}. That is what makes MRR code portable, and\n * it costs nothing at a Warlock call site, because the two grammars cannot\n * collide: a route name never begins with `/` and never carries a `scheme:`.\n * The ruling RESTS on that, so this file asserts it rather than trusting it\n * ({@link RouteNameShapeCollisionError}).\n */\n\ntype AnchorProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"href\">;\n\n/**\n * Where the link goes. Every field is documented here once; which COMBINATIONS\n * are legal is decided by {@link LinkDestination}.\n */\ntype LinkDestinationProps = {\n /**\n * A route NAME, or a literal URL — told apart by SHAPE.\n *\n * `\"products.details\"` is a NAME and is resolved through the route table. A\n * page that moves changes its URL and keeps its name, so every call site\n * survives the move; a dead name throws at render naming the routes that do\n * exist, rather than rendering an anchor that 404s. This is the form to\n * prefer, and the only one `params` and `query` apply to.\n *\n * `\"/pricing\"`, `\"https://stripe.com\"`, `\"mailto:sales@example.com\"` and any\n * other `scheme:` are LITERAL — passed through to the element untouched, with\n * no route lookup at all. An app links out, and a route name is not a thing\n * you can have for a page that is not yours.\n */\n to?: string;\n /**\n * An alias of {@link to}, for parity with `@mongez/react-router`. Identical\n * in every respect, including which shapes it accepts.\n */\n href?: string;\n /** Renders a `mailto:` link. Not an in-app navigation. */\n email?: string;\n /** Renders a `tel:` link. Not an in-app navigation. */\n tel?: string;\n /**\n * Values for the route's `:param` segments, e.g. `{ id }` for\n * `\"/products/:id\"`. Only meaningful with a route NAME.\n */\n params?: Record<string, unknown>;\n /**\n * Query string values; an `undefined` value is omitted. Only meaningful with\n * a route NAME.\n */\n query?: Record<string, unknown>;\n};\n\n/**\n * EXACTLY ONE destination, enforced by the type.\n *\n * The alternative — a documented precedence such as \"`to` wins over `href`\" —\n * is silent by construction: the losing prop goes on compiling and goes on\n * reading like it works at the call site, and the anchor points at the wrong\n * page. Refusing the pair costs a call site one edit and can never be\n * misread. The runtime refuses it as well, because a JavaScript caller and a\n * cast both get past this.\n */\ntype LinkDestination =\n | { to: string; href?: never; email?: never; tel?: never }\n | { href: string; to?: never; email?: never; tel?: never }\n | { email: string; to?: never; href?: never; tel?: never }\n | { tel: string; to?: never; href?: never; email?: never };\n\nexport type LinkProps = AnchorProps &\n LinkDestinationProps &\n LinkDestination & {\n /**\n * Open in a new browsing context: `target=\"_blank\"` plus the `rel` that\n * stops the opened page from reaching back through `window.opener`.\n *\n * A caller's own `target`/`rel` win — this only fills in what was not said.\n */\n newTab?: boolean;\n /**\n * Render as something other than `<a>` — a tag name or a component.\n *\n * It receives the resolved `href`, the click handler and every remaining\n * prop, so a design-system anchor keeps client-side navigation as long as\n * it spreads what it is given onto the element it renders.\n *\n * `ComponentType<any>` is MRR's signature, kept verbatim: the component is\n * the caller's and its props are unknowable from here.\n */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n component?: ComponentType<any> | string;\n /**\n * Fetch this page's data when the pointer or the keyboard reaches the link,\n * so the click that follows swaps without a round trip.\n *\n * A GUESS, and treated as one everywhere: it is never awaited, a failure is\n * silent, and the click behaves exactly as it would without it. Opt-in per\n * link rather than on by default, because every prefetch is a request the\n * user did not ask for and someone pays for the bandwidth.\n *\n * IGNORED for anything that is not an in-app navigation — an external URL,\n * `mailto:`, `tel:`, `newTab`, any explicit `target`. Prefetching those\n * would mean issuing a cross-origin request to a third party on hover,\n * which is not a thing a link component may decide to do.\n */\n prefetch?: boolean;\n };\n\nconst DESTINATION_PROPS = [\"to\", \"href\", \"email\", \"tel\"] as const;\n\nexport class AmbiguousLinkDestinationError extends Error {\n public constructor(public readonly providedProps: readonly string[]) {\n super(\n `Warlock <Link> was given ${providedProps\n .map(name => JSON.stringify(name))\n .join(\" and \")}, but a link goes to exactly one place. There is no ` +\n \"precedence between them on purpose: one of the two would silently win, and the \" +\n \"call site would go on naming a destination that never renders. Delete the one \" +\n \"you did not mean.\",\n );\n this.name = \"AmbiguousLinkDestinationError\";\n }\n}\n\nexport class MissingLinkDestinationError extends Error {\n public constructor() {\n super(\n `Warlock <Link> was given no destination. Pass exactly one of ${DESTINATION_PROPS.map(\n name => JSON.stringify(name),\n ).join(\", \")}. It is not defaulted to the current page: an anchor with an empty ` +\n \"`href` renders as a working link and reloads the page when clicked, which is a \" +\n \"harder fault to see than this message.\",\n );\n this.name = \"MissingLinkDestinationError\";\n }\n}\n\nexport class RouteArgumentsOnLiteralUrlError extends Error {\n public constructor(\n public readonly url: string,\n public readonly providedProps: readonly string[],\n ) {\n super(\n `Warlock <Link> was given ${providedProps\n .map(name => JSON.stringify(name))\n .join(\" and \")} alongside the literal URL \"${url}\". Those apply to a route NAME, ` +\n \"which is resolved through the route table; a literal URL is passed through exactly \" +\n \"as written, so they would have been dropped and the link would have pointed at an \" +\n \"unfiltered page that still looked right at the call site. Put the values in the URL, \" +\n \"or name the route.\",\n );\n this.name = \"RouteArgumentsOnLiteralUrlError\";\n }\n}\n\n/**\n * The ruling's one assumption, broken. See the module doc comment: telling a\n * literal URL from a route NAME by shape is only safe while no route is NAMED\n * like a URL, and nothing in the route pipeline validates a hand-declared\n * `route.name`. So the collision is checked at the one place it could do harm,\n * where it is a loud refusal instead of an anchor that silently points\n * somewhere else.\n */\nexport class RouteNameShapeCollisionError extends Error {\n public constructor(public readonly routeName: string) {\n super(\n `Warlock route table: a route is NAMED ${JSON.stringify(routeName)}, which is shaped ` +\n \"like a URL. <Link> tells a literal URL from a route name by shape — a destination \" +\n \"starting with `/` or carrying a `scheme:` is passed through untouched — so this name \" +\n \"can never be resolved, and every link to it would silently point at that path \" +\n \"instead. Rename the route (`route = { path, name }`) to a dotted name such as \" +\n `${JSON.stringify(routeName.replace(/^\\/+/, \"\").replace(/\\//g, \".\") || \"index\")}.`,\n );\n this.name = \"RouteNameShapeCollisionError\";\n }\n}\n\ntype Destination = {\n /** What lands on the element's `href`. */\n url: string;\n /**\n * Whether this URL is a page in THIS app — the only kind the client\n * navigation runtime may be asked about, and the only kind that may be\n * prefetched. `mailto:`, `tel:` and an external URL hand off to another\n * application or another origin entirely, so intercepting any of them would\n * break it, and speculatively fetching one would be a cross-origin request\n * the developer never asked for.\n */\n isInApp: boolean;\n};\n\n/**\n * Any RFC 3986 scheme — `https:`, `mailto:`, `tel:`, `whatsapp:`, an app's own\n * custom one. Matched generically rather than as a list of known schemes: a\n * list would silently resolve `bitcoin:...` through the route table, which is\n * the exact failure this ruling exists to remove, and it would have to grow\n * forever.\n */\nconst SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:/i;\n\n/**\n * Whether this destination is a URL to be used as written, rather than a route\n * name to resolve.\n *\n * The whole discriminator, and deliberately the whole of it: two cheap shape\n * tests, no parsing, no matching. Anything more would be a SECOND route matcher\n * living beside the server's, which this codebase refuses everywhere it comes\n * up — a matcher that disagreed with the real one would produce links to pages\n * that do not exist.\n */\nfunction isLiteralUrl(destination: string): boolean {\n return destination.startsWith(\"/\") || SCHEME_PATTERN.test(destination);\n}\n\n/**\n * Whether a literal URL addresses THIS app.\n *\n * A path is ours. A `scheme:` is not — including `https:` to our own origin,\n * which would need `window.location` to recognise and would make the answer\n * depend on where the code is running. And `//host/path` is PROTOCOL-RELATIVE:\n * it starts with a slash and is nonetheless another origin, which is precisely\n * the case a \"starts with `/`\" test alone would hand to the navigator, where it\n * becomes a `pushState` to a foreign origin — a SecurityError — or a\n * speculative fetch of a third-party host.\n */\nfunction addressesThisApp(url: string): boolean {\n return url.startsWith(\"/\") && !url.startsWith(\"//\");\n}\n\n/**\n * Refuses the one table that would make {@link isLiteralUrl} wrong.\n *\n * Reached only for a destination already judged literal, so the cost is a scan\n * of the published names for links that were never going to hit the table\n * anyway — and zero for the route-name form, which is the common one. The right\n * permanent home for this is `publishRouteTable`, at boot, once (see the report\n * on this card).\n */\nfunction assertNotARouteName(url: string): void {\n if (knownRouteNames().includes(url)) throw new RouteNameShapeCollisionError(url);\n}\n\nconst ROUTE_ARGUMENT_PROPS = [\"params\", \"query\"] as const;\n\nfunction resolveDestination(props: LinkDestinationProps): Destination {\n const provided = DESTINATION_PROPS.filter(name => props[name] !== undefined);\n\n if (provided.length > 1) throw new AmbiguousLinkDestinationError(provided);\n\n if (provided.length === 0) throw new MissingLinkDestinationError();\n\n if (props.email !== undefined) return { url: `mailto:${props.email}`, isInApp: false };\n\n if (props.tel !== undefined) return { url: `tel:${props.tel}`, isInApp: false };\n\n const destination = (props.to ?? props.href) as string;\n\n /*\n LITERAL: `/pricing`, `https://stripe.com`, `mailto:…`, `whatsapp://…`. It\n goes to the element exactly as written and the route table is never\n consulted — there is nothing to look up, and looking anyway is what used to\n throw `UnknownRouteNameError` on every link out of the application.\n */\n if (isLiteralUrl(destination)) {\n const routeArguments = ROUTE_ARGUMENT_PROPS.filter(name => props[name] !== undefined);\n\n if (routeArguments.length > 0) {\n throw new RouteArgumentsOnLiteralUrlError(destination, routeArguments);\n }\n\n assertNotARouteName(destination);\n\n return { url: destination, isInApp: addressesThisApp(destination) };\n }\n\n /*\n A NAME, resolved against the route table published at boot from the SAME\n discovery result the server registered its routes from. The previous version\n of this file restated six URLs in a literal map, so linking to any seventh\n page in the application threw — the map was the limit on what could be\n linked, and nothing said so at the call site.\n */\n return { url: href(destination, props.params, props.query), isInApp: true };\n}\n\n/**\n * Whether this click should be left entirely to the browser.\n *\n * Every case here is a click that MEANS something other than \"go there in this\n * tab\", and intercepting any of them would take away behaviour the user\n * explicitly asked for:\n *\n * - a modifier or middle button: open in a new tab/window, or download\n * - `download`: save the resource, do not render it\n * - already prevented: something upstream in the tree handled this click\n *\n * Left button with no modifiers is the only click that means plain navigation.\n * The `target` case is decided before this, from the RESOLVED target, because\n * `newTab` sets it after the caller's props are read.\n */\nfunction isPlainLeftClick(event: MouseEvent<HTMLAnchorElement>): boolean {\n return (\n event.button === 0 &&\n !event.metaKey &&\n !event.ctrlKey &&\n !event.shiftKey &&\n !event.altKey &&\n !event.defaultPrevented\n );\n}\n\n/**\n * A target other than `_self` names ANOTHER browsing context — `_blank`, but\n * also `_parent`, `_top` and any named frame. Client navigation rewrites the\n * history of THIS one, so none of them are ours to intercept.\n */\nfunction opensAnotherContext(target: string | undefined): boolean {\n return target !== undefined && target !== \"_self\";\n}\n\nexport function Link({\n to,\n href: hrefAlias,\n email,\n tel,\n params,\n query,\n newTab,\n prefetch,\n component: Component = \"a\",\n children,\n onClick,\n ...elementProps\n}: LinkProps): ReactElement {\n const { url, isInApp } = resolveDestination({\n to,\n href: hrefAlias,\n email,\n tel,\n params,\n query,\n });\n\n const target = elementProps.target ?? (newTab === true ? \"_blank\" : undefined);\n\n // Only a DEFAULT: a caller that wrote its own `rel` (`\"me noopener\"`,\n // `\"external\"`) meant it, and overwriting it would delete a value the page\n // depends on to say something this component knows nothing about.\n const rel =\n elementProps.rel ?? (target === \"_blank\" ? \"noopener noreferrer\" : undefined);\n\n const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {\n // The caller's handler runs FIRST and unconditionally — it may be doing\n // analytics, closing a menu, or calling `preventDefault()` to veto the\n // navigation outright. Deciding before it ran would let this component\n // navigate away from a click the application had already cancelled.\n onClick?.(event);\n\n // `mailto:`, `tel:` and anything aimed at another browsing context leave\n // this page standing. The runtime is not consulted at all — asking it would\n // spend a page-data fetch on a click that was never going to navigate here.\n if (!isInApp || opensAnotherContext(target)) return;\n\n if (!isPlainLeftClick(event)) return;\n\n /*\n Asked for per click, never captured at render: the runtime registers\n itself when the hydration bundle mounts, which is AFTER the first render\n of every anchor on the page. A value read at render time would be\n `undefined` forever for exactly the links present at hydration — that is,\n all of them.\n\n Absent (server render, or before hydration) the anchor is left alone and\n does what an anchor does. That is the whole progressive-enhancement story:\n links work before this code runs, and work better after.\n */\n if (currentNavigator()?.(url) !== true) return;\n\n event.preventDefault();\n };\n\n /*\n The SAME gate the click uses, asked before any speculative request exists:\n only a destination this app would have navigated to itself may be fetched\n ahead of time. `mailto:`, `tel:`, an external URL and anything aimed at\n another browsing context are all clicks that leave this page, and none of\n them has page data to fetch.\n */\n const prefetchesOnInteraction =\n prefetch === true && isInApp && !opensAnotherContext(target);\n\n /*\n Attached ONLY when prefetching — a link without the prop keeps whatever\n handlers the caller passed, on the element, unwrapped.\n\n Hover AND focus, because a keyboard user never generates the first one and\n would otherwise be the only visitor who never gets the optimisation.\n\n Fire-and-forget by construction: `prefetchPageData` never rejects and is\n never awaited, so nothing here can delay the event or surface a failure. It\n is also safe to reach on the server — it no-ops without a browser — which is\n why this file can import it directly rather than through a `connect*` seam\n like the navigator's. The navigator needs a seam because the runtime behind\n it drags React state and the page registry into the server bundle; the\n prefetch cache is a `Map` and a `fetch` call, inert until an event fires.\n */\n const prefetchHandlers = prefetchesOnInteraction\n ? {\n onMouseEnter: (event: MouseEvent<HTMLAnchorElement>): void => {\n elementProps.onMouseEnter?.(event);\n void prefetchPageData(url);\n },\n onFocus: (event: FocusEvent<HTMLAnchorElement>): void => {\n elementProps.onFocus?.(event);\n void prefetchPageData(url);\n },\n }\n : undefined;\n\n return createElement(\n Component,\n { ...elementProps, ...prefetchHandlers, target, rel, href: url, onClick: handleClick },\n children,\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"link.mjs","names":[],"sources":["../../../../../../../web/src/components/link.ts"],"sourcesContent":["import { createElement } from \"react\";\nimport type {\n AnchorHTMLAttributes,\n ComponentType,\n FocusEvent,\n MouseEvent,\n ReactElement,\n} from \"react\";\nimport { prefetchPageData } from \"../client/navigation/prefetch\";\nimport { currentNavigator } from \"../routing/navigator\";\nimport { href, knownRouteNames } from \"../routing/route-table\";\n\n/**\n * `<Link>` is SUGAR over `href()`, and deliberately thin.\n *\n * `href(name, params, query)` is the durable primitive — it serves emails,\n * redirects, `Location` headers and every non-React caller, none of which can\n * render a component. This file adds one thing to it: an anchor element.\n *\n * It renders a real `<a href>`. Client-side interception is a later slice of\n * the navigation runtime and lands here without changing this API, which is the\n * point of routing everything through `href` first: navigation becomes a\n * BEHAVIOUR change, not an API change.\n *\n * ── Parity with `@mongez/react-router` ───────────────────────────────────────\n * `href`, `newTab`, `email`, `tel`, `component` and `prefetch` are spelled\n * exactly as MRR spells them, so a component moved across keeps compiling.\n * `params` and `query` are ours and have no MRR equivalent: they pair with the\n * typed `href()` helper, which is what makes a route NAME — rather than a URL —\n * the thing a call site names.\n *\n * ── The semantic divergence this file bridges ────────────────────────────────\n * MRR's `to` is a PATH. Ours was a route NAME, and only a name — which meant a\n * component moved across from MRR compiled and then threw at render, because\n * `\"/products\"` is not the name of anything. The two packages disagreed about\n * what the most-used prop in either of them MEANS.\n *\n * Since 2026-08-24 (owner ruling) `to`/`href` accept BOTH, discriminated by\n * SHAPE — see {@link isLiteralUrl}. That is what makes MRR code portable, and\n * it costs nothing at a Warlock call site, because the two grammars cannot\n * collide: a route name never begins with `/` and never carries a `scheme:`.\n * The ruling RESTS on that, so this file asserts it rather than trusting it\n * ({@link RouteNameShapeCollisionError}).\n */\n\ntype AnchorProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"href\">;\n\n/**\n * Where the link goes. Every field is documented here once; which COMBINATIONS\n * are legal is decided by {@link LinkDestination}.\n */\ntype LinkDestinationProps = {\n /**\n * A route NAME, or a literal URL — told apart by SHAPE.\n *\n * `\"products.details\"` is a NAME and is resolved through the route table. A\n * page that moves changes its URL and keeps its name, so every call site\n * survives the move; a dead name throws at render naming the routes that do\n * exist, rather than rendering an anchor that 404s. This is the form to\n * prefer, and the only one `params` and `query` apply to.\n *\n * `\"/pricing\"`, `\"https://stripe.com\"`, `\"mailto:sales@example.com\"` and any\n * other `scheme:` are LITERAL — passed through to the element untouched, with\n * no route lookup at all. An app links out, and a route name is not a thing\n * you can have for a page that is not yours.\n */\n to?: string;\n /**\n * An alias of {@link to}, for parity with `@mongez/react-router`. Identical\n * in every respect, including which shapes it accepts.\n */\n href?: string;\n /** Renders a `mailto:` link. Not an in-app navigation. */\n email?: string;\n /** Renders a `tel:` link. Not an in-app navigation. */\n tel?: string;\n /**\n * Values for the route's `:param` segments, e.g. `{ id }` for\n * `\"/products/:id\"`. Only meaningful with a route NAME.\n */\n params?: Record<string, unknown>;\n /**\n * Query string values; an `undefined` value is omitted. Only meaningful with\n * a route NAME.\n */\n query?: Record<string, unknown>;\n};\n\n/**\n * EXACTLY ONE destination, enforced by the type.\n *\n * The alternative — a documented precedence such as \"`to` wins over `href`\" —\n * is silent by construction: the losing prop goes on compiling and goes on\n * reading like it works at the call site, and the anchor points at the wrong\n * page. Refusing the pair costs a call site one edit and can never be\n * misread. The runtime refuses it as well, because a JavaScript caller and a\n * cast both get past this.\n */\ntype LinkDestination =\n | { to: string; href?: never; email?: never; tel?: never }\n | { href: string; to?: never; email?: never; tel?: never }\n | { email: string; to?: never; href?: never; tel?: never }\n | { tel: string; to?: never; href?: never; email?: never };\n\nexport type LinkProps = AnchorProps &\n LinkDestinationProps &\n LinkDestination & {\n /**\n * Open in a new browsing context: `target=\"_blank\"` plus the `rel` that\n * stops the opened page from reaching back through `window.opener`.\n *\n * A caller's own `target`/`rel` win — this only fills in what was not said.\n */\n newTab?: boolean;\n /**\n * Render as something other than `<a>` — a tag name or a component.\n *\n * It receives the resolved `href`, the click handler and every remaining\n * prop, so a design-system anchor keeps client-side navigation as long as\n * it spreads what it is given onto the element it renders.\n *\n * `ComponentType<any>` is MRR's signature, kept verbatim: the component is\n * the caller's and its props are unknowable from here.\n */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n component?: ComponentType<any> | string;\n /**\n * Fetch this page's data when the pointer or the keyboard reaches the link,\n * so the click that follows swaps without a round trip.\n *\n * A GUESS, and treated as one everywhere: it is never awaited, a failure is\n * silent, and the click behaves exactly as it would without it. Opt-in per\n * link rather than on by default, because every prefetch is a request the\n * user did not ask for and someone pays for the bandwidth.\n *\n * IGNORED for anything that is not an in-app navigation — an external URL,\n * `mailto:`, `tel:`, `newTab`, any explicit `target`. Prefetching those\n * would mean issuing a cross-origin request to a third party on hover,\n * which is not a thing a link component may decide to do.\n */\n prefetch?: boolean;\n };\n\nconst DESTINATION_PROPS = [\"to\", \"href\", \"email\", \"tel\"] as const;\n\nexport class AmbiguousLinkDestinationError extends Error {\n public constructor(public readonly providedProps: readonly string[]) {\n super(\n `Warlock <Link> was given ${providedProps\n .map(name => JSON.stringify(name))\n .join(\" and \")}, but a link goes to exactly one place. There is no ` +\n \"precedence between them on purpose: one of the two would silently win, and the \" +\n \"call site would go on naming a destination that never renders. Delete the one \" +\n \"you did not mean.\",\n );\n this.name = \"AmbiguousLinkDestinationError\";\n }\n}\n\nexport class MissingLinkDestinationError extends Error {\n public constructor() {\n super(\n `Warlock <Link> was given no destination. Pass exactly one of ${DESTINATION_PROPS.map(\n name => JSON.stringify(name),\n ).join(\", \")}. It is not defaulted to the current page: an anchor with an empty ` +\n \"`href` renders as a working link and reloads the page when clicked, which is a \" +\n \"harder fault to see than this message.\",\n );\n this.name = \"MissingLinkDestinationError\";\n }\n}\n\nexport class RouteArgumentsOnLiteralUrlError extends Error {\n public constructor(\n public readonly url: string,\n public readonly providedProps: readonly string[],\n ) {\n super(\n `Warlock <Link> was given ${providedProps\n .map(name => JSON.stringify(name))\n .join(\" and \")} alongside the literal URL \"${url}\". Those apply to a route NAME, ` +\n \"which is resolved through the route table; a literal URL is passed through exactly \" +\n \"as written, so they would have been dropped and the link would have pointed at an \" +\n \"unfiltered page that still looked right at the call site. Put the values in the URL, \" +\n \"or name the route.\",\n );\n this.name = \"RouteArgumentsOnLiteralUrlError\";\n }\n}\n\n/**\n * The ruling's one assumption, broken. See the module doc comment: telling a\n * literal URL from a route NAME by shape is only safe while no route is NAMED\n * like a URL, and nothing in the route pipeline validates a hand-declared\n * `route.name`. So the collision is checked at the one place it could do harm,\n * where it is a loud refusal instead of an anchor that silently points\n * somewhere else.\n */\nexport class RouteNameShapeCollisionError extends Error {\n public constructor(public readonly routeName: string) {\n super(\n `Warlock route table: a route is NAMED ${JSON.stringify(routeName)}, which is shaped ` +\n \"like a URL. <Link> tells a literal URL from a route name by shape — a destination \" +\n \"starting with `/` or carrying a `scheme:` is passed through untouched — so this name \" +\n \"can never be resolved, and every link to it would silently point at that path \" +\n \"instead. Rename the route (`route = { path, name }`) to a dotted name such as \" +\n `${JSON.stringify(routeName.replace(/^\\/+/, \"\").replace(/\\//g, \".\") || \"index\")}.`,\n );\n this.name = \"RouteNameShapeCollisionError\";\n }\n}\n\ntype Destination = {\n /** What lands on the element's `href`. */\n url: string;\n /**\n * Whether this URL is a page in THIS app — the only kind the client\n * navigation runtime may be asked about, and the only kind that may be\n * prefetched. `mailto:`, `tel:` and an external URL hand off to another\n * application or another origin entirely, so intercepting any of them would\n * break it, and speculatively fetching one would be a cross-origin request\n * the developer never asked for.\n */\n isInApp: boolean;\n};\n\n/**\n * Any RFC 3986 scheme — `https:`, `mailto:`, `tel:`, `whatsapp:`, an app's own\n * custom one. Matched generically rather than as a list of known schemes: a\n * list would silently resolve `bitcoin:...` through the route table, which is\n * the exact failure this ruling exists to remove, and it would have to grow\n * forever.\n */\nconst SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:/i;\n\n/**\n * Whether this destination is a URL to be used as written, rather than a route\n * name to resolve.\n *\n * The whole discriminator, and deliberately the whole of it: two cheap shape\n * tests, no parsing, no matching. Anything more would be a SECOND route matcher\n * living beside the server's, which this codebase refuses everywhere it comes\n * up — a matcher that disagreed with the real one would produce links to pages\n * that do not exist.\n */\nfunction isLiteralUrl(destination: string): boolean {\n return destination.startsWith(\"/\") || SCHEME_PATTERN.test(destination);\n}\n\n/**\n * Whether a literal URL addresses THIS app.\n *\n * A path is ours. A `scheme:` is not — including `https:` to our own origin,\n * which would need `window.location` to recognise and would make the answer\n * depend on where the code is running. And `//host/path` is PROTOCOL-RELATIVE:\n * it starts with a slash and is nonetheless another origin, which is precisely\n * the case a \"starts with `/`\" test alone would hand to the navigator, where it\n * becomes a `pushState` to a foreign origin — a SecurityError — or a\n * speculative fetch of a third-party host.\n */\nfunction addressesThisApp(url: string): boolean {\n return url.startsWith(\"/\") && !url.startsWith(\"//\");\n}\n\n/**\n * Refuses the one table that would make {@link isLiteralUrl} wrong.\n *\n * Reached only for a destination already judged literal, so the cost is a scan\n * of the published names for links that were never going to hit the table\n * anyway — and zero for the route-name form, which is the common one. The right\n * permanent home for this is `publishRouteTable`, at boot, once (see the report\n * on this card).\n */\nfunction assertNotARouteName(url: string): void {\n if (knownRouteNames().includes(url)) throw new RouteNameShapeCollisionError(url);\n}\n\nconst ROUTE_ARGUMENT_PROPS = [\"params\", \"query\"] as const;\n\nfunction resolveDestination(props: LinkDestinationProps): Destination {\n const provided = DESTINATION_PROPS.filter(name => props[name] !== undefined);\n\n if (provided.length > 1) throw new AmbiguousLinkDestinationError(provided);\n\n if (provided.length === 0) throw new MissingLinkDestinationError();\n\n if (props.email !== undefined) return { url: `mailto:${props.email}`, isInApp: false };\n\n if (props.tel !== undefined) return { url: `tel:${props.tel}`, isInApp: false };\n\n const destination = (props.to ?? props.href) as string;\n\n /*\n LITERAL: `/pricing`, `https://stripe.com`, `mailto:…`, `whatsapp://…`. It\n goes to the element exactly as written and the route table is never\n consulted — there is nothing to look up, and looking anyway is what used to\n throw `UnknownRouteNameError` on every link out of the application.\n */\n if (isLiteralUrl(destination)) {\n const routeArguments = ROUTE_ARGUMENT_PROPS.filter(name => props[name] !== undefined);\n\n if (routeArguments.length > 0) {\n throw new RouteArgumentsOnLiteralUrlError(destination, routeArguments);\n }\n\n assertNotARouteName(destination);\n\n return { url: destination, isInApp: addressesThisApp(destination) };\n }\n\n /*\n A NAME, resolved against the route table published at boot from the SAME\n discovery result the server registered its routes from. The previous version\n of this file restated six URLs in a literal map, so linking to any seventh\n page in the application threw — the map was the limit on what could be\n linked, and nothing said so at the call site.\n */\n return { url: href(destination, props.params, props.query), isInApp: true };\n}\n\n/**\n * Whether this click should be left entirely to the browser.\n *\n * Every case here is a click that MEANS something other than \"go there in this\n * tab\", and intercepting any of them would take away behaviour the user\n * explicitly asked for:\n *\n * - a modifier or middle button: open in a new tab/window, or download\n * - `download`: save the resource, do not render it\n * - already prevented: something upstream in the tree handled this click\n *\n * Left button with no modifiers is the only click that means plain navigation.\n * The `target` case is decided before this, from the RESOLVED target, because\n * `newTab` sets it after the caller's props are read.\n */\nfunction isPlainLeftClick(event: MouseEvent<HTMLAnchorElement>): boolean {\n return (\n event.button === 0 &&\n !event.metaKey &&\n !event.ctrlKey &&\n !event.shiftKey &&\n !event.altKey &&\n !event.defaultPrevented\n );\n}\n\n/**\n * A target other than `_self` names ANOTHER browsing context — `_blank`, but\n * also `_parent`, `_top` and any named frame. Client navigation rewrites the\n * history of THIS one, so none of them are ours to intercept.\n */\nfunction opensAnotherContext(target: string | undefined): boolean {\n return target !== undefined && target !== \"_self\";\n}\n\nexport function Link({\n to,\n href: hrefAlias,\n email,\n tel,\n params,\n query,\n newTab,\n prefetch,\n component: Component = \"a\",\n children,\n onClick,\n ...elementProps\n}: LinkProps): ReactElement {\n const { url, isInApp } = resolveDestination({\n to,\n href: hrefAlias,\n email,\n tel,\n params,\n query,\n });\n\n const target = elementProps.target ?? (newTab === true ? \"_blank\" : undefined);\n\n // Only a DEFAULT: a caller that wrote its own `rel` (`\"me noopener\"`,\n // `\"external\"`) meant it, and overwriting it would delete a value the page\n // depends on to say something this component knows nothing about.\n const rel =\n elementProps.rel ?? (target === \"_blank\" ? \"noopener noreferrer\" : undefined);\n\n const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {\n // The caller's handler runs FIRST and unconditionally — it may be doing\n // analytics, closing a menu, or calling `preventDefault()` to veto the\n // navigation outright. Deciding before it ran would let this component\n // navigate away from a click the application had already cancelled.\n onClick?.(event);\n\n // `mailto:`, `tel:` and anything aimed at another browsing context leave\n // this page standing. The runtime is not consulted at all — asking it would\n // spend a page-data fetch on a click that was never going to navigate here.\n if (!isInApp || opensAnotherContext(target)) return;\n\n if (!isPlainLeftClick(event)) return;\n\n /*\n Asked for per click, never captured at render: the runtime registers\n itself when the hydration bundle mounts, which is AFTER the first render\n of every anchor on the page. A value read at render time would be\n `undefined` forever for exactly the links present at hydration — that is,\n all of them.\n\n Absent (server render, or before hydration) the anchor is left alone and\n does what an anchor does. That is the whole progressive-enhancement story:\n links work before this code runs, and work better after.\n */\n if (currentNavigator()?.(url) !== true) return;\n\n event.preventDefault();\n };\n\n /*\n The SAME gate the click uses, asked before any speculative request exists:\n only a destination this app would have navigated to itself may be fetched\n ahead of time. `mailto:`, `tel:`, an external URL and anything aimed at\n another browsing context are all clicks that leave this page, and none of\n them has page data to fetch.\n */\n const prefetchesOnInteraction =\n prefetch === true && isInApp && !opensAnotherContext(target);\n\n /*\n Attached ONLY when prefetching — a link without the prop keeps whatever\n handlers the caller passed, on the element, unwrapped.\n\n Hover AND focus, because a keyboard user never generates the first one and\n would otherwise be the only visitor who never gets the optimisation.\n\n Fire-and-forget by construction: `prefetchPageData` never rejects and is\n never awaited, so nothing here can delay the event or surface a failure. It\n is also safe to reach on the server — it no-ops without a browser — which is\n why this file can import it directly rather than through a `connect*` seam\n like the navigator's. The navigator needs a seam because the runtime behind\n it drags React state and the page registry into the server bundle; the\n prefetch cache is a `Map` and a `fetch` call, inert until an event fires.\n */\n const prefetchHandlers = prefetchesOnInteraction\n ? {\n onMouseEnter: (event: MouseEvent<HTMLAnchorElement>): void => {\n elementProps.onMouseEnter?.(event);\n void prefetchPageData(url);\n },\n onFocus: (event: FocusEvent<HTMLAnchorElement>): void => {\n elementProps.onFocus?.(event);\n void prefetchPageData(url);\n },\n }\n : undefined;\n\n return createElement(\n Component,\n { ...elementProps, ...prefetchHandlers, target, rel, href: url, onClick: handleClick },\n children,\n );\n}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PAYLOAD_SCRIPT_ID, escapePayload, useDocumentContext } from "./document-context.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { isNonHydrating } from "../server/page-render-bundle.mjs";
|
|
3
|
+
import { Fragment, createElement } from "react";
|
|
3
4
|
|
|
4
5
|
//#region ../web/src/components/scripts.ts
|
|
5
6
|
/**
|
|
@@ -8,6 +9,7 @@ import { createElement } from "react";
|
|
|
8
9
|
*/
|
|
9
10
|
function Scripts(props) {
|
|
10
11
|
const { payload, nonce } = useDocumentContext("Scripts");
|
|
12
|
+
if (isNonHydrating(payload)) return createElement(Fragment, null);
|
|
11
13
|
return createElement("script", {
|
|
12
14
|
id: PAYLOAD_SCRIPT_ID,
|
|
13
15
|
type: "application/json",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scripts.mjs","names":[],"sources":["../../../../../../../web/src/components/scripts.ts"],"sourcesContent":["import { createElement, type ReactElement } from \"react\";\nimport { escapePayload, PAYLOAD_SCRIPT_ID, useDocumentContext } from \"./document-context\";\n\nexport type ScriptsProps = {\n /** Per-request CSP nonce for the inline payload script (root.tsx:119). */\n nonce?: string;\n};\n\n/**\n * OPTIONAL placement override for the serialized loader data and `shared`.\n * Hydration module emission is separate wiring.\n */\nexport function Scripts(props: ScriptsProps): ReactElement {\n const { payload, nonce } = useDocumentContext(\"Scripts\");\n\n // Explicit prop wins: v5/app's root.tsx passes `shared.nonce` today\n // (root.tsx:119) and must keep working unchanged. Only an absent prop falls\n // back to the framework's nonce slot (document-context.ts).\n const resolvedNonce = props.nonce ?? nonce;\n\n // `dangerouslySetInnerHTML`, not children: the serializer's escaped output must\n // reach the document byte-for-byte. React's default child-text escaping\n // (HTML-entity escaping) would double-process it and corrupt the JSON\n // (spike P7's escaping contract).\n return createElement(\"script\", {\n id: PAYLOAD_SCRIPT_ID,\n type: \"application/json\",\n nonce: resolvedNonce,\n dangerouslySetInnerHTML: { __html: escapePayload(JSON.stringify(payload)) },\n });\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"scripts.mjs","names":[],"sources":["../../../../../../../web/src/components/scripts.ts"],"sourcesContent":["import { createElement, Fragment, type ReactElement } from \"react\";\nimport { escapePayload, PAYLOAD_SCRIPT_ID, useDocumentContext } from \"./document-context\";\nimport { isNonHydrating } from \"../server/page-render-bundle\";\n\nexport type ScriptsProps = {\n /** Per-request CSP nonce for the inline payload script (root.tsx:119). */\n nonce?: string;\n};\n\n/**\n * OPTIONAL placement override for the serialized loader data and `shared`.\n * Hydration module emission is separate wiring.\n */\nexport function Scripts(props: ScriptsProps): ReactElement {\n const { payload, nonce } = useDocumentContext(\"Scripts\");\n\n // `renderPageFailure` marks its payload non-hydrating (page-render-bundle.ts):\n // a module-load/registration throw happens before any triple exists, so\n // there is nothing trustworthy for a hydration script to describe. A normal\n // app `error.page.tsx` payload (from `finishRender`) is never marked and\n // keeps emitting `__WARLOCK_DATA__` below.\n if (isNonHydrating(payload)) return createElement(Fragment, null);\n\n // Explicit prop wins: v5/app's root.tsx passes `shared.nonce` today\n // (root.tsx:119) and must keep working unchanged. Only an absent prop falls\n // back to the framework's nonce slot (document-context.ts).\n const resolvedNonce = props.nonce ?? nonce;\n\n // `dangerouslySetInnerHTML`, not children: the serializer's escaped output must\n // reach the document byte-for-byte. React's default child-text escaping\n // (HTML-entity escaping) would double-process it and corrupt the JSON\n // (spike P7's escaping contract).\n return createElement(\"script\", {\n id: PAYLOAD_SCRIPT_ID,\n type: \"application/json\",\n nonce: resolvedNonce,\n dangerouslySetInnerHTML: { __html: escapePayload(JSON.stringify(payload)) },\n });\n}\n"],"mappings":";;;;;;;;;AAaA,SAAgB,QAAQ,OAAmC;CACzD,MAAM,EAAE,SAAS,UAAU,mBAAmB,SAAS;CAOvD,IAAI,eAAe,OAAO,GAAG,OAAO,cAAc,UAAU,IAAI;CAWhE,OAAO,cAAc,UAAU;EAC7B,IAAI;EACJ,MAAM;EACN,OAToB,MAAM,SAAS;EAUnC,yBAAyB,EAAE,QAAQ,cAAc,KAAK,UAAU,OAAO,CAAC,EAAE;CAC5E,CAAC;AACH"}
|