@warlock.js/web 5.4.0 → 5.6.0
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 +33 -5
- package/esm/build/contribution.d.mts +2 -2
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +127 -205
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs +5 -3
- package/esm/build/generate-pages-barrel.mjs.map +1 -1
- package/esm/build/public-files.mjs +29 -1
- package/esm/build/public-files.mjs.map +1 -1
- package/esm/build/read-metadata-keys.mjs +225 -0
- package/esm/build/read-metadata-keys.mjs.map +1 -0
- package/esm/build/read-route-exports.mjs.map +1 -1
- package/esm/client/build-hydrated-tree.mjs +1 -1
- package/esm/client/build-hydrated-tree.mjs.map +1 -1
- package/esm/client/navigation/document-metadata.mjs +108 -0
- package/esm/client/navigation/document-metadata.mjs.map +1 -0
- package/esm/client/navigation/navigation-root.mjs +23 -103
- package/esm/client/navigation/navigation-root.mjs.map +1 -1
- package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -1
- package/esm/client/navigation/use-query-string.d.mts +35 -0
- package/esm/client/navigation/use-query-string.mjs +68 -0
- package/esm/client/navigation/use-query-string.mjs.map +1 -0
- package/esm/client/runtime/index.d.mts +1 -1
- package/esm/client/runtime/index.mjs +1 -1
- package/esm/client/runtime/manifest.mjs.map +1 -1
- package/esm/client/runtime/matcher.mjs.map +1 -1
- package/esm/client/runtime/types.d.mts +1 -1
- package/esm/components/link.mjs.map +1 -1
- package/esm/core/src/router/normalize-route-path.mjs +33 -0
- package/esm/core/src/router/normalize-route-path.mjs.map +1 -0
- package/esm/{hydration → entry}/index.mjs +1 -1
- package/esm/entry/index.mjs.map +1 -0
- package/esm/hydration-payload.mjs.map +1 -1
- package/esm/index.d.mts +3 -2
- package/esm/index.mjs +3 -2
- package/esm/loaders.d.mts +10 -3
- package/esm/localization.mjs.map +1 -1
- package/esm/metadata.d.mts +4 -4
- package/esm/metadata.mjs.map +1 -1
- package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs +33 -0
- package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs.map +1 -0
- package/esm/{runtime/register-modules.d.mts → register-modules.d.mts} +1 -1
- package/esm/{runtime/register-modules.mjs → register-modules.mjs} +1 -1
- package/esm/register-modules.mjs.map +1 -0
- package/esm/route.d.mts +35 -2
- package/esm/routing/duplicate-route-path.mjs +22 -0
- package/esm/routing/duplicate-route-path.mjs.map +1 -0
- package/esm/routing/layout-level.mjs +50 -0
- package/esm/routing/layout-level.mjs.map +1 -0
- package/esm/routing/layout-policy.mjs.map +1 -1
- package/esm/routing/query-string.d.mts +23 -1
- package/esm/routing/query-string.mjs +23 -1
- package/esm/routing/query-string.mjs.map +1 -1
- package/esm/routing/route-identity.mjs +27 -2
- package/esm/routing/route-identity.mjs.map +1 -1
- package/esm/routing/route-table.mjs.map +1 -1
- package/esm/routing/router-events.d.mts +5 -4
- package/esm/routing/router-events.mjs.map +1 -1
- package/esm/server/create-page-route-handler.d.mts +2 -2
- package/esm/server/create-page-route-handler.mjs +6 -6
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/{dev-server.mjs → dev-error-transport.mjs} +2 -2
- package/esm/server/dev-error-transport.mjs.map +1 -0
- package/esm/server/execute-page-request.mjs +87 -23
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/execute-page-request.types.d.mts +10 -1
- package/esm/server/fold-layout-loaders.mjs +36 -0
- package/esm/server/fold-layout-loaders.mjs.map +1 -0
- package/esm/server/hydration-client-url.mjs +2 -2
- package/esm/server/hydration-client-url.mjs.map +1 -1
- package/esm/server/index.d.mts +2 -2
- package/esm/server/index.mjs +2 -2
- package/esm/server/install-page-routes-from-manifest.d.mts +1 -1
- package/esm/server/install-page-routes-from-manifest.mjs +43 -40
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +22 -2
- package/esm/server/install-page-routes.mjs +108 -42
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/install-production-page-routes.mjs.map +1 -1
- package/esm/server/match-page-route.mjs.map +1 -1
- package/esm/server/page-file-change.mjs.map +1 -1
- package/esm/server/page-route-reload.mjs +4 -3
- package/esm/server/page-route-reload.mjs.map +1 -1
- package/esm/server/register-production-public-files.mjs +25 -1
- package/esm/server/register-production-public-files.mjs.map +1 -1
- package/esm/server/render-page.d.mts +11 -48
- package/esm/server/render-page.mjs +13 -55
- package/esm/server/render-page.mjs.map +1 -1
- package/esm/server/resolve-page-metadata.mjs.map +1 -1
- package/esm/server/resolve-route-validation-input.mjs +11 -0
- package/esm/server/resolve-route-validation-input.mjs.map +1 -0
- package/esm/server/route-validation-error.mjs +32 -0
- package/esm/server/route-validation-error.mjs.map +1 -0
- package/esm/server/settle-page-response.mjs +5 -3
- package/esm/server/settle-page-response.mjs.map +1 -1
- package/esm/server/unregistered-pages.mjs +2 -1
- package/esm/server/unregistered-pages.mjs.map +1 -1
- package/esm/server/web-connector-factory.mjs +1 -1
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.mjs +24 -321
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/shared/module-key.mjs +15 -0
- package/esm/shared/module-key.mjs.map +1 -0
- package/esm/shared/to-posix.mjs +13 -0
- package/esm/shared/to-posix.mjs.map +1 -0
- package/esm/shared.mjs +24 -0
- package/esm/shared.mjs.map +1 -1
- package/esm/vite/build-client.mjs.map +1 -1
- package/esm/vite/dev-server-config.mjs +240 -0
- package/esm/vite/dev-server-config.mjs.map +1 -0
- package/esm/vite/gate-a-resolve.d.mts +5 -4
- package/esm/vite/gate-a-resolve.mjs +31 -70
- package/esm/vite/gate-a-resolve.mjs.map +1 -1
- package/esm/vite/gate-b-secrets.mjs.map +1 -1
- package/esm/vite/gate-c-verify.d.mts +1 -1
- package/esm/vite/gate-c-verify.mjs +1 -1
- package/esm/vite/gate-c-verify.mjs.map +1 -1
- package/esm/vite/hydration-entries.mjs +5 -7
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/index.d.mts +1 -1
- package/esm/vite/index.mjs +4 -110
- package/esm/vite/index.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.mjs +9 -26
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/esm/vite/projection.mjs.map +1 -1
- package/esm/vite/react-refresh-preamble.mjs +90 -0
- package/esm/vite/react-refresh-preamble.mjs.map +1 -0
- package/esm/vite/ssr-client-view.mjs +135 -0
- package/esm/vite/ssr-client-view.mjs.map +1 -0
- package/llms-full.txt +564 -485
- package/llms.txt +2 -2
- package/package.json +6 -6
- package/skills/add-web-to-an-app/SKILL.md +3 -3
- package/skills/create-a-page/SKILL.md +348 -295
- package/skills/navigate-on-the-client/SKILL.md +212 -189
- package/esm/hydration/index.mjs.map +0 -1
- package/esm/runtime/register-modules.mjs.map +0 -1
- package/esm/server/dev-server.mjs.map +0 -1
- /package/esm/{hydration → entry}/index.d.mts +0 -0
|
@@ -3,118 +3,17 @@ import { LocaleProvider } from "../../localization.mjs";
|
|
|
3
3
|
import { fetchPageData } from "./fetch-page-data.mjs";
|
|
4
4
|
import { takePrefetchedPageData } from "./prefetch.mjs";
|
|
5
5
|
import { connectNavigator } from "../../routing/navigator.mjs";
|
|
6
|
+
import { routerEvents } from "../../routing/router-events.mjs";
|
|
6
7
|
import { recordCurrentRoute } from "./current-route.mjs";
|
|
7
8
|
import { connectRefresher, createRefresher } from "./refresh.mjs";
|
|
8
9
|
import { DocumentContext } from "../../components/document-context.mjs";
|
|
9
10
|
import { fragmentOf, samePageFragment, withFragmentFrom, withoutFragment } from "../../routing/url-fragment.mjs";
|
|
11
|
+
import { applyDocumentMetadata } from "./document-metadata.mjs";
|
|
10
12
|
import { scrollToFragment } from "./scroll-to-fragment.mjs";
|
|
11
13
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
12
14
|
import { jsx } from "react/jsx-runtime";
|
|
13
15
|
|
|
14
16
|
//#region ../web/src/client/navigation/navigation-root.tsx
|
|
15
|
-
function metaTag(attribute, key) {
|
|
16
|
-
return {
|
|
17
|
-
selector: `meta[${attribute}="${key}"]`,
|
|
18
|
-
create: (documentNode) => {
|
|
19
|
-
const element = documentNode.createElement("meta");
|
|
20
|
-
element.setAttribute(attribute, key);
|
|
21
|
-
return element;
|
|
22
|
-
},
|
|
23
|
-
write: (element, value) => element.setAttribute("content", value)
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
const TITLE_TAG = {
|
|
27
|
-
selector: "title",
|
|
28
|
-
create: (documentNode) => documentNode.createElement("title"),
|
|
29
|
-
write: (element, value) => {
|
|
30
|
-
element.textContent = value;
|
|
31
|
-
}
|
|
32
|
-
};
|
|
33
|
-
const CANONICAL_TAG = {
|
|
34
|
-
selector: "link[rel=\"canonical\"]",
|
|
35
|
-
create: (documentNode) => {
|
|
36
|
-
const element = documentNode.createElement("link");
|
|
37
|
-
element.setAttribute("rel", "canonical");
|
|
38
|
-
return element;
|
|
39
|
-
},
|
|
40
|
-
write: (element, value) => element.setAttribute("href", value)
|
|
41
|
-
};
|
|
42
|
-
/**
|
|
43
|
-
* The metadata, resolved into (tag, value) pairs in `<Head/>`'s ORDER and by
|
|
44
|
-
* `<Head/>`'s RULES — including the og fallbacks and the fact that they apply
|
|
45
|
-
* only when `openGraph` is present (`components/head.ts:21-24,43-48`).
|
|
46
|
-
*
|
|
47
|
-
* The duplication is deliberate and it is the known cost here. `<Head/>` is a
|
|
48
|
-
* React component that renders elements into a tree; this writes elements into
|
|
49
|
-
* a live `<head>` that no client tree owns. They cannot be one function today,
|
|
50
|
-
* but they MUST agree: the head after navigating to a URL has to equal the head
|
|
51
|
-
* after landing on it, or a share preview depends on how the visitor arrived.
|
|
52
|
-
* The fix is a shared descriptor list both consume — see the report's followup.
|
|
53
|
-
*/
|
|
54
|
-
function resolveManagedTags(metadata) {
|
|
55
|
-
const keywords = metadata?.keywords === void 0 ? void 0 : Array.isArray(metadata.keywords) ? metadata.keywords.join(", ") : metadata.keywords;
|
|
56
|
-
const openGraph = metadata?.openGraph;
|
|
57
|
-
const twitter = metadata?.twitter;
|
|
58
|
-
return [
|
|
59
|
-
[TITLE_TAG, metadata?.title],
|
|
60
|
-
[metaTag("name", "description"), metadata?.description],
|
|
61
|
-
[metaTag("name", "keywords"), keywords],
|
|
62
|
-
[CANONICAL_TAG, metadata?.canonical],
|
|
63
|
-
[metaTag("name", "robots"), metadata?.robots],
|
|
64
|
-
[metaTag("property", "og:title"), openGraph && (openGraph.title ?? metadata?.title)],
|
|
65
|
-
[metaTag("property", "og:description"), openGraph && (openGraph.description ?? metadata?.description)],
|
|
66
|
-
[metaTag("property", "og:image"), openGraph?.image],
|
|
67
|
-
[metaTag("property", "og:url"), openGraph?.url],
|
|
68
|
-
[metaTag("property", "og:type"), openGraph?.type],
|
|
69
|
-
[metaTag("name", "twitter:card"), twitter?.card],
|
|
70
|
-
[metaTag("name", "twitter:title"), twitter?.title],
|
|
71
|
-
[metaTag("name", "twitter:description"), twitter?.description],
|
|
72
|
-
[metaTag("name", "twitter:image"), twitter?.image]
|
|
73
|
-
];
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Make `<head>` describe the page now on screen.
|
|
77
|
-
*
|
|
78
|
-
* ## Why this is imperative, and why that is not a shortcut
|
|
79
|
-
*
|
|
80
|
-
* `<Head/>` renders inside the App level, and the App level is deliberately NOT
|
|
81
|
-
* in the hydrated tree — the client mounts at `#root`, which App contains
|
|
82
|
-
* (`client/build-hydrated-tree.ts`'s header). So no client render can reach
|
|
83
|
-
* `<head>`, and a swap either writes it directly or leaves the previous page's
|
|
84
|
-
* title in the tab. It leaves it today; that is the bug.
|
|
85
|
-
*
|
|
86
|
-
* ## ABSENT MEANS REMOVED
|
|
87
|
-
*
|
|
88
|
-
* Every managed tag the new metadata does not set is REMOVED, not left alone.
|
|
89
|
-
* `/` sets a description and `/contact-us` does not: keeping it would describe
|
|
90
|
-
* the contact page with the home page's words to every crawler, share preview
|
|
91
|
-
* and assistive reader that looks — a wrong answer, where an absent one is
|
|
92
|
-
* merely absent. A title the new page does not set goes too, and the tab falls
|
|
93
|
-
* back to the URL, which is the honest rendering of "this page did not name
|
|
94
|
-
* itself".
|
|
95
|
-
*
|
|
96
|
-
* Only the tags `<Head/>` renders FROM METADATA are touched. The charset meta
|
|
97
|
-
* is rendered unconditionally and belongs to the document, so it is left alone.
|
|
98
|
-
*
|
|
99
|
-
* Takes the document as an argument rather than reaching for the global, which
|
|
100
|
-
* is what makes it provable in a suite with no DOM.
|
|
101
|
-
*/
|
|
102
|
-
function applyDocumentMetadata(documentNode, metadata) {
|
|
103
|
-
for (const [tag, value] of resolveManagedTags(metadata)) {
|
|
104
|
-
const existing = documentNode.querySelector(tag.selector);
|
|
105
|
-
if (value === void 0) {
|
|
106
|
-
existing?.remove();
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
if (existing !== null) {
|
|
110
|
-
tag.write(existing, value);
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
const created = tag.create(documentNode);
|
|
114
|
-
tag.write(created, value);
|
|
115
|
-
documentNode.head.appendChild(created);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
17
|
function NavigationRoot({ pages, initialPayload, initialTree, buildTree }) {
|
|
119
18
|
const [current, setCurrent] = useState({
|
|
120
19
|
payload: initialPayload,
|
|
@@ -141,10 +40,21 @@ function NavigationRoot({ pages, initialPayload, initialTree, buildTree }) {
|
|
|
141
40
|
*/
|
|
142
41
|
const apply = async (url, replace, honourFragment) => {
|
|
143
42
|
const ticket = ++token;
|
|
43
|
+
const mode = replace ? "replace" : "push";
|
|
44
|
+
routerEvents.emitNavigating({
|
|
45
|
+
url,
|
|
46
|
+
mode
|
|
47
|
+
});
|
|
144
48
|
const result = takePrefetchedPageData(url) ?? await fetchPageData(url);
|
|
145
49
|
if (disposed || ticket !== token) return;
|
|
146
50
|
if (result.type === "hard-navigate") {
|
|
51
|
+
const error = /* @__PURE__ */ new Error(`Warlock navigation fell back to a full load (${result.reason}): ${url}`);
|
|
147
52
|
console.warn(`Warlock navigation fell back to a full load (${result.reason}):`, url);
|
|
53
|
+
routerEvents.emitNavigationError({
|
|
54
|
+
url,
|
|
55
|
+
mode,
|
|
56
|
+
error
|
|
57
|
+
});
|
|
148
58
|
window.location.assign(url);
|
|
149
59
|
return;
|
|
150
60
|
}
|
|
@@ -153,6 +63,11 @@ function NavigationRoot({ pages, initialPayload, initialTree, buildTree }) {
|
|
|
153
63
|
tree = await buildTree(pages, result.payload);
|
|
154
64
|
} catch (error) {
|
|
155
65
|
console.warn("Warlock navigation could not build the page tree:", error);
|
|
66
|
+
routerEvents.emitNavigationError({
|
|
67
|
+
url,
|
|
68
|
+
mode,
|
|
69
|
+
error
|
|
70
|
+
});
|
|
156
71
|
window.location.assign(url);
|
|
157
72
|
return;
|
|
158
73
|
}
|
|
@@ -168,6 +83,11 @@ function NavigationRoot({ pages, initialPayload, initialTree, buildTree }) {
|
|
|
168
83
|
tree,
|
|
169
84
|
routeSource: result.payload
|
|
170
85
|
});
|
|
86
|
+
routerEvents.emitNavigated({
|
|
87
|
+
url,
|
|
88
|
+
resolvedUrl: finalUrl,
|
|
89
|
+
mode
|
|
90
|
+
});
|
|
171
91
|
};
|
|
172
92
|
const claimTicket = () => {
|
|
173
93
|
const ticket = ++token;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"navigation-root.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/navigation-root.tsx"],"sourcesContent":["import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\r\nimport { DocumentContext } from \"../../components/document-context\";\nimport { LocaleProvider } from \"../../localization\";\nimport type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\r\nimport type { MetadataOutput } from \"../../metadata\";\r\nimport { connectNavigator } from \"../../routing/navigator\";\r\nimport {\r\n fragmentOf,\r\n samePageFragment,\r\n withFragmentFrom,\r\n withoutFragment,\r\n} from \"../../routing/url-fragment\";\r\nimport { hydrateShared } from \"../../shared\";\r\nimport type { ClientPageEntry } from \"../runtime\";\r\nimport { recordCurrentRoute } from \"./current-route\";\r\nimport { fetchPageData } from \"./fetch-page-data\";\r\nimport { takePrefetchedPageData } from \"./prefetch\";\r\nimport { connectRefresher, createRefresher, type RefreshablePage } from \"./refresh\";\r\nimport { scrollToFragment } from \"./scroll-to-fragment\";\r\n\r\n/**\r\n * The component that makes a page REPLACEABLE.\r\n *\r\n * Hydration mounts a fixed tree — correct, because the first render must match\r\n * the server's markup byte for byte. Client navigation needs that same position\r\n * in the tree to be able to hold a *different* page later, which means state,\r\n * which means a component. This is that component and nothing more.\r\n *\r\n * ## Why the layout stays mounted\r\n *\r\n * The new tree is built by the same `buildHydratedTree` the server's payload\r\n * went through, so a navigation within one layout produces an element whose\r\n * layout components are the same types in the same positions. React reconciles\r\n * them rather than remounting, so layout state — an open menu, a scroll\r\n * container, a video — survives. That is the entire user-visible benefit of\r\n * client navigation, and it is a consequence of reusing the builder rather than\r\n * anything this file does.\r\n */\r\n\r\n/**\r\n * ONE tag `<head>` may hold at most one of, addressed the way the browser\r\n * already addresses it. No marker attribute: the tags this replaces were\r\n * rendered by `<Head/>` on the server and carry none, and a marker would make\r\n * the applier ignore exactly the tags it exists to correct — the first\r\n * navigation's.\r\n */\r\ntype ManagedTag = {\r\n /** Finds the existing tag, server-rendered or applied by a previous swap. */\r\n selector: string;\r\n create: (documentNode: Document) => Element;\r\n write: (element: Element, value: string) => void;\r\n};\r\n\r\nfunction metaTag(attribute: \"name\" | \"property\", key: string): ManagedTag {\r\n return {\r\n selector: `meta[${attribute}=\"${key}\"]`,\r\n create: documentNode => {\r\n const element = documentNode.createElement(\"meta\");\r\n\r\n element.setAttribute(attribute, key);\r\n\r\n return element;\r\n },\r\n write: (element, value) => element.setAttribute(\"content\", value),\r\n };\r\n}\r\n\r\nconst TITLE_TAG: ManagedTag = {\r\n selector: \"title\",\r\n create: documentNode => documentNode.createElement(\"title\"),\r\n write: (element, value) => {\r\n element.textContent = value;\r\n },\r\n};\r\n\r\nconst CANONICAL_TAG: ManagedTag = {\r\n selector: 'link[rel=\"canonical\"]',\r\n create: documentNode => {\r\n const element = documentNode.createElement(\"link\");\r\n\r\n element.setAttribute(\"rel\", \"canonical\");\r\n\r\n return element;\r\n },\r\n write: (element, value) => element.setAttribute(\"href\", value),\r\n};\r\n\r\n/**\r\n * The metadata, resolved into (tag, value) pairs in `<Head/>`'s ORDER and by\r\n * `<Head/>`'s RULES — including the og fallbacks and the fact that they apply\r\n * only when `openGraph` is present (`components/head.ts:21-24,43-48`).\r\n *\r\n * The duplication is deliberate and it is the known cost here. `<Head/>` is a\r\n * React component that renders elements into a tree; this writes elements into\r\n * a live `<head>` that no client tree owns. They cannot be one function today,\r\n * but they MUST agree: the head after navigating to a URL has to equal the head\r\n * after landing on it, or a share preview depends on how the visitor arrived.\r\n * The fix is a shared descriptor list both consume — see the report's followup.\r\n */\r\nfunction resolveManagedTags(\r\n metadata: MetadataOutput | undefined,\r\n): readonly (readonly [ManagedTag, string | undefined])[] {\r\n const keywords =\r\n metadata?.keywords === undefined\r\n ? undefined\r\n : Array.isArray(metadata.keywords)\r\n ? metadata.keywords.join(\", \")\r\n : (metadata.keywords as string);\r\n\r\n const openGraph = metadata?.openGraph;\r\n const twitter = metadata?.twitter;\r\n\r\n return [\r\n [TITLE_TAG, metadata?.title],\r\n [metaTag(\"name\", \"description\"), metadata?.description],\r\n [metaTag(\"name\", \"keywords\"), keywords],\r\n [CANONICAL_TAG, metadata?.canonical],\r\n [metaTag(\"name\", \"robots\"), metadata?.robots],\r\n [metaTag(\"property\", \"og:title\"), openGraph && (openGraph.title ?? metadata?.title)],\r\n [\r\n metaTag(\"property\", \"og:description\"),\r\n openGraph && (openGraph.description ?? metadata?.description),\r\n ],\r\n [metaTag(\"property\", \"og:image\"), openGraph?.image],\r\n [metaTag(\"property\", \"og:url\"), openGraph?.url],\r\n [metaTag(\"property\", \"og:type\"), openGraph?.type],\r\n [metaTag(\"name\", \"twitter:card\"), twitter?.card],\r\n [metaTag(\"name\", \"twitter:title\"), twitter?.title],\r\n [metaTag(\"name\", \"twitter:description\"), twitter?.description],\r\n [metaTag(\"name\", \"twitter:image\"), twitter?.image],\r\n ];\r\n}\r\n\r\n/**\r\n * Make `<head>` describe the page now on screen.\r\n *\r\n * ## Why this is imperative, and why that is not a shortcut\r\n *\r\n * `<Head/>` renders inside the App level, and the App level is deliberately NOT\r\n * in the hydrated tree — the client mounts at `#root`, which App contains\r\n * (`client/build-hydrated-tree.ts`'s header). So no client render can reach\r\n * `<head>`, and a swap either writes it directly or leaves the previous page's\r\n * title in the tab. It leaves it today; that is the bug.\r\n *\r\n * ## ABSENT MEANS REMOVED\r\n *\r\n * Every managed tag the new metadata does not set is REMOVED, not left alone.\r\n * `/` sets a description and `/contact-us` does not: keeping it would describe\r\n * the contact page with the home page's words to every crawler, share preview\r\n * and assistive reader that looks — a wrong answer, where an absent one is\r\n * merely absent. A title the new page does not set goes too, and the tab falls\r\n * back to the URL, which is the honest rendering of \"this page did not name\r\n * itself\".\r\n *\r\n * Only the tags `<Head/>` renders FROM METADATA are touched. The charset meta\r\n * is rendered unconditionally and belongs to the document, so it is left alone.\r\n *\r\n * Takes the document as an argument rather than reaching for the global, which\r\n * is what makes it provable in a suite with no DOM.\r\n */\r\nexport function applyDocumentMetadata(\r\n documentNode: Document,\r\n metadata: MetadataOutput | undefined,\r\n): void {\r\n for (const [tag, value] of resolveManagedTags(metadata)) {\r\n const existing = documentNode.querySelector(tag.selector);\r\n\r\n if (value === undefined) {\r\n existing?.remove();\r\n continue;\r\n }\r\n\r\n if (existing !== null) {\r\n tag.write(existing, value);\r\n continue;\r\n }\r\n\r\n const created = tag.create(documentNode);\r\n\r\n tag.write(created, value);\r\n documentNode.head.appendChild(created);\r\n }\r\n}\r\n\r\nexport type NavigationRootProps = {\r\n pages: readonly ClientPageEntry[];\r\n /** The payload the document was rendered from — the hydration one. */\r\n initialPayload: HydrationDocumentPayloadSource;\r\n /** The tree already built from {@link initialPayload}, rendered as-is first. */\r\n initialTree: ReactNode;\r\n /**\r\n * How a payload becomes a tree. Injected rather than imported so this\r\n * component is testable without the page registry or a bundler — the same\r\n * reason `hydratePage` takes its builder as an argument.\r\n */\r\n buildTree: (\r\n pages: readonly ClientPageEntry[],\r\n payload: HydrationDocumentPayloadSource,\r\n ) => Promise<ReactNode>;\r\n};\r\n\r\n/**\r\n * The page on screen. Defined in `refresh.ts` because the third field is that\r\n * file's decision: `routeSource` is the payload object `current-route.ts`\r\n * identifies the route by, which is `payload` after a navigation but the\r\n * PREVIOUS page's object after a refresh — a refresh must not shift\r\n * `previousRoute()` onto the page the user is already looking at.\r\n */\r\ntype Current = RefreshablePage;\r\n\r\nexport function NavigationRoot({\r\n pages,\r\n initialPayload,\r\n initialTree,\r\n buildTree,\r\n}: NavigationRootProps) {\r\n const [current, setCurrent] = useState<Current>({\r\n payload: initialPayload,\r\n tree: initialTree,\r\n routeSource: initialPayload,\r\n });\r\n\r\n /*\r\n The latest state, readable from the effect below — which closes over the\r\n render that created it and would otherwise see the page the user was on\r\n when the runtime connected. A ref rather than an effect dependency because\r\n re-running the effect on every swap would disconnect and reconnect the\r\n navigator mid-navigation.\r\n */\r\n const currentRef = useRef(current);\r\n\r\n currentRef.current = current;\r\n\r\n /*\r\n THE ORDERING PROBLEM, and this ref is half of the answer to it.\r\n\r\n The element a fragment names lives in the tree that has not been built yet:\r\n at the moment `apply` finishes fetching, the DOM still holds the page the\r\n user is LEAVING. Scrolling there finds nothing, and finding nothing is\r\n silent — indistinguishable from the fragment bug itself.\r\n\r\n So the fragment is not scrolled to; it is HANDED OVER. `apply` parks it here\r\n immediately before the `setCurrent` that swaps the tree, and the layout\r\n effect below — which React runs after it has committed that tree to the DOM\r\n and before the browser paints — spends it. Read the two together; neither\r\n half means anything alone.\r\n */\r\n const pendingFragment = useRef<string | undefined>(undefined);\r\n\r\n useEffect(() => {\r\n /*\r\n THE RACE THIS COUNTER EXISTS FOR. Two clicks in quick succession start two\r\n fetches; the second can easily answer first (a cached page beating an\r\n uncached one is the common case, not the exotic one). Without a token the\r\n slower FIRST response lands last and the user ends up on the page they\r\n navigated away from, with the address bar showing the other one.\r\n\r\n Every navigation takes a token; a result is applied only if its token is\r\n still the newest. Superseded responses are dropped silently — they are not\r\n errors, they are answers to a question the user stopped asking.\r\n */\r\n let token = 0;\r\n let disposed = false;\r\n /*\r\n The URL this runtime last put in the address bar, so `popstate` can tell a\r\n move BETWEEN pages from a move between two fragments of one page. Seeded\r\n with the URL the document was loaded at, which is the entry the first Back\r\n would come from.\r\n */\r\n let committedUrl = window.location.href;\r\n\r\n /**\r\n * @param honourFragment whether the URL's fragment should be SCROLLED to\r\n * once the new page is on screen. True for a navigation the app asked for\r\n * — a `<Link>` click, `navigateTo` — and false for Back/Forward, where the\r\n * browser has already restored the scroll position of the entry being\r\n * returned to and moving the page again would overwrite the user's own\r\n * position with the anchor they had scrolled away from. (Restoration is\r\n * the browser's, deliberately: canon `0342c0d4`.)\r\n *\r\n * The fragment is still PRESERVED in the URL in both cases — see below.\r\n */\r\n const apply = async (\r\n url: string,\r\n replace: boolean,\r\n honourFragment: boolean,\r\n ): Promise<void> => {\r\n const ticket = ++token;\r\n /*\r\n A prefetched response is CONSUMED, never merely read — `take` removes it,\r\n so the same speculative fetch can satisfy exactly one navigation and a\r\n second click on the same link goes to the network. That matters because\r\n the HTTP cache cannot stand in for this: dev responses are `no-store`\r\n (`server/dev-server.ts:254`) and production is `private` with no\r\n `max-age` (`server/render-page.ts:432`), so the browser will not reliably\r\n replay the speculative response on the real click.\r\n\r\n The race guard below still holds on a cache hit: `??` short-circuits the\r\n await, and the synchronous path reaches the same `ticket !== token` check.\r\n */\r\n const result = takePrefetchedPageData(url) ?? (await fetchPageData(url));\r\n\r\n if (disposed || ticket !== token) return;\r\n\r\n if (result.type === \"hard-navigate\") {\r\n // The documented degradation: hand the URL back to the browser. The\r\n // user still gets the page — see `fetch-page-data.ts`.\r\n console.warn(`Warlock navigation fell back to a full load (${result.reason}):`, url);\r\n window.location.assign(url);\r\n\r\n return;\r\n }\r\n\r\n let tree: ReactNode;\r\n\r\n try {\r\n tree = await buildTree(pages, result.payload);\r\n } catch (error) {\r\n // The payload was fine but its page chunk would not load or compose —\r\n // a stale bundle after a deploy is the realistic cause. A full load\r\n // fetches the current bundle, which is also the fix.\r\n console.warn(\"Warlock navigation could not build the page tree:\", error);\r\n window.location.assign(url);\r\n\r\n return;\r\n }\r\n\r\n if (disposed || ticket !== token) return;\r\n\r\n /*\r\n Shared state BEFORE the render that consumes it. `hydrateShared`\r\n installs the snapshot `useShared()` reads; swapping the tree first would\r\n render one frame of the new page against the previous page's shared\r\n state — locale, permissions, the current user.\r\n */\r\n hydrateShared(result.payload.shared);\r\n\r\n /*\r\n The fragment PUT BACK. `result.url` comes from `response.url`, and a\r\n fragment is never sent to a server, so the URL a navigation would\r\n otherwise be written to history from has had it stripped — which is how\r\n `<Link href=\"/docs#install\">` used to land on `/docs` with the author's\r\n fragment gone from the address bar for good.\r\n\r\n Applied on EVERY path, Back included: a popstate re-fetch that wrote\r\n `result.url` back would delete the fragment from an entry the user is\r\n merely returning to.\r\n */\r\n const finalUrl = withFragmentFrom(result.url, url);\r\n\r\n // History AFTER the fetch succeeded, never before. Pushing optimistically\r\n // would leave the address bar pointing at a page that then failed to\r\n // load, and a Back press would return to a URL the user never saw.\r\n if (replace) {\r\n window.history.replaceState(null, \"\", finalUrl);\r\n } else {\r\n window.history.pushState(null, \"\", finalUrl);\r\n }\r\n\r\n committedUrl = finalUrl;\r\n\r\n // Handed to the layout effect, which runs once React has committed the\r\n // tree below to the DOM — the first moment the target can exist. Set\r\n // unconditionally so a navigation with no fragment CLEARS a fragment left\r\n // pending by one that was superseded.\r\n pendingFragment.current = honourFragment ? fragmentOf(finalUrl) : undefined;\r\n\r\n // A navigation IS the route moving, so the fetched payload is both the\r\n // page and the route's identity.\r\n setCurrent({ payload: result.payload, tree, routeSource: result.payload });\r\n };\r\n\r\n /*\r\n The same counter `apply` above takes its tickets from, handed to\r\n `refresh()` as a predicate. ONE mechanism, not two: a refresh and a\r\n navigation can overtake each other in either direction, and separate\r\n counters would leave each blind to the other.\r\n */\r\n const claimTicket = (): (() => boolean) => {\r\n const ticket = ++token;\r\n\r\n return () => !disposed && ticket === token;\r\n };\r\n\r\n const previousRefresher = connectRefresher(\r\n createRefresher({\r\n readCurrent: () => currentRef.current,\r\n writeCurrent: setCurrent,\r\n buildTree: payload => buildTree(pages, payload),\r\n claimTicket,\r\n }),\r\n );\r\n\r\n const previousNavigator = connectNavigator((url, options) => {\r\n const replace = options?.replace === true;\r\n\r\n /*\r\n THIS page with a fragment on it — `#reviews`, or the current path spelled\r\n out with one appended. No fetch, no tree swap: the page is already here,\r\n and re-fetching it would discard its DOM and everything live in it to\r\n arrive back where we started, one round trip later. Address bar first,\r\n then the jump, which is the order the browser uses for a plain anchor.\r\n */\r\n const fragment = samePageFragment(url, window.location.href);\r\n\r\n if (fragment !== undefined) {\r\n if (replace) {\r\n window.history.replaceState(null, \"\", url);\r\n } else {\r\n window.history.pushState(null, \"\", url);\r\n }\r\n\r\n committedUrl = window.location.href;\r\n\r\n // The target is in the DOM already, so there is nothing to wait for —\r\n // and nothing to hand to the layout effect, which no swap would fire.\r\n scrollToFragment(document, fragment);\r\n\r\n return true;\r\n }\r\n\r\n void apply(url, replace, true);\r\n\r\n // Accepted: the caller suppresses the browser's default. Returning `true`\r\n // before the fetch resolves is deliberate — the decision to handle a link\r\n // cannot wait on the network without the browser having already followed\r\n // it.\r\n return true;\r\n });\r\n\r\n /*\r\n Back/Forward. The entry is already in history and the URL has already\r\n changed by the time this fires, so the page is fetched and swapped with\r\n `replace` — pushing here would append a duplicate entry and make Back\r\n require two presses.\r\n */\r\n const onPopState = (): void => {\r\n const target = window.location.href;\r\n /*\r\n A hash-only move within one page — Back off a `#section` click, or\r\n Forward onto one. The document is the same document and the tree on\r\n screen is already the right tree, so there is nothing to fetch: the\r\n browser has changed the URL and restored the position for that entry\r\n itself, and re-fetching would throw away a live page to rebuild the one\r\n already showing. Scroll restoration stays the browser's (canon\r\n `0342c0d4`), which is exactly what leaving this alone means.\r\n */\r\n const hashOnlyMove = withoutFragment(target) === withoutFragment(committedUrl);\r\n\r\n committedUrl = target;\r\n\r\n if (hashOnlyMove) return;\r\n\r\n void apply(target, true, false);\r\n };\r\n\r\n window.addEventListener(\"popstate\", onPopState);\r\n\r\n return () => {\r\n disposed = true;\r\n window.removeEventListener(\"popstate\", onPopState);\r\n connectNavigator(previousNavigator);\r\n connectRefresher(previousRefresher);\r\n };\r\n }, [pages, buildTree]);\r\n\r\n /*\r\n THE OTHER HALF OF THE ORDERING PROBLEM (see `pendingFragment` above).\r\n\r\n `useLayoutEffect`, not `useEffect`, and the difference is the whole point:\r\n React runs a layout effect after it has COMMITTED this render to the DOM and\r\n BEFORE the browser paints. That is the earliest instant the new page's\r\n elements exist — a scroll any sooner finds nothing — and the last instant\r\n before the user sees anything, so the page is never painted at the top and\r\n then jumped. `useEffect` would satisfy the first requirement and not the\r\n second: it runs after paint, which is a visible flash of the wrong position.\r\n\r\n Keyed on `current` rather than reaching for a fresh render: the effect fires\r\n on the swap that put the target in the DOM, so no polling, no rAF, no\r\n timeout. What it CANNOT wait for is content that arrives later still — an\r\n image without dimensions above the target, a component that suspends — which\r\n moves the target after we have scrolled to where it was. That is the known\r\n limit of this mechanism and it is the same one a browser has.\r\n\r\n Consumed once: the fragment is cleared as it is read, so a later re-render\r\n (a refresh, a parent's state change) does not yank the page back to an\r\n anchor the user has since scrolled away from.\r\n */\r\n useLayoutEffect(() => {\r\n const fragment = pendingFragment.current;\r\n\r\n if (fragment === undefined) return;\r\n\r\n pendingFragment.current = undefined;\r\n\r\n scrollToFragment(document, fragment);\r\n }, [current]);\r\n\r\n /*\r\n The payload whose metadata `<head>` currently reflects. Seeded with the\r\n HYDRATION payload, which is the point: on the initial mount the head is the\r\n server's own render of this very metadata, so there is nothing to correct —\r\n and re-applying would be a chance to get it wrong, since a payload from a\r\n build with no `metadata` key would wipe a head the server filled in\r\n correctly. The head is applied on SWAPS only.\r\n */\r\n const appliedMetadataSource = useRef(current.payload);\r\n\r\n useEffect(() => {\r\n if (appliedMetadataSource.current === current.payload) return;\r\n\r\n appliedMetadataSource.current = current.payload;\r\n\r\n /*\r\n Keyed on the payload's identity, so this covers a refresh as well as a\r\n navigation: `refresh()` produces a NEW payload for the page already on\r\n screen, and a page whose metadata is a function of its loader data can\r\n legitimately re-title itself when that data changes. One applier at the\r\n one place the page changes, rather than a call in each pathway that could\r\n be forgotten in the next one.\r\n */\r\n applyDocumentMetadata(document, current.payload.metadata);\r\n }, [current.payload]);\r\n\r\n /*\r\n DURING RENDER, not in an effect, and that placement is the requirement\r\n rather than a shortcut. A page component calling `currentRoute()` does it\r\n while IT is rendering, and child effects run before a parent's, so anything\r\n recorded from an effect here would be recorded too late to answer the first\r\n render of the page it describes — which on the initial mount is the only\r\n render there has been, and the server's match is all there is.\r\n\r\n Recording is keyed on the payload's identity (`current-route.ts`), so the\r\n extra passes render gives us for free — StrictMode's double invoke, a parent\r\n re-render — are recognised as the same page rather than counted as\r\n navigations.\r\n\r\n `routeSource`, NOT `payload`: they are the same object for every navigation,\r\n and differ only after a refresh, which produces a new payload for the page\r\n already on screen and must not be counted as a move (see `refresh.ts`).\r\n */\r\n recordCurrentRoute(current.routeSource);\r\n\r\n /*\r\n An INNER DocumentContext provider, nested inside the one `hydratePage`\r\n mounted. That outer provider is created once with the hydration payload and\r\n never changes, so anything reading the document context after a navigation\r\n would see the payload of the page the user has left. The nearest provider\r\n wins, so this one keeps it current.\r\n */\r\n /*\r\n `metadata` from the PAYLOAD, not `undefined`. The document context is the\r\n universal shape — the server provides the resolved metadata around the same\r\n tree — so handing the client's readers `undefined` was a lie the payload can\r\n now correct. It also removes a latent mismatch: a `<Head/>` rendered inside\r\n a Layout (not the App level) renders its tags into `#root` server-side, and\r\n hydrating that markup against `metadata: undefined` produced a tree the\r\n server never rendered.\r\n */\r\n return (\n <DocumentContext.Provider\n value={{ metadata: current.payload.metadata, payload: current.payload }}\n >\n <LocaleProvider locale={current.payload.locale}>{current.tree}</LocaleProvider>\n </DocumentContext.Provider>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"navigation-root.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/navigation-root.tsx"],"sourcesContent":["import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\r\nimport { DocumentContext } from \"../../components/document-context\";\r\nimport { LocaleProvider } from \"../../localization\";\r\nimport type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\r\nimport { connectNavigator } from \"../../routing/navigator\";\r\nimport { routerEvents, type NavigationMode } from \"../../routing/router-events\";\r\nimport {\r\n fragmentOf,\r\n samePageFragment,\r\n withFragmentFrom,\r\n withoutFragment,\r\n} from \"../../routing/url-fragment\";\r\nimport { hydrateShared } from \"../../shared\";\r\nimport type { ClientPageEntry } from \"../runtime\";\r\nimport { recordCurrentRoute } from \"./current-route\";\r\nimport { applyDocumentMetadata } from \"./document-metadata\";\r\nimport { fetchPageData } from \"./fetch-page-data\";\r\nimport { takePrefetchedPageData } from \"./prefetch\";\r\nimport { connectRefresher, createRefresher, type RefreshablePage } from \"./refresh\";\r\nimport { scrollToFragment } from \"./scroll-to-fragment\";\r\n\r\n/**\r\n * The component that makes a page REPLACEABLE.\r\n *\r\n * Hydration mounts a fixed tree — correct, because the first render must match\r\n * the server's markup byte for byte. Client navigation needs that same position\r\n * in the tree to be able to hold a *different* page later, which means state,\r\n * which means a component. This is that component and nothing more.\r\n *\r\n * ## Why the layout stays mounted\r\n *\r\n * The new tree is built by the same `buildHydratedTree` the server's payload\r\n * went through, so a navigation within one layout produces an element whose\r\n * layout components are the same types in the same positions. React reconciles\r\n * them rather than remounting, so layout state — an open menu, a scroll\r\n * container, a video — survives. That is the entire user-visible benefit of\r\n * client navigation, and it is a consequence of reusing the builder rather than\r\n * anything this file does.\r\n */\r\n\r\nexport type NavigationRootProps = {\r\n pages: readonly ClientPageEntry[];\r\n /** The payload the document was rendered from — the hydration one. */\r\n initialPayload: HydrationDocumentPayloadSource;\r\n /** The tree already built from {@link initialPayload}, rendered as-is first. */\r\n initialTree: ReactNode;\r\n /**\r\n * How a payload becomes a tree. Injected rather than imported so this\r\n * component is testable without the page registry or a bundler — the same\r\n * reason `hydratePage` takes its builder as an argument.\r\n */\r\n buildTree: (\r\n pages: readonly ClientPageEntry[],\r\n payload: HydrationDocumentPayloadSource,\r\n ) => Promise<ReactNode>;\r\n};\r\n\r\n/**\r\n * The page on screen. Defined in `refresh.ts` because the third field is that\r\n * file's decision: `routeSource` is the payload object `current-route.ts`\r\n * identifies the route by, which is `payload` after a navigation but the\r\n * PREVIOUS page's object after a refresh — a refresh must not shift\r\n * `previousRoute()` onto the page the user is already looking at.\r\n */\r\ntype Current = RefreshablePage;\r\n\r\nexport function NavigationRoot({\r\n pages,\r\n initialPayload,\r\n initialTree,\r\n buildTree,\r\n}: NavigationRootProps) {\r\n const [current, setCurrent] = useState<Current>({\r\n payload: initialPayload,\r\n tree: initialTree,\r\n routeSource: initialPayload,\r\n });\r\n\r\n /*\r\n The latest state, readable from the effect below — which closes over the\r\n render that created it and would otherwise see the page the user was on\r\n when the runtime connected. A ref rather than an effect dependency because\r\n re-running the effect on every swap would disconnect and reconnect the\r\n navigator mid-navigation.\r\n */\r\n const currentRef = useRef(current);\r\n\r\n currentRef.current = current;\r\n\r\n /*\r\n THE ORDERING PROBLEM, and this ref is half of the answer to it.\r\n\r\n The element a fragment names lives in the tree that has not been built yet:\r\n at the moment `apply` finishes fetching, the DOM still holds the page the\r\n user is LEAVING. Scrolling there finds nothing, and finding nothing is\r\n silent — indistinguishable from the fragment bug itself.\r\n\r\n So the fragment is not scrolled to; it is HANDED OVER. `apply` parks it here\r\n immediately before the `setCurrent` that swaps the tree, and the layout\r\n effect below — which React runs after it has committed that tree to the DOM\r\n and before the browser paints — spends it. Read the two together; neither\r\n half means anything alone.\r\n */\r\n const pendingFragment = useRef<string | undefined>(undefined);\r\n\r\n useEffect(() => {\r\n /*\r\n THE RACE THIS COUNTER EXISTS FOR. Two clicks in quick succession start two\r\n fetches; the second can easily answer first (a cached page beating an\r\n uncached one is the common case, not the exotic one). Without a token the\r\n slower FIRST response lands last and the user ends up on the page they\r\n navigated away from, with the address bar showing the other one.\r\n\r\n Every navigation takes a token; a result is applied only if its token is\r\n still the newest. Superseded responses are dropped silently — they are not\r\n errors, they are answers to a question the user stopped asking.\r\n */\r\n let token = 0;\r\n let disposed = false;\r\n /*\r\n The URL this runtime last put in the address bar, so `popstate` can tell a\r\n move BETWEEN pages from a move between two fragments of one page. Seeded\r\n with the URL the document was loaded at, which is the entry the first Back\r\n would come from.\r\n */\r\n let committedUrl = window.location.href;\r\n\r\n /**\r\n * @param honourFragment whether the URL's fragment should be SCROLLED to\r\n * once the new page is on screen. True for a navigation the app asked for\r\n * — a `<Link>` click, `navigateTo` — and false for Back/Forward, where the\r\n * browser has already restored the scroll position of the entry being\r\n * returned to and moving the page again would overwrite the user's own\r\n * position with the anchor they had scrolled away from. (Restoration is\r\n * the browser's, deliberately: canon `0342c0d4`.)\r\n *\r\n * The fragment is still PRESERVED in the URL in both cases — see below.\r\n */\r\n const apply = async (url: string, replace: boolean, honourFragment: boolean): Promise<void> => {\r\n const ticket = ++token;\r\n /*\r\n `\"replace\"` covers a Back/Forward press as well as an explicit\r\n `<Link replace>` — both calls into `apply` pass `replace: true` for\r\n exactly that reason (see `onPopState` below), and `NavigationMode`'s own\r\n doc records why a listener does not need the two told apart.\r\n */\r\n const mode: NavigationMode = replace ? \"replace\" : \"push\";\r\n\r\n routerEvents.emitNavigating({ url, mode });\r\n /*\r\n A prefetched response is CONSUMED, never merely read — `take` removes it,\r\n so the same speculative fetch can satisfy exactly one navigation and a\r\n second click on the same link goes to the network. That matters because\r\n the HTTP cache cannot stand in for this: dev responses are `no-store`\r\n (`server/dev-server.ts:254`) and production is `private` with no\r\n `max-age` (`server/render-page.ts:432`), so the browser will not reliably\r\n replay the speculative response on the real click.\r\n\r\n The race guard below still holds on a cache hit: `??` short-circuits the\r\n await, and the synchronous path reaches the same `ticket !== token` check.\r\n */\r\n const result = takePrefetchedPageData(url) ?? (await fetchPageData(url));\r\n\r\n if (disposed || ticket !== token) return;\r\n\r\n if (result.type === \"hard-navigate\") {\r\n // The documented degradation: hand the URL back to the browser. The\r\n // user still gets the page — see `fetch-page-data.ts`. Announced as a\r\n // navigation ERROR, not a navigated one: `NavigationErrorPayload`'s own\r\n // doc names this exact case — the in-flight navigation is over, not\r\n // completed within this document.\r\n const error = new Error(\r\n `Warlock navigation fell back to a full load (${result.reason}): ${url}`,\r\n );\r\n\r\n console.warn(`Warlock navigation fell back to a full load (${result.reason}):`, url);\r\n routerEvents.emitNavigationError({ url, mode, error });\r\n window.location.assign(url);\r\n\r\n return;\r\n }\r\n\r\n let tree: ReactNode;\r\n\r\n try {\r\n tree = await buildTree(pages, result.payload);\r\n } catch (error) {\r\n // The payload was fine but its page chunk would not load or compose —\r\n // a stale bundle after a deploy is the realistic cause. A full load\r\n // fetches the current bundle, which is also the fix.\r\n console.warn(\"Warlock navigation could not build the page tree:\", error);\r\n routerEvents.emitNavigationError({ url, mode, error });\r\n window.location.assign(url);\r\n\r\n return;\r\n }\r\n\r\n if (disposed || ticket !== token) return;\r\n\r\n /*\r\n Shared state BEFORE the render that consumes it. `hydrateShared`\r\n installs the snapshot `useShared()` reads; swapping the tree first would\r\n render one frame of the new page against the previous page's shared\r\n state — locale, permissions, the current user.\r\n */\r\n hydrateShared(result.payload.shared);\r\n\r\n /*\r\n The fragment PUT BACK. `result.url` comes from `response.url`, and a\r\n fragment is never sent to a server, so the URL a navigation would\r\n otherwise be written to history from has had it stripped — which is how\r\n `<Link href=\"/docs#install\">` used to land on `/docs` with the author's\r\n fragment gone from the address bar for good.\r\n\r\n Applied on EVERY path, Back included: a popstate re-fetch that wrote\r\n `result.url` back would delete the fragment from an entry the user is\r\n merely returning to.\r\n */\r\n const finalUrl = withFragmentFrom(result.url, url);\r\n\r\n // History AFTER the fetch succeeded, never before. Pushing optimistically\r\n // would leave the address bar pointing at a page that then failed to\r\n // load, and a Back press would return to a URL the user never saw.\r\n if (replace) {\r\n window.history.replaceState(null, \"\", finalUrl);\r\n } else {\r\n window.history.pushState(null, \"\", finalUrl);\r\n }\r\n\r\n committedUrl = finalUrl;\r\n\r\n // Handed to the layout effect, which runs once React has committed the\r\n // tree below to the DOM — the first moment the target can exist. Set\r\n // unconditionally so a navigation with no fragment CLEARS a fragment left\r\n // pending by one that was superseded.\r\n pendingFragment.current = honourFragment ? fragmentOf(finalUrl) : undefined;\r\n\r\n // A navigation IS the route moving, so the fetched payload is both the\r\n // page and the route's identity.\r\n setCurrent({ payload: result.payload, tree, routeSource: result.payload });\r\n\r\n routerEvents.emitNavigated({ url, resolvedUrl: finalUrl, mode });\r\n };\r\n\r\n /*\r\n The same counter `apply` above takes its tickets from, handed to\r\n `refresh()` as a predicate. ONE mechanism, not two: a refresh and a\r\n navigation can overtake each other in either direction, and separate\r\n counters would leave each blind to the other.\r\n */\r\n const claimTicket = (): (() => boolean) => {\r\n const ticket = ++token;\r\n\r\n return () => !disposed && ticket === token;\r\n };\r\n\r\n const previousRefresher = connectRefresher(\r\n createRefresher({\r\n readCurrent: () => currentRef.current,\r\n writeCurrent: setCurrent,\r\n buildTree: (payload) => buildTree(pages, payload),\r\n claimTicket,\r\n }),\r\n );\r\n\r\n const previousNavigator = connectNavigator((url, options) => {\r\n const replace = options?.replace === true;\r\n\r\n /*\r\n THIS page with a fragment on it — `#reviews`, or the current path spelled\r\n out with one appended. No fetch, no tree swap: the page is already here,\r\n and re-fetching it would discard its DOM and everything live in it to\r\n arrive back where we started, one round trip later. Address bar first,\r\n then the jump, which is the order the browser uses for a plain anchor.\r\n */\r\n const fragment = samePageFragment(url, window.location.href);\r\n\r\n if (fragment !== undefined) {\r\n if (replace) {\r\n window.history.replaceState(null, \"\", url);\r\n } else {\r\n window.history.pushState(null, \"\", url);\r\n }\r\n\r\n committedUrl = window.location.href;\r\n\r\n // The target is in the DOM already, so there is nothing to wait for —\r\n // and nothing to hand to the layout effect, which no swap would fire.\r\n scrollToFragment(document, fragment);\r\n\r\n return true;\r\n }\r\n\r\n void apply(url, replace, true);\r\n\r\n // Accepted: the caller suppresses the browser's default. Returning `true`\r\n // before the fetch resolves is deliberate — the decision to handle a link\r\n // cannot wait on the network without the browser having already followed\r\n // it.\r\n return true;\r\n });\r\n\r\n /*\r\n Back/Forward. The entry is already in history and the URL has already\r\n changed by the time this fires, so the page is fetched and swapped with\r\n `replace` — pushing here would append a duplicate entry and make Back\r\n require two presses.\r\n */\r\n const onPopState = (): void => {\r\n const target = window.location.href;\r\n /*\r\n A hash-only move within one page — Back off a `#section` click, or\r\n Forward onto one. The document is the same document and the tree on\r\n screen is already the right tree, so there is nothing to fetch: the\r\n browser has changed the URL and restored the position for that entry\r\n itself, and re-fetching would throw away a live page to rebuild the one\r\n already showing. Scroll restoration stays the browser's (canon\r\n `0342c0d4`), which is exactly what leaving this alone means.\r\n */\r\n const hashOnlyMove = withoutFragment(target) === withoutFragment(committedUrl);\r\n\r\n committedUrl = target;\r\n\r\n if (hashOnlyMove) return;\r\n\r\n void apply(target, true, false);\r\n };\r\n\r\n window.addEventListener(\"popstate\", onPopState);\r\n\r\n return () => {\r\n disposed = true;\r\n window.removeEventListener(\"popstate\", onPopState);\r\n connectNavigator(previousNavigator);\r\n connectRefresher(previousRefresher);\r\n };\r\n }, [pages, buildTree]);\r\n\r\n /*\r\n THE OTHER HALF OF THE ORDERING PROBLEM (see `pendingFragment` above).\r\n\r\n `useLayoutEffect`, not `useEffect`, and the difference is the whole point:\r\n React runs a layout effect after it has COMMITTED this render to the DOM and\r\n BEFORE the browser paints. That is the earliest instant the new page's\r\n elements exist — a scroll any sooner finds nothing — and the last instant\r\n before the user sees anything, so the page is never painted at the top and\r\n then jumped. `useEffect` would satisfy the first requirement and not the\r\n second: it runs after paint, which is a visible flash of the wrong position.\r\n\r\n Keyed on `current` rather than reaching for a fresh render: the effect fires\r\n on the swap that put the target in the DOM, so no polling, no rAF, no\r\n timeout. What it CANNOT wait for is content that arrives later still — an\r\n image without dimensions above the target, a component that suspends — which\r\n moves the target after we have scrolled to where it was. That is the known\r\n limit of this mechanism and it is the same one a browser has.\r\n\r\n Consumed once: the fragment is cleared as it is read, so a later re-render\r\n (a refresh, a parent's state change) does not yank the page back to an\r\n anchor the user has since scrolled away from.\r\n */\r\n useLayoutEffect(() => {\r\n const fragment = pendingFragment.current;\r\n\r\n if (fragment === undefined) return;\r\n\r\n pendingFragment.current = undefined;\r\n\r\n scrollToFragment(document, fragment);\r\n }, [current]);\r\n\r\n /*\r\n The payload whose metadata `<head>` currently reflects. Seeded with the\r\n HYDRATION payload, which is the point: on the initial mount the head is the\r\n server's own render of this very metadata, so there is nothing to correct —\r\n and re-applying would be a chance to get it wrong, since a payload from a\r\n build with no `metadata` key would wipe a head the server filled in\r\n correctly. The head is applied on SWAPS only.\r\n */\r\n const appliedMetadataSource = useRef(current.payload);\r\n\r\n useEffect(() => {\r\n if (appliedMetadataSource.current === current.payload) return;\r\n\r\n appliedMetadataSource.current = current.payload;\r\n\r\n /*\r\n Keyed on the payload's identity, so this covers a refresh as well as a\r\n navigation: `refresh()` produces a NEW payload for the page already on\r\n screen, and a page whose metadata is a function of its loader data can\r\n legitimately re-title itself when that data changes. One applier at the\r\n one place the page changes, rather than a call in each pathway that could\r\n be forgotten in the next one.\r\n */\r\n applyDocumentMetadata(document, current.payload.metadata);\r\n }, [current.payload]);\r\n\r\n /*\r\n DURING RENDER, not in an effect, and that placement is the requirement\r\n rather than a shortcut. A page component calling `currentRoute()` does it\r\n while IT is rendering, and child effects run before a parent's, so anything\r\n recorded from an effect here would be recorded too late to answer the first\r\n render of the page it describes — which on the initial mount is the only\r\n render there has been, and the server's match is all there is.\r\n\r\n Recording is keyed on the payload's identity (`current-route.ts`), so the\r\n extra passes render gives us for free — StrictMode's double invoke, a parent\r\n re-render — are recognised as the same page rather than counted as\r\n navigations.\r\n\r\n `routeSource`, NOT `payload`: they are the same object for every navigation,\r\n and differ only after a refresh, which produces a new payload for the page\r\n already on screen and must not be counted as a move (see `refresh.ts`).\r\n */\r\n recordCurrentRoute(current.routeSource);\r\n\r\n /*\r\n An INNER DocumentContext provider, nested inside the one `hydratePage`\r\n mounted. That outer provider is created once with the hydration payload and\r\n never changes, so anything reading the document context after a navigation\r\n would see the payload of the page the user has left. The nearest provider\r\n wins, so this one keeps it current.\r\n */\r\n /*\r\n `metadata` from the PAYLOAD, not `undefined`. The document context is the\r\n universal shape — the server provides the resolved metadata around the same\r\n tree — so handing the client's readers `undefined` was a lie the payload can\r\n now correct. It also removes a latent mismatch: a `<Head/>` rendered inside\r\n a Layout (not the App level) renders its tags into `#root` server-side, and\r\n hydrating that markup against `metadata: undefined` produced a tree the\r\n server never rendered.\r\n */\r\n return (\r\n <DocumentContext.Provider\r\n value={{ metadata: current.payload.metadata, payload: current.payload }}\r\n >\r\n <LocaleProvider locale={current.payload.locale}>{current.tree}</LocaleProvider>\r\n </DocumentContext.Provider>\r\n );\r\n}\r\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scroll-to-fragment.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/scroll-to-fragment.ts"],"sourcesContent":["import { fragmentTargetId } from \"../../routing/url-fragment\";\n\n/**\n * Put the element a fragment names on screen — the browser's job, done by hand\n * because a client navigation never let the browser see the fragment.\n *\n * ## A fragment that matches nothing is NOT an error\n *\n * It does not throw, and — decided with it — it does not cause the fragment to\n * be dropped from the URL either. A browser loading `/docs#gone` shows\n * `#gone` in the address bar and leaves the page where it is; an id that has\n * not been added yet, or a page whose content moved, is an ordinary state of\n * the web and not something to report. So the return value says whether\n * anything was found, for a caller that wants to know, and every caller today\n * is free to ignore it.\n *\n * ## Why the document is an argument\n *\n * The suite runs with no DOM (`web/vitest.config.ts` — `environment: \"node\"`),\n * so a module that reached for the global `document` could only be proved in a\n * browser. The structural type below is satisfied by a real `Document` and by\n * three lines of test double, which is what makes the lookup rules provable at\n * all.\n */\n\n/** The two lookups, and nothing else this module needs from a `Document`. */\nexport type FragmentScrollDocument = {\n getElementById(id: string): { scrollIntoView(): void } | null;\n /**\n * The legacy anchor form, `<a name=\"install\">`, which browsers still honour\n * as a fragment target and older documentation pages are still full of.\n * Optional so a test double may leave it out.\n */\n getElementsByName?(name: string): ArrayLike<{ scrollIntoView(): void }>;\n};\n\n/**\n * @param fragment the fragment WITHOUT its leading `#`, as\n * `fragmentOf`/`samePageFragment` return it. Still percent-encoded — decoding\n * is this function's job, via `fragmentTargetId`.\n *\n * @returns whether a target was found and scrolled to.\n */\nexport function scrollToFragment(
|
|
1
|
+
{"version":3,"file":"scroll-to-fragment.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/scroll-to-fragment.ts"],"sourcesContent":["import { fragmentTargetId } from \"../../routing/url-fragment\";\n\n/**\n * Put the element a fragment names on screen — the browser's job, done by hand\n * because a client navigation never let the browser see the fragment.\n *\n * ## A fragment that matches nothing is NOT an error\n *\n * It does not throw, and — decided with it — it does not cause the fragment to\n * be dropped from the URL either. A browser loading `/docs#gone` shows\n * `#gone` in the address bar and leaves the page where it is; an id that has\n * not been added yet, or a page whose content moved, is an ordinary state of\n * the web and not something to report. So the return value says whether\n * anything was found, for a caller that wants to know, and every caller today\n * is free to ignore it.\n *\n * ## Why the document is an argument\n *\n * The suite runs with no DOM (`web/vitest.config.ts` — `environment: \"node\"`),\n * so a module that reached for the global `document` could only be proved in a\n * browser. The structural type below is satisfied by a real `Document` and by\n * three lines of test double, which is what makes the lookup rules provable at\n * all.\n */\n\n/** The two lookups, and nothing else this module needs from a `Document`. */\nexport type FragmentScrollDocument = {\n getElementById(id: string): { scrollIntoView(): void } | null;\n /**\n * The legacy anchor form, `<a name=\"install\">`, which browsers still honour\n * as a fragment target and older documentation pages are still full of.\n * Optional so a test double may leave it out.\n */\n getElementsByName?(name: string): ArrayLike<{ scrollIntoView(): void }>;\n};\n\n/**\n * @param fragment the fragment WITHOUT its leading `#`, as\n * `fragmentOf`/`samePageFragment` return it. Still percent-encoded — decoding\n * is this function's job, via `fragmentTargetId`.\n *\n * @returns whether a target was found and scrolled to.\n */\nexport function scrollToFragment(documentNode: FragmentScrollDocument, fragment: string): boolean {\n // `/docs#` names no target. Nothing to look up, and nothing to move.\n if (fragment === \"\") return false;\n\n const id = fragmentTargetId(fragment);\n\n /*\n `getElementById`, never `querySelector(\"#\" + id)`. An id is allowed to\n contain characters that are not valid in a CSS selector — `#1`, `#a.b`,\n `#a b` — and `querySelector` THROWS on those rather than missing them, which\n would turn a link to a legal id into an exception in the middle of a\n navigation.\n */\n const target = documentNode.getElementById(id) ?? namedAnchor(documentNode, id);\n\n if (target === undefined || target === null) return false;\n\n target.scrollIntoView();\n\n return true;\n}\n\nfunction namedAnchor(\n documentNode: FragmentScrollDocument,\n name: string,\n): { scrollIntoView(): void } | undefined {\n const matches = documentNode.getElementsByName?.(name);\n\n return matches === undefined || matches.length === 0 ? undefined : matches[0];\n}\n"],"mappings":";;;;;;;;;;AA2CA,SAAgB,iBAAiB,cAAsC,UAA2B;CAEhG,IAAI,aAAa,IAAI,OAAO;CAE5B,MAAM,KAAK,iBAAiB,QAAQ;CASpC,MAAM,SAAS,aAAa,eAAe,EAAE,KAAK,YAAY,cAAc,EAAE;CAE9E,IAAI,WAAW,UAAa,WAAW,MAAM,OAAO;CAEpD,OAAO,eAAe;CAEtB,OAAO;AACT;AAEA,SAAS,YACP,cACA,MACwC;CACxC,MAAM,UAAU,aAAa,oBAAoB,IAAI;CAErD,OAAO,YAAY,UAAa,QAAQ,WAAW,IAAI,SAAY,QAAQ;AAC7E"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { QueryStringValue } from "../../routing/query-string.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../web/src/client/navigation/use-query-string.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* One value from the current query string, live across client navigation.
|
|
6
|
+
*
|
|
7
|
+
* @param key the query key to read.
|
|
8
|
+
*
|
|
9
|
+
* @returns the decoded value at `key` — a string, an array (`key[]`) or a
|
|
10
|
+
* one-level bag (`key[sub]`), the same three shapes `queryString.parse`
|
|
11
|
+
* produces. **Absent is `undefined`, never `""`**: an absent key and a
|
|
12
|
+
* present-but-empty one (`?q=`, which parses to `""`) are different values,
|
|
13
|
+
* and returning `""` for both would erase that difference for every caller.
|
|
14
|
+
* There is no default-value parameter for the same reason — the caller
|
|
15
|
+
* decides what "absent" means for its own UI, this hook only reports it.
|
|
16
|
+
*
|
|
17
|
+
* **SSR**: during a server render, this reads the search string of the
|
|
18
|
+
* REQUEST being rendered (via `connectRequestSearch`,
|
|
19
|
+
* `routing/query-string.ts`), not `undefined` and not a thrown error, so the
|
|
20
|
+
* value the server sends down is the value the first client render computes
|
|
21
|
+
* too — the two renders match and hydration does not mismatch. Before the
|
|
22
|
+
* pipeline connects that resolver, the server side reads as `""` (no query),
|
|
23
|
+
* matching `browserSearch()`'s own "nothing to report yet" answer outside a
|
|
24
|
+
* document.
|
|
25
|
+
*
|
|
26
|
+
* Re-renders the calling component when a client navigation completes —
|
|
27
|
+
* `<Link>`, `navigateTo()`, or Back/Forward — and the query string it reads
|
|
28
|
+
* changed as a result. Safe to call during a server render and before
|
|
29
|
+
* hydration; there is nothing to subscribe to in either case and the value is
|
|
30
|
+
* simply read once.
|
|
31
|
+
*/
|
|
32
|
+
declare function useQueryString(key: string): QueryStringValue | undefined;
|
|
33
|
+
//#endregion
|
|
34
|
+
export { useQueryString };
|
|
35
|
+
//# sourceMappingURL=use-query-string.d.mts.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { currentSearch, queryString } from "../../routing/query-string.mjs";
|
|
2
|
+
import { routerEvents } from "../../routing/router-events.mjs";
|
|
3
|
+
import { useCallback, useRef, useSyncExternalStore } from "react";
|
|
4
|
+
|
|
5
|
+
//#region ../web/src/client/navigation/use-query-string.ts
|
|
6
|
+
/**
|
|
7
|
+
* The snapshot read, extracted from the hook so it is callable — and its
|
|
8
|
+
* caching provably correct — with no React renderer (`refresh.spec.ts`
|
|
9
|
+
* records why this package's test suite cannot mount one: `node` environment,
|
|
10
|
+
* no DOM dependency, so a hook's effects never run).
|
|
11
|
+
*
|
|
12
|
+
* Cached by the raw search string, not recomputed on every call: `decode()`
|
|
13
|
+
* (via `queryString.parse`) builds a fresh object each time it runs, and
|
|
14
|
+
* `useSyncExternalStore` requires a snapshot function that returns the SAME
|
|
15
|
+
* reference for an unchanged store — a fresh object every call is an infinite
|
|
16
|
+
* re-render loop the moment `key` names an array or a bag.
|
|
17
|
+
*/
|
|
18
|
+
function readQueryStringValue(key, cache) {
|
|
19
|
+
const search = currentSearch();
|
|
20
|
+
const cached = cache.current;
|
|
21
|
+
const all = cached !== void 0 && cached.search === search ? cached.all : queryString.parse(search);
|
|
22
|
+
if (cached === void 0 || cached.search !== search) cache.current = {
|
|
23
|
+
search,
|
|
24
|
+
all
|
|
25
|
+
};
|
|
26
|
+
return key in all ? all[key] : void 0;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* One value from the current query string, live across client navigation.
|
|
30
|
+
*
|
|
31
|
+
* @param key the query key to read.
|
|
32
|
+
*
|
|
33
|
+
* @returns the decoded value at `key` — a string, an array (`key[]`) or a
|
|
34
|
+
* one-level bag (`key[sub]`), the same three shapes `queryString.parse`
|
|
35
|
+
* produces. **Absent is `undefined`, never `""`**: an absent key and a
|
|
36
|
+
* present-but-empty one (`?q=`, which parses to `""`) are different values,
|
|
37
|
+
* and returning `""` for both would erase that difference for every caller.
|
|
38
|
+
* There is no default-value parameter for the same reason — the caller
|
|
39
|
+
* decides what "absent" means for its own UI, this hook only reports it.
|
|
40
|
+
*
|
|
41
|
+
* **SSR**: during a server render, this reads the search string of the
|
|
42
|
+
* REQUEST being rendered (via `connectRequestSearch`,
|
|
43
|
+
* `routing/query-string.ts`), not `undefined` and not a thrown error, so the
|
|
44
|
+
* value the server sends down is the value the first client render computes
|
|
45
|
+
* too — the two renders match and hydration does not mismatch. Before the
|
|
46
|
+
* pipeline connects that resolver, the server side reads as `""` (no query),
|
|
47
|
+
* matching `browserSearch()`'s own "nothing to report yet" answer outside a
|
|
48
|
+
* document.
|
|
49
|
+
*
|
|
50
|
+
* Re-renders the calling component when a client navigation completes —
|
|
51
|
+
* `<Link>`, `navigateTo()`, or Back/Forward — and the query string it reads
|
|
52
|
+
* changed as a result. Safe to call during a server render and before
|
|
53
|
+
* hydration; there is nothing to subscribe to in either case and the value is
|
|
54
|
+
* simply read once.
|
|
55
|
+
*/
|
|
56
|
+
function useQueryString(key) {
|
|
57
|
+
const cache = useRef(void 0);
|
|
58
|
+
const getSnapshot = useCallback(() => {
|
|
59
|
+
return readQueryStringValue(key, cache);
|
|
60
|
+
}, [key]);
|
|
61
|
+
return useSyncExternalStore(useCallback((onStoreChange) => {
|
|
62
|
+
return routerEvents.onNavigated(onStoreChange);
|
|
63
|
+
}, []), getSnapshot, getSnapshot);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
//#endregion
|
|
67
|
+
export { useQueryString };
|
|
68
|
+
//# sourceMappingURL=use-query-string.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-query-string.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/use-query-string.ts"],"sourcesContent":["import { useCallback, useRef, useSyncExternalStore } from \"react\";\nimport {\n currentSearch,\n queryString,\n type QueryStringObject,\n type QueryStringValue,\n} from \"../../routing/query-string\";\nimport { routerEvents } from \"../../routing/router-events\";\n\n/**\n * The client half of the query-string ruling (canon `f2e514c0`): `shared` is\n * an SSR-time snapshot that silently disagrees with `location.search` after a\n * client `Link` navigation, so both sides were told to read the URL instead —\n * `queryStringOf` (`routing/query-string.ts`) is the server's half, this is\n * the browser's.\n *\n * ## Why re-reading `location.search` is not enough on its own\n *\n * A `Link` navigation is not a browser history event from React's point of\n * view — no `popstate` fires, `navigation-root.tsx`'s `apply()` moves the URL\n * with `pushState`/`replaceState` directly — so a component that reads the\n * query string once and never again would render a stale value for the rest\n * of the session the moment a `<Link>` changed only the query string. This\n * hook subscribes to `routerEvents.onNavigated` (`routing/router-events.ts`),\n * the framework's OWN navigation lifecycle, which `navigation-root.tsx` fires\n * for both a `<Link>` navigation and Back/Forward — the one signal that is\n * live for everything that can move the query string, `popstate` included.\n *\n * ## Parity with `queryStringOf`\n *\n * Reuses `queryString.parse`, not a second parser: it is universal-safe (its\n * own doc — \"safe to import and to CALL with no DOM\"), so re-implementing it\n * here would be exactly the two-independently-written-query-string-parsers\n * defect `routing/query-string.ts`'s header exists to prevent (canon\n * `1ca1e8ae`'s shape), applied to reading instead of writing.\n */\n\n/**\n * What {@link readQueryStringValue} caches its last parse in — the exact shape\n * a `useRef` holds, so the hook can hand its ref straight through with no\n * cast.\n */\nexport type QueryStringCache = { current: { search: string; all: QueryStringObject } | undefined };\n\n/**\n * The snapshot read, extracted from the hook so it is callable — and its\n * caching provably correct — with no React renderer (`refresh.spec.ts`\n * records why this package's test suite cannot mount one: `node` environment,\n * no DOM dependency, so a hook's effects never run).\n *\n * Cached by the raw search string, not recomputed on every call: `decode()`\n * (via `queryString.parse`) builds a fresh object each time it runs, and\n * `useSyncExternalStore` requires a snapshot function that returns the SAME\n * reference for an unchanged store — a fresh object every call is an infinite\n * re-render loop the moment `key` names an array or a bag.\n */\nexport function readQueryStringValue(\n key: string,\n cache: QueryStringCache,\n): QueryStringValue | undefined {\n const search = currentSearch();\n const cached = cache.current;\n const all =\n cached !== undefined && cached.search === search ? cached.all : queryString.parse(search);\n\n if (cached === undefined || cached.search !== search) {\n cache.current = { search, all };\n }\n\n return key in all ? all[key] : undefined;\n}\n\n/**\n * One value from the current query string, live across client navigation.\n *\n * @param key the query key to read.\n *\n * @returns the decoded value at `key` — a string, an array (`key[]`) or a\n * one-level bag (`key[sub]`), the same three shapes `queryString.parse`\n * produces. **Absent is `undefined`, never `\"\"`**: an absent key and a\n * present-but-empty one (`?q=`, which parses to `\"\"`) are different values,\n * and returning `\"\"` for both would erase that difference for every caller.\n * There is no default-value parameter for the same reason — the caller\n * decides what \"absent\" means for its own UI, this hook only reports it.\n *\n * **SSR**: during a server render, this reads the search string of the\n * REQUEST being rendered (via `connectRequestSearch`,\n * `routing/query-string.ts`), not `undefined` and not a thrown error, so the\n * value the server sends down is the value the first client render computes\n * too — the two renders match and hydration does not mismatch. Before the\n * pipeline connects that resolver, the server side reads as `\"\"` (no query),\n * matching `browserSearch()`'s own \"nothing to report yet\" answer outside a\n * document.\n *\n * Re-renders the calling component when a client navigation completes —\n * `<Link>`, `navigateTo()`, or Back/Forward — and the query string it reads\n * changed as a result. Safe to call during a server render and before\n * hydration; there is nothing to subscribe to in either case and the value is\n * simply read once.\n */\nexport function useQueryString(key: string): QueryStringValue | undefined {\n const cache: QueryStringCache = useRef<QueryStringCache[\"current\"]>(undefined);\n\n const getSnapshot = useCallback((): QueryStringValue | undefined => {\n return readQueryStringValue(key, cache);\n }, [key]);\n\n const subscribe = useCallback((onStoreChange: () => void): (() => void) => {\n return routerEvents.onNavigated(onStoreChange);\n }, []);\n\n // The same function for both arguments: `readQueryStringValue` already\n // branches on `currentSearch()`'s own `typeof window` check, so there is no\n // separate server answer to compute — passing it twice is what tells React\n // the hydration render and the server render must agree.\n return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAwDA,SAAgB,qBACd,KACA,OAC8B;CAC9B,MAAM,SAAS,cAAc;CAC7B,MAAM,SAAS,MAAM;CACrB,MAAM,MACJ,WAAW,UAAa,OAAO,WAAW,SAAS,OAAO,MAAM,YAAY,MAAM,MAAM;CAE1F,IAAI,WAAW,UAAa,OAAO,WAAW,QAC5C,MAAM,UAAU;EAAE;EAAQ;CAAI;CAGhC,OAAO,OAAO,MAAM,IAAI,OAAO;AACjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAgB,eAAe,KAA2C;CACxE,MAAM,QAA0B,OAAoC,MAAS;CAE7E,MAAM,cAAc,kBAAgD;EAClE,OAAO,qBAAqB,KAAK,KAAK;CACxC,GAAG,CAAC,GAAG,CAAC;CAUR,OAAO,qBARW,aAAa,kBAA4C;EACzE,OAAO,aAAa,YAAY,aAAa;CAC/C,GAAG,CAAC,CAMgC,GAAG,aAAa,WAAW;AACjE"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { registerModules } from "../../
|
|
1
|
+
import { registerModules } from "../../register-modules.mjs";
|
|
2
2
|
import { ClientPageEntry, ClientProjectedModule, ClientRouteComposition, ClientRouteLoad, ClientRouteMatch, ClientRouteParams } from "./types.mjs";
|
|
3
3
|
import { loadClientRouteComposition, validateClientRouteManifest } from "./manifest.mjs";
|
|
4
4
|
import { matchClientRoute } from "./matcher.mjs";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { registerModules } from "../../
|
|
1
|
+
import { registerModules } from "../../register-modules.mjs";
|
|
2
2
|
import { loadClientRouteComposition, validateClientRouteManifest } from "./manifest.mjs";
|
|
3
3
|
import { matchClientRoute } from "./matcher.mjs";
|
|
4
4
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest.mjs","names":[],"sources":["../../../../../../../../web/src/client/runtime/manifest.ts"],"sourcesContent":["import type {
|
|
1
|
+
{"version":3,"file":"manifest.mjs","names":[],"sources":["../../../../../../../../web/src/client/runtime/manifest.ts"],"sourcesContent":["import type { ClientPageEntry, ClientProjectedModule, ClientRouteComposition } 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(input, COMPOSITION_REQUIRED_KEYS, COMPOSITION_OPTIONAL_KEYS, label);\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 +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\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
|
+
{"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(\"/\") ? original.slice(0, -1) : original;\n const segments = isExactRootCatchAll ? [\"*\"] : pattern === \"/\" ? [] : 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 (\n segment.includes(\":\") ||\n segment.includes(\"*\") ||\n segment.includes(\"?\") ||\n segment.includes(\"%\")\n ) {\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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"}
|