@warlock.js/web 5.0.2 → 5.2.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.
Files changed (219) hide show
  1. package/CHANGELOG.md +250 -0
  2. package/README.md +126 -12
  3. package/esm/build/contribution.d.mts +11 -4
  4. package/esm/build/contribution.mjs +46 -22
  5. package/esm/build/contribution.mjs.map +1 -1
  6. package/esm/build/discover-pages.mjs +318 -44
  7. package/esm/build/discover-pages.mjs.map +1 -1
  8. package/esm/build/generate-client-registry.mjs +47 -13
  9. package/esm/build/generate-client-registry.mjs.map +1 -1
  10. package/esm/build/generate-pages-barrel.mjs +55 -13
  11. package/esm/build/generate-pages-barrel.mjs.map +1 -1
  12. package/esm/build/page-default-export.mjs +48 -0
  13. package/esm/build/page-default-export.mjs.map +1 -0
  14. package/esm/build/page-routes-manifest.mjs +21 -0
  15. package/esm/build/page-routes-manifest.mjs.map +1 -0
  16. package/esm/build/public-files.mjs +45 -0
  17. package/esm/build/public-files.mjs.map +1 -0
  18. package/esm/build/read-route-exports.mjs +1 -1
  19. package/esm/build/read-route-exports.mjs.map +1 -1
  20. package/esm/client/build-hydrated-tree.mjs +40 -9
  21. package/esm/client/build-hydrated-tree.mjs.map +1 -1
  22. package/esm/client/hydrate-page.mjs +1 -1
  23. package/esm/client/hydrate-page.mjs.map +1 -1
  24. package/esm/client/navigation/current-route.d.mts +1 -1
  25. package/esm/client/navigation/current-route.mjs +1 -1
  26. package/esm/client/navigation/current-route.mjs.map +1 -1
  27. package/esm/client/navigation/fetch-page-data.mjs +1 -1
  28. package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
  29. package/esm/client/navigation/navigation-root.mjs +44 -7
  30. package/esm/client/navigation/navigation-root.mjs.map +1 -1
  31. package/esm/client/navigation/prefetch.mjs +1 -1
  32. package/esm/client/navigation/prefetch.mjs.map +1 -1
  33. package/esm/client/navigation/refresh.d.mts +1 -1
  34. package/esm/client/navigation/refresh.mjs +1 -1
  35. package/esm/client/navigation/refresh.mjs.map +1 -1
  36. package/esm/client/navigation/scroll-to-fragment.mjs +26 -0
  37. package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -0
  38. package/esm/client/navigation/verbs.d.mts +1 -1
  39. package/esm/client/navigation/verbs.mjs +1 -1
  40. package/esm/client/navigation/verbs.mjs.map +1 -1
  41. package/esm/client/runtime/index.d.mts +2 -1
  42. package/esm/client/runtime/index.mjs +2 -1
  43. package/esm/client/runtime/manifest.d.mts +1 -1
  44. package/esm/client/runtime/manifest.mjs +3 -2
  45. package/esm/client/runtime/manifest.mjs.map +1 -1
  46. package/esm/client/runtime/matcher.d.mts +13 -1
  47. package/esm/client/runtime/matcher.mjs +13 -1
  48. package/esm/client/runtime/matcher.mjs.map +1 -1
  49. package/esm/client/runtime/types.d.mts +15 -2
  50. package/esm/components/default-app.mjs +1 -1
  51. package/esm/components/default-app.mjs.map +1 -1
  52. package/esm/components/document-context.d.mts +40 -2
  53. package/esm/components/document-context.mjs +1 -1
  54. package/esm/components/document-context.mjs.map +1 -1
  55. package/esm/components/head.d.mts +1 -1
  56. package/esm/components/head.mjs +1 -1
  57. package/esm/components/head.mjs.map +1 -1
  58. package/esm/components/link.d.mts +1 -1
  59. package/esm/components/link.mjs +1 -1
  60. package/esm/components/link.mjs.map +1 -1
  61. package/esm/components/scripts.d.mts +1 -1
  62. package/esm/components/scripts.mjs +4 -2
  63. package/esm/components/scripts.mjs.map +1 -1
  64. package/esm/context.d.mts +6 -102
  65. package/esm/hydration/index.mjs +1 -1
  66. package/esm/hydration/index.mjs.map +1 -1
  67. package/esm/hydration-payload.d.mts +1 -1
  68. package/esm/hydration-payload.mjs +37 -8
  69. package/esm/hydration-payload.mjs.map +1 -1
  70. package/esm/index.d.mts +4 -3
  71. package/esm/loaders.d.mts +7 -36
  72. package/esm/metadata.d.mts +15 -1
  73. package/esm/metadata.mjs +45 -0
  74. package/esm/metadata.mjs.map +1 -0
  75. package/esm/props.d.mts +16 -8
  76. package/esm/route.d.mts +2 -17
  77. package/esm/routing/compose-route-path.d.mts +1 -1
  78. package/esm/routing/compose-route-path.mjs +1 -1
  79. package/esm/routing/compose-route-path.mjs.map +1 -1
  80. package/esm/routing/data-request.mjs +1 -1
  81. package/esm/routing/data-request.mjs.map +1 -1
  82. package/esm/routing/filesystem-route.mjs +45 -0
  83. package/esm/routing/filesystem-route.mjs.map +1 -0
  84. package/esm/routing/layout-policy.mjs +1 -1
  85. package/esm/routing/layout-policy.mjs.map +1 -1
  86. package/esm/routing/navigator.mjs +1 -1
  87. package/esm/routing/navigator.mjs.map +1 -1
  88. package/esm/routing/query-string.d.mts +1 -1
  89. package/esm/routing/query-string.mjs +1 -1
  90. package/esm/routing/query-string.mjs.map +1 -1
  91. package/esm/routing/route-identity.mjs +1 -1
  92. package/esm/routing/route-identity.mjs.map +1 -1
  93. package/esm/routing/route-table.d.mts +1 -1
  94. package/esm/routing/route-table.mjs +1 -1
  95. package/esm/routing/route-table.mjs.map +1 -1
  96. package/esm/routing/router-events.d.mts +1 -1
  97. package/esm/routing/router-events.mjs +1 -1
  98. package/esm/routing/router-events.mjs.map +1 -1
  99. package/esm/routing/url-fragment.mjs +120 -0
  100. package/esm/routing/url-fragment.mjs.map +1 -0
  101. package/esm/runtime/register-modules.d.mts +19 -0
  102. package/esm/runtime/register-modules.mjs +22 -0
  103. package/esm/runtime/register-modules.mjs.map +1 -0
  104. package/esm/server/build-hydration-payload.mjs +2 -1
  105. package/esm/server/build-hydration-payload.mjs.map +1 -1
  106. package/esm/server/client-asset-url-prefix.d.mts +1 -1
  107. package/esm/server/client-asset-url-prefix.mjs +1 -1
  108. package/esm/server/client-asset-url-prefix.mjs.map +1 -1
  109. package/esm/server/create-page-module-loader.d.mts +1 -1
  110. package/esm/server/create-page-module-loader.mjs +2 -1
  111. package/esm/server/create-page-module-loader.mjs.map +1 -1
  112. package/esm/server/create-page-route-handler.d.mts +58 -6
  113. package/esm/server/create-page-route-handler.mjs +127 -40
  114. package/esm/server/create-page-route-handler.mjs.map +1 -1
  115. package/esm/server/dev-server.mjs +2 -13
  116. package/esm/server/dev-server.mjs.map +1 -1
  117. package/esm/server/error-page.d.mts +14 -0
  118. package/esm/server/error-page.mjs +39 -0
  119. package/esm/server/error-page.mjs.map +1 -0
  120. package/esm/server/execute-page-request.d.mts +7 -4
  121. package/esm/server/execute-page-request.mjs +79 -80
  122. package/esm/server/execute-page-request.mjs.map +1 -1
  123. package/esm/server/execute-page-request.types.d.mts +33 -92
  124. package/esm/server/hydration-client-url.d.mts +1 -1
  125. package/esm/server/hydration-client-url.mjs +2 -2
  126. package/esm/server/hydration-client-url.mjs.map +1 -1
  127. package/esm/server/index.d.mts +6 -5
  128. package/esm/server/index.mjs +4 -4
  129. package/esm/server/install-page-routes-from-manifest.d.mts +17 -7
  130. package/esm/server/install-page-routes-from-manifest.mjs +91 -8
  131. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
  132. package/esm/server/install-page-routes.d.mts +32 -13
  133. package/esm/server/install-page-routes.mjs +112 -32
  134. package/esm/server/install-page-routes.mjs.map +1 -1
  135. package/esm/server/install-production-page-routes.mjs +3 -31
  136. package/esm/server/install-production-page-routes.mjs.map +1 -1
  137. package/esm/server/match-page-route.mjs +2 -2
  138. package/esm/server/match-page-route.mjs.map +1 -1
  139. package/esm/server/not-found-page.d.mts +126 -0
  140. package/esm/server/not-found-page.mjs +157 -0
  141. package/esm/server/not-found-page.mjs.map +1 -0
  142. package/esm/server/page-context.d.mts +3 -9
  143. package/esm/server/page-context.mjs +3 -16
  144. package/esm/server/page-context.mjs.map +1 -1
  145. package/esm/server/page-file-change.mjs +77 -0
  146. package/esm/server/page-file-change.mjs.map +1 -0
  147. package/esm/server/page-manifest.d.mts +25 -10
  148. package/esm/server/page-manifest.mjs +1 -1
  149. package/esm/server/page-manifest.mjs.map +1 -1
  150. package/esm/server/page-render-bundle.mjs +38 -0
  151. package/esm/server/page-render-bundle.mjs.map +1 -0
  152. package/esm/server/page-route-reload.mjs +54 -0
  153. package/esm/server/page-route-reload.mjs.map +1 -0
  154. package/esm/server/register-production-public-files.mjs +40 -0
  155. package/esm/server/register-production-public-files.mjs.map +1 -0
  156. package/esm/server/render-page.d.mts +11 -14
  157. package/esm/server/render-page.mjs +154 -28
  158. package/esm/server/render-page.mjs.map +1 -1
  159. package/esm/server/resolve-page-metadata.mjs +2 -2
  160. package/esm/server/resolve-page-metadata.mjs.map +1 -1
  161. package/esm/server/resolve-validation-data.mjs +1 -1
  162. package/esm/server/resolve-validation-data.mjs.map +1 -1
  163. package/esm/server/settle-page-response.d.mts +58 -1
  164. package/esm/server/settle-page-response.mjs +111 -46
  165. package/esm/server/settle-page-response.mjs.map +1 -1
  166. package/esm/server/stylesheet-urls.d.mts +41 -30
  167. package/esm/server/stylesheet-urls.mjs +137 -52
  168. package/esm/server/stylesheet-urls.mjs.map +1 -1
  169. package/esm/server/unregistered-pages.mjs +42 -0
  170. package/esm/server/unregistered-pages.mjs.map +1 -0
  171. package/esm/server/web-connector-factory.d.mts +2 -3
  172. package/esm/server/web-connector-factory.mjs +6 -4
  173. package/esm/server/web-connector-factory.mjs.map +1 -1
  174. package/esm/server/web-connector.d.mts +1 -1
  175. package/esm/server/web-connector.mjs +233 -18
  176. package/esm/server/web-connector.mjs.map +1 -1
  177. package/esm/shared.d.mts +18 -3
  178. package/esm/shared.mjs +3 -7
  179. package/esm/shared.mjs.map +1 -1
  180. package/esm/validation.d.mts +1 -1
  181. package/esm/vite/app-convention-aliases.mjs +1 -1
  182. package/esm/vite/app-convention-aliases.mjs.map +1 -1
  183. package/esm/vite/build-client.d.mts +1 -1
  184. package/esm/vite/build-client.mjs +2 -2
  185. package/esm/vite/build-client.mjs.map +1 -1
  186. package/esm/vite/gate-a-resolve.d.mts +1 -1
  187. package/esm/vite/gate-a-resolve.mjs +67 -13
  188. package/esm/vite/gate-a-resolve.mjs.map +1 -1
  189. package/esm/vite/gate-b-secrets.d.mts +1 -1
  190. package/esm/vite/gate-b-secrets.mjs +35 -17
  191. package/esm/vite/gate-b-secrets.mjs.map +1 -1
  192. package/esm/vite/gate-c-verify.d.mts +1 -1
  193. package/esm/vite/gate-c-verify.mjs +6 -6
  194. package/esm/vite/gate-c-verify.mjs.map +1 -1
  195. package/esm/vite/hydration-entries.d.mts +1 -1
  196. package/esm/vite/hydration-entries.mjs +10 -6
  197. package/esm/vite/hydration-entries.mjs.map +1 -1
  198. package/esm/vite/index.d.mts +9 -6
  199. package/esm/vite/index.mjs +124 -11
  200. package/esm/vite/index.mjs.map +1 -1
  201. package/esm/vite/page-registry-plugin.d.mts +22 -3
  202. package/esm/vite/page-registry-plugin.mjs +247 -2
  203. package/esm/vite/page-registry-plugin.mjs.map +1 -1
  204. package/esm/vite/projection.d.mts +2 -2
  205. package/esm/vite/projection.mjs +38 -15
  206. package/esm/vite/projection.mjs.map +1 -1
  207. package/llms-full.txt +241 -49
  208. package/llms.txt +6 -2
  209. package/package.json +3 -3
  210. package/skills/add-web-to-an-app/SKILL.md +3 -3
  211. package/skills/create-a-page/SKILL.md +130 -7
  212. package/skills/load-page-data/SKILL.md +92 -12
  213. package/skills/navigate-on-the-client/SKILL.md +3 -1
  214. package/skills/serve-styles/SKILL.md +13 -12
  215. package/skills/use-layouts/SKILL.md +20 -8
  216. package/skills/write-the-root/SKILL.md +5 -1
  217. package/esm/server/buffered-response.d.mts +0 -58
  218. package/esm/server/buffered-response.mjs +0 -115
  219. package/esm/server/buffered-response.mjs.map +0 -1
@@ -0,0 +1,48 @@
1
+ import { parse } from "@babel/parser";
2
+
3
+ //#region web/src/build/page-default-export.ts
4
+ /** Raised when a `*.page.tsx` module cannot render a page component. */
5
+ var MissingPageDefaultExportError = class extends Error {
6
+ pageFile;
7
+ constructor(pageFile) {
8
+ super(`The page "${pageFile}" has no runtime default export. Every \`*.page.tsx\` file must default-export the React component it renders. For example: \`export default function Page() { return <main />; }\``);
9
+ this.pageFile = pageFile;
10
+ this.name = "MissingPageDefaultExportError";
11
+ }
12
+ };
13
+ /**
14
+ * Refuses a page that only has named or type exports.
15
+ *
16
+ * This is static on purpose: discovery runs in both the dev registry and the
17
+ * production barrel generator, before either consumer imports application
18
+ * code. Re-exporting a runtime binding as `default` is valid ES module syntax
19
+ * and therefore satisfies the same contract as `export default`.
20
+ */
21
+ function assertPageHasDefaultExport(pageFile, source) {
22
+ let program;
23
+ try {
24
+ program = parse(source, {
25
+ sourceType: "module",
26
+ plugins: ["typescript", "jsx"],
27
+ errorRecovery: false
28
+ }).program;
29
+ } catch (error) {
30
+ throw new Error(`Cannot inspect the default export of "${pageFile}": the file could not be parsed (${error.message}). Fix the syntax error and discovery will continue.`);
31
+ }
32
+ for (const statement of program.body) {
33
+ if (statement.type === "ExportDefaultDeclaration") {
34
+ if (statement.declaration.type !== "TSInterfaceDeclaration") return;
35
+ continue;
36
+ }
37
+ if (statement.type !== "ExportNamedDeclaration" || statement.exportKind === "type") continue;
38
+ for (const specifier of statement.specifiers) {
39
+ if (!("exported" in specifier) || specifier.type === "ExportSpecifier" && specifier.exportKind === "type") continue;
40
+ if ((specifier.exported.type === "Identifier" ? specifier.exported.name : specifier.exported.value) === "default") return;
41
+ }
42
+ }
43
+ throw new MissingPageDefaultExportError(pageFile);
44
+ }
45
+
46
+ //#endregion
47
+ export { assertPageHasDefaultExport };
48
+ //# sourceMappingURL=page-default-export.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-default-export.mjs","names":[],"sources":["C:/Work/Warlock.js/web/src/build/page-default-export.ts"],"sourcesContent":["import { parse } from \"@babel/parser\";\n\n/** Raised when a `*.page.tsx` module cannot render a page component. */\nexport class MissingPageDefaultExportError extends Error {\n public constructor(public readonly pageFile: string) {\n super(\n `The page \"${pageFile}\" has no runtime default export. Every \\`*.page.tsx\\` file must ` +\n \"default-export the React component it renders. For example: \" +\n \"`export default function Page() { return <main />; }`\",\n );\n this.name = \"MissingPageDefaultExportError\";\n }\n}\n\n/**\n * Refuses a page that only has named or type exports.\n *\n * This is static on purpose: discovery runs in both the dev registry and the\n * production barrel generator, before either consumer imports application\n * code. Re-exporting a runtime binding as `default` is valid ES module syntax\n * and therefore satisfies the same contract as `export default`.\n */\nexport function assertPageHasDefaultExport(pageFile: string, source: string): void {\n let program: ReturnType<typeof parse>[\"program\"];\n\n try {\n program = parse(source, {\n sourceType: \"module\",\n plugins: [\"typescript\", \"jsx\"],\n errorRecovery: false,\n }).program;\n } catch (error) {\n throw new Error(\n `Cannot inspect the default export of \"${pageFile}\": the file could not be parsed ` +\n `(${(error as Error).message}). Fix the syntax error and discovery will continue.`,\n );\n }\n\n for (const statement of program.body) {\n if (statement.type === \"ExportDefaultDeclaration\") {\n // `export default interface Page {}` is erased by TypeScript and leaves\n // no runtime component for either SSR or hydration to render.\n if ((statement.declaration as { type: string }).type !== \"TSInterfaceDeclaration\") return;\n continue;\n }\n\n if (statement.type !== \"ExportNamedDeclaration\" || statement.exportKind === \"type\") continue;\n\n for (const specifier of statement.specifiers) {\n if (\n !(\"exported\" in specifier) ||\n (specifier.type === \"ExportSpecifier\" && specifier.exportKind === \"type\")\n ) {\n continue;\n }\n\n const exported =\n specifier.exported.type === \"Identifier\"\n ? specifier.exported.name\n : specifier.exported.value;\n\n if (exported === \"default\") return;\n }\n }\n\n throw new MissingPageDefaultExportError(pageFile);\n}\n"],"mappings":";;;;AAGA,IAAa,gCAAb,cAAmD,MAAM;CACpB;CAAnC,AAAO,YAAY,AAAgB,UAAkB;EACnD,MACE,aAAa,SAAS,oLAGxB;EALiC;EAMjC,KAAK,OAAO;CACd;AACF;;;;;;;;;AAUA,SAAgB,2BAA2B,UAAkB,QAAsB;CACjF,IAAI;CAEJ,IAAI;EACF,UAAU,MAAM,QAAQ;GACtB,YAAY;GACZ,SAAS,CAAC,cAAc,KAAK;GAC7B,eAAe;EACjB,CAAC,EAAE;CACL,SAAS,OAAO;EACd,MAAM,IAAI,MACR,yCAAyC,SAAS,mCAC3C,MAAgB,QAAQ,qDACjC;CACF;CAEA,KAAK,MAAM,aAAa,QAAQ,MAAM;EACpC,IAAI,UAAU,SAAS,4BAA4B;GAGjD,IAAK,UAAU,YAAiC,SAAS,0BAA0B;GACnF;EACF;EAEA,IAAI,UAAU,SAAS,4BAA4B,UAAU,eAAe,QAAQ;EAEpF,KAAK,MAAM,aAAa,UAAU,YAAY;GAC5C,IACE,EAAE,cAAc,cACf,UAAU,SAAS,qBAAqB,UAAU,eAAe,QAElE;GAQF,KAJE,UAAU,SAAS,SAAS,eACxB,UAAU,SAAS,OACnB,UAAU,SAAS,WAER,WAAW;EAC9B;CACF;CAEA,MAAM,IAAI,8BAA8B,QAAQ;AAClD"}
@@ -0,0 +1,21 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ //#region web/src/build/page-routes-manifest.ts
5
+ const PAGE_ROUTES_MANIFEST_FILE = "page-routes.manifest.json";
6
+ /** Atomically replace the snapshot only after the rest of the build succeeds. */
7
+ async function writePageRoutesManifest(outdir, manifest) {
8
+ const target = path.join(outdir, PAGE_ROUTES_MANIFEST_FILE);
9
+ const temporary = path.join(outdir, `.${PAGE_ROUTES_MANIFEST_FILE}.${process.pid}.${Date.now()}.tmp`);
10
+ await fs.promises.mkdir(outdir, { recursive: true });
11
+ try {
12
+ await fs.promises.writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
13
+ await fs.promises.rename(temporary, target);
14
+ } finally {
15
+ await fs.promises.unlink(temporary).catch(() => {});
16
+ }
17
+ }
18
+
19
+ //#endregion
20
+ export { writePageRoutesManifest };
21
+ //# sourceMappingURL=page-routes-manifest.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-routes-manifest.mjs","names":[],"sources":["C:/Work/Warlock.js/web/src/build/page-routes-manifest.ts"],"sourcesContent":["import fs from \"node:fs\";\r\nimport path from \"node:path\";\r\nimport type { PageRoutesManifest } from \"./generate-pages-barrel\";\r\n\r\nexport const PAGE_ROUTES_MANIFEST_FILE = \"page-routes.manifest.json\";\r\n\r\n/** Atomically replace the snapshot only after the rest of the build succeeds. */\r\nexport async function writePageRoutesManifest(\r\n outdir: string,\r\n manifest: PageRoutesManifest,\r\n): Promise<void> {\r\n const target = path.join(outdir, PAGE_ROUTES_MANIFEST_FILE);\r\n const temporary = path.join(\r\n outdir,\r\n `.${PAGE_ROUTES_MANIFEST_FILE}.${process.pid}.${Date.now()}.tmp`,\r\n );\r\n\r\n await fs.promises.mkdir(outdir, { recursive: true });\r\n\r\n try {\r\n await fs.promises.writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\\n`, \"utf-8\");\r\n await fs.promises.rename(temporary, target);\r\n } finally {\r\n await fs.promises.unlink(temporary).catch(() => {});\r\n }\r\n}\r\n"],"mappings":";;;;AAIA,MAAa,4BAA4B;;AAGzC,eAAsB,wBACpB,QACA,UACe;CACf,MAAM,SAAS,KAAK,KAAK,QAAQ,yBAAyB;CAC1D,MAAM,YAAY,KAAK,KACrB,QACA,IAAI,0BAA0B,GAAG,QAAQ,IAAI,GAAG,KAAK,IAAI,EAAE,KAC7D;CAEA,MAAM,GAAG,SAAS,MAAM,QAAQ,EAAE,WAAW,KAAK,CAAC;CAEnD,IAAI;EACF,MAAM,GAAG,SAAS,UAAU,WAAW,GAAG,KAAK,UAAU,UAAU,MAAM,CAAC,EAAE,KAAK,OAAO;EACxF,MAAM,GAAG,SAAS,OAAO,WAAW,MAAM;CAC5C,UAAU;EACR,MAAM,GAAG,SAAS,OAAO,SAAS,EAAE,YAAY,CAAC,CAAC;CACpD;AACF"}
@@ -0,0 +1,45 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ //#region web/src/build/public-files.ts
5
+ /**
6
+ * Enumerate the application-owned files Vite serves from `<appRoot>/public`
7
+ * in development. Paths are POSIX and relative so the same list can be baked
8
+ * into the production page manifest without carrying a machine-local root.
9
+ */
10
+ async function collectPublicFiles(publicRoot) {
11
+ let root;
12
+ try {
13
+ root = await fs.promises.stat(publicRoot);
14
+ } catch (error) {
15
+ if (error.code === "ENOENT") return [];
16
+ throw error;
17
+ }
18
+ if (!root.isDirectory()) return [];
19
+ const files = [];
20
+ async function visit(directory) {
21
+ const entries = await fs.promises.readdir(directory, { withFileTypes: true });
22
+ entries.sort((left, right) => left.name.localeCompare(right.name));
23
+ for (const entry of entries) {
24
+ const absolute = path.join(directory, entry.name);
25
+ if (entry.isDirectory()) await visit(absolute);
26
+ else if (entry.isFile()) files.push(path.relative(publicRoot, absolute).split(path.sep).join("/"));
27
+ }
28
+ }
29
+ await visit(publicRoot);
30
+ return files;
31
+ }
32
+ /** Copy exactly the files recorded by {@link collectPublicFiles}. */
33
+ async function copyPublicFiles(publicRoot, outputRoot, files) {
34
+ for (const file of files) {
35
+ const segments = file.split("/");
36
+ const source = path.join(publicRoot, ...segments);
37
+ const target = path.join(outputRoot, ...segments);
38
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
39
+ await fs.promises.copyFile(source, target);
40
+ }
41
+ }
42
+
43
+ //#endregion
44
+ export { collectPublicFiles, copyPublicFiles };
45
+ //# sourceMappingURL=public-files.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"public-files.mjs","names":[],"sources":["C:/Work/Warlock.js/web/src/build/public-files.ts"],"sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\n\n/**\n * Enumerate the application-owned files Vite serves from `<appRoot>/public`\n * in development. Paths are POSIX and relative so the same list can be baked\n * into the production page manifest without carrying a machine-local root.\n */\nexport async function collectPublicFiles(publicRoot: string): Promise<string[]> {\n let root: fs.Stats;\n\n try {\n root = await fs.promises.stat(publicRoot);\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return [];\n throw error;\n }\n\n if (!root.isDirectory()) return [];\n\n const files: string[] = [];\n\n async function visit(directory: string): Promise<void> {\n const entries = await fs.promises.readdir(directory, { withFileTypes: true });\n entries.sort((left, right) => left.name.localeCompare(right.name));\n\n for (const entry of entries) {\n const absolute = path.join(directory, entry.name);\n\n if (entry.isDirectory()) {\n await visit(absolute);\n } else if (entry.isFile()) {\n files.push(path.relative(publicRoot, absolute).split(path.sep).join(\"/\"));\n }\n }\n }\n\n await visit(publicRoot);\n return files;\n}\n\n/** Copy exactly the files recorded by {@link collectPublicFiles}. */\nexport async function copyPublicFiles(\n publicRoot: string,\n outputRoot: string,\n files: readonly string[],\n): Promise<void> {\n for (const file of files) {\n const segments = file.split(\"/\");\n const source = path.join(publicRoot, ...segments);\n const target = path.join(outputRoot, ...segments);\n\n await fs.promises.mkdir(path.dirname(target), { recursive: true });\n await fs.promises.copyFile(source, target);\n }\n}\n"],"mappings":";;;;;;;;;AAQA,eAAsB,mBAAmB,YAAuC;CAC9E,IAAI;CAEJ,IAAI;EACF,OAAO,MAAM,GAAG,SAAS,KAAK,UAAU;CAC1C,SAAS,OAAO;EACd,IAAK,MAAgC,SAAS,UAAU,OAAO,CAAC;EAChE,MAAM;CACR;CAEA,IAAI,CAAC,KAAK,YAAY,GAAG,OAAO,CAAC;CAEjC,MAAM,QAAkB,CAAC;CAEzB,eAAe,MAAM,WAAkC;EACrD,MAAM,UAAU,MAAM,GAAG,SAAS,QAAQ,WAAW,EAAE,eAAe,KAAK,CAAC;EAC5E,QAAQ,MAAM,MAAM,UAAU,KAAK,KAAK,cAAc,MAAM,IAAI,CAAC;EAEjE,KAAK,MAAM,SAAS,SAAS;GAC3B,MAAM,WAAW,KAAK,KAAK,WAAW,MAAM,IAAI;GAEhD,IAAI,MAAM,YAAY,GACpB,MAAM,MAAM,QAAQ;QACf,IAAI,MAAM,OAAO,GACtB,MAAM,KAAK,KAAK,SAAS,YAAY,QAAQ,EAAE,MAAM,KAAK,GAAG,EAAE,KAAK,GAAG,CAAC;EAE5E;CACF;CAEA,MAAM,MAAM,UAAU;CACtB,OAAO;AACT;;AAGA,eAAsB,gBACpB,YACA,YACA,OACe;CACf,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,WAAW,KAAK,MAAM,GAAG;EAC/B,MAAM,SAAS,KAAK,KAAK,YAAY,GAAG,QAAQ;EAChD,MAAM,SAAS,KAAK,KAAK,YAAY,GAAG,QAAQ;EAEhD,MAAM,GAAG,SAAS,MAAM,KAAK,QAAQ,MAAM,GAAG,EAAE,WAAW,KAAK,CAAC;EACjE,MAAM,GAAG,SAAS,SAAS,QAAQ,MAAM;CAC3C;AACF"}
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { parse } from "@babel/parser";
3
3
 
4
- //#region ../web/src/build/read-route-exports.ts
4
+ //#region web/src/build/read-route-exports.ts
5
5
  /**
6
6
  * Reads a page's `route` export and a layout's `prefix` export STATICALLY —
7
7
  * by parsing the source, never by loading the module.
@@ -1 +1 @@
1
- {"version":3,"file":"read-route-exports.mjs","names":[],"sources":["../../../../../../../web/src/build/read-route-exports.ts"],"sourcesContent":["/**\n * Reads a page's `route` export and a layout's `prefix` export STATICALLY —\n * by parsing the source, never by loading the module.\n *\n * The build has to know a page's declared route before anything is built, and\n * the only other way to learn it is to run the page: import the module, let its\n * top-level code execute, and read the binding. That is a different program\n * from the one being built, with the application's own side effects in it. So\n * this module parses instead, and the price of parsing is that the declaration\n * has to be readable without evaluation — a literal. What cannot be read is\n * REFUSED rather than guessed: a wrong route path that builds is worse than a\n * build that stops and says which file to change.\n *\n * `route` and `prefix` are names the page contract reserves, so this reads them\n * out of whichever file it is given and refuses a computed one wherever it\n * appears — a page that exports `prefix`, or a layout that exports `route`, is\n * using a name the framework already owns.\n *\n * Single responsibility, deliberately: this returns values or a typed\n * rejection and decides nothing. What a rejection costs, and when a page is\n * routed at all, belongs to the caller.\n */\nimport fs from \"node:fs\";\nimport { parse } from \"@babel/parser\";\n\n/**\n * The AST types are derived from `parse`'s own return type rather than imported\n * from `@babel/types`: the parser resolves its own copy of that package, and a\n * node from one copy is not assignable to the identically-shaped type from the\n * other. Reading the types off the function that produced the nodes cannot\n * disagree with it.\n */\ntype Statement = ReturnType<typeof parse>[\"program\"][\"body\"][number];\ntype Expression = Extract<Statement, { type: \"ExpressionStatement\" }>[\"expression\"];\ntype ObjectExpression = Extract<Expression, { type: \"ObjectExpression\" }>;\ntype ObjectProperty = Extract<ObjectExpression[\"properties\"][number], { type: \"ObjectProperty\" }>;\n\n/**\n * Anything that can appear where a value is expected — an expression, or one of\n * the destructuring patterns that are legal in an object literal's value slot\n * and are never a literal string.\n */\ntype ValueNode = ObjectProperty[\"value\"];\n\n/**\n * A declared route, normalised. The bare-string form (`route = \"/list\"`) and\n * the object form (`route = { path: \"/list\" }`) reach the caller identically,\n * because the server resolves them identically — `name` is absent exactly when\n * the source omitted it, which is the caller's signal to derive one.\n */\nexport type DeclaredRoute = { path: string; name?: string };\n\n/** Which export could not be read, from which file, and what was found instead. */\nexport type RouteExportsRejection = {\n sourceFile: string;\n exportName: \"route\" | \"prefix\";\n /** A sentence fragment naming the form that was found, e.g. \"its value is a function call\". */\n detail: string;\n};\n\nexport type RouteExportsReadResult =\n | { ok: true; route?: DeclaredRoute; prefix?: string }\n | { ok: false; rejection: RouteExportsRejection };\n\nconst EXAMPLES: Record<\"route\" | \"prefix\", string> = {\n route: 'export const route = \"/list\"; (or export const route = { path: \"/list\", name: \"shop.list\" };)',\n prefix: 'export const prefix = \"/shop\";',\n};\n\n/**\n * The one thing an app developer is told when a declaration cannot be read.\n *\n * It names the file, says what was found, says why a literal is required, and\n * shows one — because the reader of this message is someone who wrote perfectly\n * valid TypeScript and needs to know why the build will not take it.\n */\nexport class NonLiteralRouteExportError extends Error {\n public constructor(public readonly rejection: RouteExportsRejection) {\n const { sourceFile, exportName, detail } = rejection;\n\n super(\n `Cannot read the \\`${exportName}\\` export of \"${sourceFile}\": ${detail}. The build reads ` +\n \"route declarations without running your application code, so this value has to be \" +\n `written out as a literal. For example: ${EXAMPLES[exportName]}`,\n );\n\n this.name = \"NonLiteralRouteExportError\";\n }\n}\n\n/**\n * `as const`, `satisfies`, a non-null assertion and parentheses all wrap a value\n * without changing it, so reading through them costs nothing and refusing them\n * would reject declarations that are literal in every sense that matters here.\n */\nfunction unwrap(node: ValueNode): ValueNode {\n switch (node.type) {\n case \"TSAsExpression\":\n case \"TSSatisfiesExpression\":\n case \"TSNonNullExpression\":\n case \"TypeCastExpression\":\n case \"ParenthesizedExpression\":\n return unwrap(node.expression);\n default:\n return node;\n }\n}\n\n/** The string a node denotes, or `undefined` when that needs evaluation to know. */\nfunction stringLiteralOf(node: ValueNode): string | undefined {\n const value = unwrap(node);\n\n if (value.type === \"StringLiteral\") return value.value;\n\n // A template with no substitutions is a string spelled with backticks.\n if (value.type === \"TemplateLiteral\" && value.expressions.length === 0) {\n return value.quasis[0]?.value.cooked ?? value.quasis[0]?.value.raw;\n }\n\n return undefined;\n}\n\n/** A sentence fragment naming what was found, for the developer-facing message. */\nfunction describe(node: ValueNode): string {\n const value = unwrap(node);\n\n switch (value.type) {\n case \"CallExpression\":\n case \"OptionalCallExpression\":\n case \"NewExpression\":\n return \"its value is a function call\";\n case \"Identifier\":\n return `its value is the variable \\`${value.name}\\``;\n case \"MemberExpression\":\n case \"OptionalMemberExpression\":\n return \"its value is read off another object\";\n case \"TemplateLiteral\":\n return \"its value is a template literal with an expression in it\";\n case \"ConditionalExpression\":\n return \"its value depends on a condition\";\n case \"BinaryExpression\":\n case \"LogicalExpression\":\n return \"its value is built by an expression\";\n default:\n return \"its value is computed rather than written out\";\n }\n}\n\ntype ObjectRead = { ok: true; route: DeclaredRoute } | { ok: false; detail: string };\n\n/**\n * The object form. Unknown keys are IGNORED rather than refused, matching the\n * server, which reads `path` and `name` and lets a page carry whatever else it\n * wants alongside them. A spread is not an unknown key: it can contribute\n * `path` itself, so an object that spreads is an object whose route this cannot\n * claim to have read.\n */\nfunction readRouteObject(node: ObjectExpression): ObjectRead {\n let routePath: string | undefined;\n let routeName: string | undefined;\n\n for (const property of node.properties) {\n if (property.type === \"SpreadElement\") {\n return { ok: false, detail: \"the object spreads another value into itself\" };\n }\n\n if (property.computed) {\n return { ok: false, detail: \"one of the object's keys is computed\" };\n }\n\n const { key } = property;\n const keyName =\n key.type === \"Identifier\" ? key.name : key.type === \"StringLiteral\" ? key.value : undefined;\n\n if (keyName !== \"path\" && keyName !== \"name\") continue;\n\n if (property.type !== \"ObjectProperty\") {\n return { ok: false, detail: `\\`${keyName}\\` is declared as a method` };\n }\n\n const value = stringLiteralOf(property.value);\n\n if (value === undefined) {\n return { ok: false, detail: `its \\`${keyName}\\` is not written as a string literal` };\n }\n\n if (keyName === \"path\") routePath = value;\n else routeName = value;\n }\n\n if (routePath === undefined) {\n return { ok: false, detail: \"the object does not declare a `path`\" };\n }\n\n return {\n ok: true,\n route: routeName === undefined ? { path: routePath } : { path: routePath, name: routeName },\n };\n}\n\n/**\n * Parses the source, or THROWS when it cannot be parsed at all.\n *\n * A syntax error is not a rejection, deliberately: nothing about the route\n * declaration is known yet, so telling the developer to write a literal would\n * answer a question they did not ask.\n */\nfunction parseSource(sourceFile: string, source: string) {\n try {\n return parse(source, {\n sourceType: \"module\",\n // Every file this reads is a page or a layout, i.e. `.tsx`.\n plugins: [\"typescript\", \"jsx\"],\n errorRecovery: false,\n });\n } catch (error) {\n throw new Error(\n `Cannot read the route declarations of \"${sourceFile}\": the file could not be parsed ` +\n `(${(error as Error).message}). Fix the syntax error and the build will continue.`,\n );\n }\n}\n\n/**\n * Returns the literal `route` and `prefix` this file declares.\n *\n * Absent is not a rejection: a file that declares neither is read successfully\n * with both fields unset, and what THAT means — a page with no public URL, a\n * layout that adds no prefix — is the caller's call to make.\n *\n * `source` is an override for callers that already hold the text; by default\n * the file is read from disk.\n */\nexport function readRouteExports(sourceFile: string, source?: string): RouteExportsReadResult {\n const text = source ?? fs.readFileSync(sourceFile, \"utf-8\");\n const ast = parseSource(sourceFile, text);\n\n const reject = (exportName: \"route\" | \"prefix\", detail: string): RouteExportsReadResult => ({\n ok: false,\n rejection: { sourceFile, exportName, detail },\n });\n\n let route: DeclaredRoute | undefined;\n let prefix: string | undefined;\n\n for (const statement of ast.program.body) {\n if (statement.type !== \"ExportNamedDeclaration\" || statement.exportKind === \"type\") continue;\n\n // `export { route }` hides the value behind a binding this cannot follow\n // without resolving scope — and following it across modules is exactly the\n // evaluation this reader exists to avoid.\n for (const specifier of statement.specifiers) {\n if (specifier.type !== \"ExportSpecifier\" || specifier.exportKind === \"type\") continue;\n\n const exported =\n specifier.exported.type === \"Identifier\"\n ? specifier.exported.name\n : specifier.exported.value;\n\n if (exported === \"route\" || exported === \"prefix\") {\n return reject(\n exported,\n \"it is exported through an export list rather than declared with `export const`\",\n );\n }\n }\n\n const { declaration } = statement;\n\n if (declaration?.type !== \"VariableDeclaration\") continue;\n\n for (const declarator of declaration.declarations) {\n if (declarator.id.type !== \"Identifier\") continue;\n\n const declared = declarator.id.name;\n\n if (declared !== \"route\" && declared !== \"prefix\") continue;\n\n if (declarator.init === null || declarator.init === undefined) {\n return reject(declared, \"it is declared without a value\");\n }\n\n if (declared === \"prefix\") {\n const value = stringLiteralOf(declarator.init);\n\n if (value === undefined) return reject(\"prefix\", describe(declarator.init));\n\n prefix = value;\n continue;\n }\n\n const value = stringLiteralOf(declarator.init);\n\n if (value !== undefined) {\n route = { path: value };\n continue;\n }\n\n const object = unwrap(declarator.init);\n\n if (object.type !== \"ObjectExpression\") return reject(\"route\", describe(declarator.init));\n\n const read = readRouteObject(object);\n\n if (!read.ok) return reject(\"route\", read.detail);\n\n route = read.route;\n }\n }\n\n return {\n ok: true,\n ...(route === undefined ? {} : { route }),\n ...(prefix === undefined ? {} : { prefix }),\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"read-route-exports.mjs","names":[],"sources":["C:/Work/Warlock.js/web/src/build/read-route-exports.ts"],"sourcesContent":["/**\r\n * Reads a page's `route` export and a layout's `prefix` export STATICALLY —\r\n * by parsing the source, never by loading the module.\r\n *\r\n * The build has to know a page's declared route before anything is built, and\r\n * the only other way to learn it is to run the page: import the module, let its\r\n * top-level code execute, and read the binding. That is a different program\r\n * from the one being built, with the application's own side effects in it. So\r\n * this module parses instead, and the price of parsing is that the declaration\r\n * has to be readable without evaluation — a literal. What cannot be read is\r\n * REFUSED rather than guessed: a wrong route path that builds is worse than a\r\n * build that stops and says which file to change.\r\n *\r\n * `route` and `prefix` are names the page contract reserves, so this reads them\r\n * out of whichever file it is given and refuses a computed one wherever it\r\n * appears — a page that exports `prefix`, or a layout that exports `route`, is\r\n * using a name the framework already owns.\r\n *\r\n * Single responsibility, deliberately: this returns values or a typed\r\n * rejection and decides nothing. What a rejection costs, and when a page is\r\n * routed at all, belongs to the caller.\r\n */\r\nimport fs from \"node:fs\";\r\nimport { parse } from \"@babel/parser\";\r\n\r\n/**\r\n * The AST types are derived from `parse`'s own return type rather than imported\r\n * from `@babel/types`: the parser resolves its own copy of that package, and a\r\n * node from one copy is not assignable to the identically-shaped type from the\r\n * other. Reading the types off the function that produced the nodes cannot\r\n * disagree with it.\r\n */\r\ntype Statement = ReturnType<typeof parse>[\"program\"][\"body\"][number];\r\ntype Expression = Extract<Statement, { type: \"ExpressionStatement\" }>[\"expression\"];\r\ntype ObjectExpression = Extract<Expression, { type: \"ObjectExpression\" }>;\r\ntype ObjectProperty = Extract<ObjectExpression[\"properties\"][number], { type: \"ObjectProperty\" }>;\r\n\r\n/**\r\n * Anything that can appear where a value is expected — an expression, or one of\r\n * the destructuring patterns that are legal in an object literal's value slot\r\n * and are never a literal string.\r\n */\r\ntype ValueNode = ObjectProperty[\"value\"];\r\n\r\n/**\r\n * A declared route, normalised. The bare-string form (`route = \"/list\"`) and\r\n * the object form (`route = { path: \"/list\" }`) reach the caller identically,\r\n * because the server resolves them identically — `name` is absent exactly when\r\n * the source omitted it, which is the caller's signal to derive one.\r\n */\r\nexport type DeclaredRoute = { path: string; name?: string };\r\n\r\n/** Which export could not be read, from which file, and what was found instead. */\r\nexport type RouteExportsRejection = {\r\n sourceFile: string;\r\n exportName: \"route\" | \"prefix\";\r\n /** A sentence fragment naming the form that was found, e.g. \"its value is a function call\". */\r\n detail: string;\r\n};\r\n\r\nexport type RouteExportsReadResult =\r\n | { ok: true; route?: DeclaredRoute; prefix?: string }\r\n | { ok: false; rejection: RouteExportsRejection };\r\n\r\nconst EXAMPLES: Record<\"route\" | \"prefix\", string> = {\r\n route: 'export const route = \"/list\"; (or export const route = { path: \"/list\", name: \"shop.list\" };)',\r\n prefix: 'export const prefix = \"/shop\";',\r\n};\r\n\r\n/**\r\n * The one thing an app developer is told when a declaration cannot be read.\r\n *\r\n * It names the file, says what was found, says why a literal is required, and\r\n * shows one — because the reader of this message is someone who wrote perfectly\r\n * valid TypeScript and needs to know why the build will not take it.\r\n */\r\nexport class NonLiteralRouteExportError extends Error {\r\n public constructor(public readonly rejection: RouteExportsRejection) {\r\n const { sourceFile, exportName, detail } = rejection;\r\n\r\n super(\r\n `Cannot read the \\`${exportName}\\` export of \"${sourceFile}\": ${detail}. The build reads ` +\r\n \"route declarations without running your application code, so this value has to be \" +\r\n `written out as a literal. For example: ${EXAMPLES[exportName]}`,\r\n );\r\n\r\n this.name = \"NonLiteralRouteExportError\";\r\n }\r\n}\r\n\r\n/**\r\n * `as const`, `satisfies`, a non-null assertion and parentheses all wrap a value\r\n * without changing it, so reading through them costs nothing and refusing them\r\n * would reject declarations that are literal in every sense that matters here.\r\n */\r\nfunction unwrap(node: ValueNode): ValueNode {\r\n switch (node.type) {\r\n case \"TSAsExpression\":\r\n case \"TSSatisfiesExpression\":\r\n case \"TSNonNullExpression\":\r\n case \"TypeCastExpression\":\r\n case \"ParenthesizedExpression\":\r\n return unwrap(node.expression);\r\n default:\r\n return node;\r\n }\r\n}\r\n\r\n/** The string a node denotes, or `undefined` when that needs evaluation to know. */\r\nfunction stringLiteralOf(node: ValueNode): string | undefined {\r\n const value = unwrap(node);\r\n\r\n if (value.type === \"StringLiteral\") return value.value;\r\n\r\n // A template with no substitutions is a string spelled with backticks.\r\n if (value.type === \"TemplateLiteral\" && value.expressions.length === 0) {\r\n return value.quasis[0]?.value.cooked ?? value.quasis[0]?.value.raw;\r\n }\r\n\r\n return undefined;\r\n}\r\n\r\n/** A sentence fragment naming what was found, for the developer-facing message. */\r\nfunction describe(node: ValueNode): string {\r\n const value = unwrap(node);\r\n\r\n switch (value.type) {\r\n case \"CallExpression\":\r\n case \"OptionalCallExpression\":\r\n case \"NewExpression\":\r\n return \"its value is a function call\";\r\n case \"Identifier\":\r\n return `its value is the variable \\`${value.name}\\``;\r\n case \"MemberExpression\":\r\n case \"OptionalMemberExpression\":\r\n return \"its value is read off another object\";\r\n case \"TemplateLiteral\":\r\n return \"its value is a template literal with an expression in it\";\r\n case \"ConditionalExpression\":\r\n return \"its value depends on a condition\";\r\n case \"BinaryExpression\":\r\n case \"LogicalExpression\":\r\n return \"its value is built by an expression\";\r\n default:\r\n return \"its value is computed rather than written out\";\r\n }\r\n}\r\n\r\ntype ObjectRead = { ok: true; route: DeclaredRoute } | { ok: false; detail: string };\r\n\r\n/**\r\n * The object form. Unknown keys are IGNORED rather than refused, matching the\r\n * server, which reads `path` and `name` and lets a page carry whatever else it\r\n * wants alongside them. A spread is not an unknown key: it can contribute\r\n * `path` itself, so an object that spreads is an object whose route this cannot\r\n * claim to have read.\r\n */\r\nfunction readRouteObject(node: ObjectExpression): ObjectRead {\r\n let routePath: string | undefined;\r\n let routeName: string | undefined;\r\n\r\n for (const property of node.properties) {\r\n if (property.type === \"SpreadElement\") {\r\n return { ok: false, detail: \"the object spreads another value into itself\" };\r\n }\r\n\r\n if (property.computed) {\r\n return { ok: false, detail: \"one of the object's keys is computed\" };\r\n }\r\n\r\n const { key } = property;\r\n const keyName =\r\n key.type === \"Identifier\" ? key.name : key.type === \"StringLiteral\" ? key.value : undefined;\r\n\r\n if (keyName !== \"path\" && keyName !== \"name\") continue;\r\n\r\n if (property.type !== \"ObjectProperty\") {\r\n return { ok: false, detail: `\\`${keyName}\\` is declared as a method` };\r\n }\r\n\r\n const value = stringLiteralOf(property.value);\r\n\r\n if (value === undefined) {\r\n return { ok: false, detail: `its \\`${keyName}\\` is not written as a string literal` };\r\n }\r\n\r\n if (keyName === \"path\") routePath = value;\r\n else routeName = value;\r\n }\r\n\r\n if (routePath === undefined) {\r\n return { ok: false, detail: \"the object does not declare a `path`\" };\r\n }\r\n\r\n return {\r\n ok: true,\r\n route: routeName === undefined ? { path: routePath } : { path: routePath, name: routeName },\r\n };\r\n}\r\n\r\n/**\r\n * Parses the source, or THROWS when it cannot be parsed at all.\r\n *\r\n * A syntax error is not a rejection, deliberately: nothing about the route\r\n * declaration is known yet, so telling the developer to write a literal would\r\n * answer a question they did not ask.\r\n */\r\nfunction parseSource(sourceFile: string, source: string) {\r\n try {\r\n return parse(source, {\r\n sourceType: \"module\",\r\n // Every file this reads is a page or a layout, i.e. `.tsx`.\r\n plugins: [\"typescript\", \"jsx\"],\r\n errorRecovery: false,\r\n });\r\n } catch (error) {\r\n throw new Error(\r\n `Cannot read the route declarations of \"${sourceFile}\": the file could not be parsed ` +\r\n `(${(error as Error).message}). Fix the syntax error and the build will continue.`,\r\n );\r\n }\r\n}\r\n\r\n/**\r\n * Returns the literal `route` and `prefix` this file declares.\r\n *\r\n * Absent is not a rejection: a file that declares neither is read successfully\r\n * with both fields unset, and what THAT means — a page with no public URL, a\r\n * layout that adds no prefix — is the caller's call to make.\r\n *\r\n * `source` is an override for callers that already hold the text; by default\r\n * the file is read from disk.\r\n */\r\nexport function readRouteExports(sourceFile: string, source?: string): RouteExportsReadResult {\r\n const text = source ?? fs.readFileSync(sourceFile, \"utf-8\");\r\n const ast = parseSource(sourceFile, text);\r\n\r\n const reject = (exportName: \"route\" | \"prefix\", detail: string): RouteExportsReadResult => ({\r\n ok: false,\r\n rejection: { sourceFile, exportName, detail },\r\n });\r\n\r\n let route: DeclaredRoute | undefined;\r\n let prefix: string | undefined;\r\n\r\n for (const statement of ast.program.body) {\r\n if (statement.type !== \"ExportNamedDeclaration\" || statement.exportKind === \"type\") continue;\r\n\r\n // `export { route }` hides the value behind a binding this cannot follow\r\n // without resolving scope — and following it across modules is exactly the\r\n // evaluation this reader exists to avoid.\r\n for (const specifier of statement.specifiers) {\r\n if (specifier.type !== \"ExportSpecifier\" || specifier.exportKind === \"type\") continue;\r\n\r\n const exported =\r\n specifier.exported.type === \"Identifier\"\r\n ? specifier.exported.name\r\n : specifier.exported.value;\r\n\r\n if (exported === \"route\" || exported === \"prefix\") {\r\n return reject(\r\n exported,\r\n \"it is exported through an export list rather than declared with `export const`\",\r\n );\r\n }\r\n }\r\n\r\n const { declaration } = statement;\r\n\r\n if (declaration?.type !== \"VariableDeclaration\") continue;\r\n\r\n for (const declarator of declaration.declarations) {\r\n if (declarator.id.type !== \"Identifier\") continue;\r\n\r\n const declared = declarator.id.name;\r\n\r\n if (declared !== \"route\" && declared !== \"prefix\") continue;\r\n\r\n if (declarator.init === null || declarator.init === undefined) {\r\n return reject(declared, \"it is declared without a value\");\r\n }\r\n\r\n if (declared === \"prefix\") {\r\n const value = stringLiteralOf(declarator.init);\r\n\r\n if (value === undefined) return reject(\"prefix\", describe(declarator.init));\r\n\r\n prefix = value;\r\n continue;\r\n }\r\n\r\n const value = stringLiteralOf(declarator.init);\r\n\r\n if (value !== undefined) {\r\n route = { path: value };\r\n continue;\r\n }\r\n\r\n const object = unwrap(declarator.init);\r\n\r\n if (object.type !== \"ObjectExpression\") return reject(\"route\", describe(declarator.init));\r\n\r\n const read = readRouteObject(object);\r\n\r\n if (!read.ok) return reject(\"route\", read.detail);\r\n\r\n route = read.route;\r\n }\r\n }\r\n\r\n return {\r\n ok: true,\r\n ...(route === undefined ? {} : { route }),\r\n ...(prefix === undefined ? {} : { prefix }),\r\n };\r\n}\r\n"],"mappings":"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"}
@@ -1,7 +1,8 @@
1
+ import { registerModules } from "../runtime/register-modules.mjs";
1
2
  import { loadClientRouteComposition } from "./runtime/manifest.mjs";
2
3
  import { createElement } from "react";
3
4
 
4
- //#region ../web/src/client/build-hydrated-tree.ts
5
+ //#region web/src/client/build-hydrated-tree.ts
5
6
  /**
6
7
  * The hydration COMPOSER: payload + page registry -> the ReactNode to mount.
7
8
  *
@@ -41,6 +42,20 @@ var UnknownHydrationPageNameError = class extends Error {
41
42
  this.name = "UnknownHydrationPageNameError";
42
43
  }
43
44
  };
45
+ /**
46
+ * The server selected an app error page, but this browser graph cannot load it.
47
+ * Substituting the ordinary page would execute the component that already
48
+ * failed and hydrate markup the server did not render, so this path fails
49
+ * closed just like an unknown route name.
50
+ */
51
+ var MissingHydrationErrorPageError = class extends Error {
52
+ pageName;
53
+ constructor(pageName) {
54
+ super(`Warlock hydration aborted: the server selected error.page.tsx for route ${JSON.stringify(pageName)}, but that route's client composition has no ErrorPage module. Rebuild the client page registry so it projects the discovered error page.`);
55
+ this.pageName = pageName;
56
+ this.name = "MissingHydrationErrorPageError";
57
+ }
58
+ };
44
59
  function findEntryByName(pages, name) {
45
60
  const entry = pages.find((candidate) => candidate.name === name);
46
61
  if (entry === void 0) throw new UnknownHydrationPageNameError(name, pages.map((candidate) => candidate.name));
@@ -69,9 +84,10 @@ function wrap(module, data, shared, children) {
69
84
  }
70
85
  /**
71
86
  * Compose the tree the server rendered inside `#root`: ordered layouts wrapping
72
- * the Page, layouts OUTERMOST FIRST as `ClientRouteComposition` declares them,
73
- * each level receiving `{ data, shared }` and every wrapper additionally
74
- * `children`.
87
+ * the selected Page or ErrorPage leaf, layouts OUTERMOST FIRST as
88
+ * `ClientRouteComposition` declares them. Ordinary levels receive
89
+ * `{ data, shared }`; the error leaf receives the serialized `{ error, status
90
+ * }` payload shape.
75
91
  *
76
92
  * ── THE APP LEVEL IS DELIBERATELY ABSENT, AND MUST STAY ABSENT ──────────────
77
93
  * `ClientRouteComposition.App` and `payload.appData` still exist and are still
@@ -98,12 +114,27 @@ function wrap(module, data, shared, children) {
98
114
  */
99
115
  async function buildHydratedTree(pages, payload) {
100
116
  const composition = await loadClientRouteComposition(findEntryByName(pages, payload.name));
117
+ const errorPageProps = payload.errorPage;
118
+ const selectedPageModule = errorPageProps === void 0 ? composition.Page : composition.ErrorPage;
119
+ if (selectedPageModule === void 0) throw new MissingHydrationErrorPageError(payload.name);
120
+ registerModules([
121
+ ...composition.App === void 0 ? [] : [composition.App],
122
+ ...composition.layouts,
123
+ selectedPageModule
124
+ ]);
101
125
  const { shared } = payload;
102
- const Page = componentOf(composition.Page);
103
- let element = Page === void 0 ? null : createElement(Page, {
104
- data: payload.pageData,
105
- shared
106
- });
126
+ let element;
127
+ if (errorPageProps === void 0) {
128
+ const Page = componentOf(selectedPageModule);
129
+ element = Page === void 0 ? null : createElement(Page, {
130
+ data: payload.pageData,
131
+ shared,
132
+ params: payload.params ?? {}
133
+ });
134
+ } else {
135
+ const ErrorPage = componentOf(selectedPageModule);
136
+ element = ErrorPage === void 0 ? null : createElement(ErrorPage, errorPageProps);
137
+ }
107
138
  for (let index = composition.layouts.length - 1; index >= 0; index -= 1) element = wrap(composition.layouts[index], payload.layoutData, shared, element);
108
139
  return element;
109
140
  }
@@ -1 +1 @@
1
- {"version":3,"file":"build-hydrated-tree.mjs","names":[],"sources":["../../../../../../../web/src/client/build-hydrated-tree.ts"],"sourcesContent":["/**\n * The hydration COMPOSER: payload + page registry -> the ReactNode to mount.\n *\n * It takes the registry as an ARGUMENT and touches no browser global, which is\n * the whole point of it living apart from `index.ts`: every rule below is\n * testable with a hand-built registry, no bundler, no virtual module, no DOM.\n *\n * LOOKUP BY NAME, NEVER BY MATCH. `payload.name` is the identity of the entry\n * the SERVER matched for this exact request (document-context.ts's `name`\n * field). Re-deriving it from `location.pathname` with `matchClientRoute`\n * would be a second implementation of route semantics running against the one\n * request it is hydrating, free to disagree with the server that produced the\n * markup. `matchClientRoute` is for client-side NAVIGATION, where no server\n * answer exists yet.\n */\nimport { createElement, type ComponentType, type ReactNode } from \"react\";\nimport type { HydrationDocumentPayloadSource } from \"../hydration-payload\";\nimport { loadClientRouteComposition } from \"./runtime\";\nimport type { ClientPageEntry, ClientProjectedModule } from \"./runtime/types\";\n\n/** What every composed level receives — the shape `render-page.ts` uses server-side. */\ntype HydratedLevelProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly children?: ReactNode;\n};\n\nfunction describeKnownNames(knownPageNames: readonly string[]): string {\n if (knownPageNames.length === 0) return \"The client page registry is empty.\";\n\n return `The registry knows: ${knownPageNames.map((name) => JSON.stringify(name)).join(\", \")}.`;\n}\n\n/**\n * The THIRD hydration failure case, beside an absent and a malformed payload.\n *\n * It fails CLOSED — no default entry, no nearest-path fallback, no silent\n * no-op. A registry that quietly substitutes a page produces a browser showing\n * one page's markup running another page's code, which is precisely the defect\n * this entry point was rewritten to remove; a fallback would reintroduce it\n * wearing a recovery costume. Throwing leaves the server-rendered markup on\n * screen and un-hydrated, which is degraded but honest.\n */\nexport class UnknownHydrationPageNameError extends Error {\n public constructor(\n public readonly pageName: string,\n public readonly knownPageNames: readonly string[],\n ) {\n super(\n `Warlock hydration aborted: the payload names page ${JSON.stringify(pageName)}, which is ` +\n `not in the client page registry. ${describeKnownNames(knownPageNames)} The server ` +\n \"rendered a page this browser bundle does not carry, so the server and client were \" +\n \"built from different page graphs. To fix: rebuild the client bundle, or check that \" +\n \"the page's file still exports a `route` discovery can see.\",\n );\n this.name = \"UnknownHydrationPageNameError\";\n }\n}\n\nfunction findEntryByName(\n pages: readonly ClientPageEntry[],\n name: string,\n): ClientPageEntry {\n const entry = pages.find((candidate) => candidate.name === name);\n\n if (entry === undefined) {\n throw new UnknownHydrationPageNameError(\n name,\n pages.map((candidate) => candidate.name),\n );\n }\n\n return entry;\n}\n\n/**\n * A level's component, or undefined when the module exports no default.\n *\n * Undefined is NOT an error: `render-page.ts:258` and `:279` treat a missing\n * default as a passthrough server-side, and the client tree has to match the\n * markup React is hydrating against — introducing a level here that the server\n * did not render is a hydration mismatch, not a repair.\n */\nfunction componentOf(\n module: ClientProjectedModule,\n): ComponentType<HydratedLevelProps> | undefined {\n const component = module.default;\n\n return typeof component === \"function\"\n ? (component as ComponentType<HydratedLevelProps>)\n : undefined;\n}\n\nfunction wrap(\n module: ClientProjectedModule,\n data: unknown,\n shared: unknown,\n children: ReactNode,\n): ReactNode {\n const Component = componentOf(module);\n\n if (Component === undefined) return children;\n\n return createElement(Component, { data, shared, children });\n}\n\n/**\n * Compose the tree the server rendered inside `#root`: ordered layouts wrapping\n * the Page, layouts OUTERMOST FIRST as `ClientRouteComposition` declares them,\n * each level receiving `{ data, shared }` and every wrapper additionally\n * `children`.\n *\n * ── THE APP LEVEL IS DELIBERATELY ABSENT, AND MUST STAY ABSENT ──────────────\n * `ClientRouteComposition.App` and `payload.appData` still exist and are still\n * carried; they are contracts owned elsewhere. They are simply not part of THIS\n * tree, because App is not part of the markup this tree hydrates against:\n *\n * - Server-side, `render-page.ts`'s `wrapRootward` wraps the page leaf in\n * `[\"layout\", \"app\"]` (`render-page.ts:274`), so the document React renders\n * is `App( Layout( Page ) )`.\n * - The app root is the level that owns `<html>`/`<body>` and renders\n * `<div id=\"root\">{children}</div>` inside the body. So App CONTAINS the\n * mount point — the markup actually inside `#root` is `Layout( Page )`.\n * - `hydrate-page.tsx` mounts at `#root` and nowhere else.\n *\n * Composing App here would therefore hydrate a whole `<html>` document inside a\n * `<div>` the server filled with a layout: a guaranteed hydration mismatch. If\n * you arrived here from the optional `App?` on the composition type and are\n * about to \"complete\" the tree with it — that would be the defect, not the\n * omission.\n *\n * `load()` is awaited exactly ONCE per hydration and its result reused for all\n * levels — the composition arrives whole, so calling it per layout would be\n * one network waterfall per level for no new information.\n */\nexport async function buildHydratedTree(\n pages: readonly ClientPageEntry[],\n payload: HydrationDocumentPayloadSource,\n): Promise<ReactNode> {\n const entry = findEntryByName(pages, payload.name);\n const composition = await loadClientRouteComposition(entry);\n const { shared } = payload;\n\n const Page = componentOf(composition.Page);\n let element: ReactNode =\n Page === undefined ? null : createElement(Page, { data: payload.pageData, shared });\n\n // Innermost layout wraps the page, so walk the outermost-first list backwards.\n for (let index = composition.layouts.length - 1; index >= 0; index -= 1) {\n element = wrap(composition.layouts[index]!, payload.layoutData, shared, element);\n }\n\n return element;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA2BA,SAAS,mBAAmB,gBAA2C;CACrE,IAAI,eAAe,WAAW,GAAG,OAAO;CAExC,OAAO,uBAAuB,eAAe,KAAK,SAAS,KAAK,UAAU,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE;AAC9F;;;;;;;;;;;AAYA,IAAa,gCAAb,cAAmD,MAAM;CAErC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,gBAChB;EACA,MACE,qDAAqD,KAAK,UAAU,QAAQ,EAAE,8CACxC,mBAAmB,cAAc,EAAE,8OAI3E;EATgB;EACA;EAShB,KAAK,OAAO;CACd;AACF;AAEA,SAAS,gBACP,OACA,MACiB;CACjB,MAAM,QAAQ,MAAM,MAAM,cAAc,UAAU,SAAS,IAAI;CAE/D,IAAI,UAAU,QACZ,MAAM,IAAI,8BACR,MACA,MAAM,KAAK,cAAc,UAAU,IAAI,CACzC;CAGF,OAAO;AACT;;;;;;;;;AAUA,SAAS,YACP,QAC+C;CAC/C,MAAM,YAAY,OAAO;CAEzB,OAAO,OAAO,cAAc,aACvB,YACD;AACN;AAEA,SAAS,KACP,QACA,MACA,QACA,UACW;CACX,MAAM,YAAY,YAAY,MAAM;CAEpC,IAAI,cAAc,QAAW,OAAO;CAEpC,OAAO,cAAc,WAAW;EAAE;EAAM;EAAQ;CAAS,CAAC;AAC5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,eAAsB,kBACpB,OACA,SACoB;CAEpB,MAAM,cAAc,MAAM,2BADZ,gBAAgB,OAAO,QAAQ,IACY,CAAC;CAC1D,MAAM,EAAE,WAAW;CAEnB,MAAM,OAAO,YAAY,YAAY,IAAI;CACzC,IAAI,UACF,SAAS,SAAY,OAAO,cAAc,MAAM;EAAE,MAAM,QAAQ;EAAU;CAAO,CAAC;CAGpF,KAAK,IAAI,QAAQ,YAAY,QAAQ,SAAS,GAAG,SAAS,GAAG,SAAS,GACpE,UAAU,KAAK,YAAY,QAAQ,QAAS,QAAQ,YAAY,QAAQ,OAAO;CAGjF,OAAO;AACT"}
1
+ {"version":3,"file":"build-hydrated-tree.mjs","names":[],"sources":["C:/Work/Warlock.js/web/src/client/build-hydrated-tree.ts"],"sourcesContent":["/**\n * The hydration COMPOSER: payload + page registry -> the ReactNode to mount.\n *\n * It takes the registry as an ARGUMENT and touches no browser global, which is\n * the whole point of it living apart from `index.ts`: every rule below is\n * testable with a hand-built registry, no bundler, no virtual module, no DOM.\n *\n * LOOKUP BY NAME, NEVER BY MATCH. `payload.name` is the identity of the entry\n * the SERVER matched for this exact request (document-context.ts's `name`\n * field). Re-deriving it from `location.pathname` with `matchClientRoute`\n * would be a second implementation of route semantics running against the one\n * request it is hydrating, free to disagree with the server that produced the\n * markup. `matchClientRoute` is for client-side NAVIGATION, where no server\n * answer exists yet.\n */\nimport { createElement, type ComponentType, type ReactNode } from \"react\";\nimport type {\n HydrationDocumentPayloadSource,\n SerializedErrorPageProps,\n} from \"../hydration-payload\";\nimport { registerModules } from \"../runtime/register-modules\";\nimport { loadClientRouteComposition } from \"./runtime\";\nimport type { ClientPageEntry, ClientProjectedModule } from \"./runtime/types\";\n\n/** What every composed level receives — the shape `render-page.ts` uses server-side. */\ntype HydratedLevelProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly children?: ReactNode;\n};\n\n/** The ordinary page leaf alone receives params from the server's match. */\ntype HydratedPageProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly params: Readonly<Record<string, string>>;\n};\n\nfunction describeKnownNames(knownPageNames: readonly string[]): string {\n if (knownPageNames.length === 0) return \"The client page registry is empty.\";\n\n return `The registry knows: ${knownPageNames.map((name) => JSON.stringify(name)).join(\", \")}.`;\n}\n\n/**\n * The THIRD hydration failure case, beside an absent and a malformed payload.\n *\n * It fails CLOSED — no default entry, no nearest-path fallback, no silent\n * no-op. A registry that quietly substitutes a page produces a browser showing\n * one page's markup running another page's code, which is precisely the defect\n * this entry point was rewritten to remove; a fallback would reintroduce it\n * wearing a recovery costume. Throwing leaves the server-rendered markup on\n * screen and un-hydrated, which is degraded but honest.\n */\nexport class UnknownHydrationPageNameError extends Error {\n public constructor(\n public readonly pageName: string,\n public readonly knownPageNames: readonly string[],\n ) {\n super(\n `Warlock hydration aborted: the payload names page ${JSON.stringify(pageName)}, which is ` +\n `not in the client page registry. ${describeKnownNames(knownPageNames)} The server ` +\n \"rendered a page this browser bundle does not carry, so the server and client were \" +\n \"built from different page graphs. To fix: rebuild the client bundle, or check that \" +\n \"the page's file still exports a `route` discovery can see.\",\n );\n this.name = \"UnknownHydrationPageNameError\";\n }\n}\n\n/**\n * The server selected an app error page, but this browser graph cannot load it.\n * Substituting the ordinary page would execute the component that already\n * failed and hydrate markup the server did not render, so this path fails\n * closed just like an unknown route name.\n */\nexport class MissingHydrationErrorPageError extends Error {\n public constructor(public readonly pageName: string) {\n super(\n `Warlock hydration aborted: the server selected error.page.tsx for route ` +\n `${JSON.stringify(pageName)}, but that route's client composition has no ErrorPage ` +\n \"module. Rebuild the client page registry so it projects the discovered error page.\",\n );\n this.name = \"MissingHydrationErrorPageError\";\n }\n}\n\nfunction findEntryByName(\n pages: readonly ClientPageEntry[],\n name: string,\n): ClientPageEntry {\n const entry = pages.find((candidate) => candidate.name === name);\n\n if (entry === undefined) {\n throw new UnknownHydrationPageNameError(\n name,\n pages.map((candidate) => candidate.name),\n );\n }\n\n return entry;\n}\n\n/**\n * A level's component, or undefined when the module exports no default.\n *\n * Undefined is NOT an error: `render-page.ts:258` and `:279` treat a missing\n * default as a passthrough server-side, and the client tree has to match the\n * markup React is hydrating against — introducing a level here that the server\n * did not render is a hydration mismatch, not a repair.\n */\nfunction componentOf<Props extends object>(\n module: ClientProjectedModule,\n): ComponentType<Props> | undefined {\n const component = module.default;\n\n return typeof component === \"function\"\n ? (component as ComponentType<Props>)\n : undefined;\n}\n\nfunction wrap(\n module: ClientProjectedModule,\n data: unknown,\n shared: unknown,\n children: ReactNode,\n): ReactNode {\n const Component = componentOf<HydratedLevelProps>(module);\n\n if (Component === undefined) return children;\n\n return createElement(Component, { data, shared, children });\n}\n\n/**\n * Compose the tree the server rendered inside `#root`: ordered layouts wrapping\n * the selected Page or ErrorPage leaf, layouts OUTERMOST FIRST as\n * `ClientRouteComposition` declares them. Ordinary levels receive\n * `{ data, shared }`; the error leaf receives the serialized `{ error, status\n * }` payload shape.\n *\n * ── THE APP LEVEL IS DELIBERATELY ABSENT, AND MUST STAY ABSENT ──────────────\n * `ClientRouteComposition.App` and `payload.appData` still exist and are still\n * carried; they are contracts owned elsewhere. They are simply not part of THIS\n * tree, because App is not part of the markup this tree hydrates against:\n *\n * - Server-side, `render-page.ts`'s `wrapRootward` wraps the page leaf in\n * `[\"layout\", \"app\"]` (`render-page.ts:274`), so the document React renders\n * is `App( Layout( Page ) )`.\n * - The app root is the level that owns `<html>`/`<body>` and renders\n * `<div id=\"root\">{children}</div>` inside the body. So App CONTAINS the\n * mount point — the markup actually inside `#root` is `Layout( Page )`.\n * - `hydrate-page.tsx` mounts at `#root` and nowhere else.\n *\n * Composing App here would therefore hydrate a whole `<html>` document inside a\n * `<div>` the server filled with a layout: a guaranteed hydration mismatch. If\n * you arrived here from the optional `App?` on the composition type and are\n * about to \"complete\" the tree with it — that would be the defect, not the\n * omission.\n *\n * `load()` is awaited exactly ONCE per hydration and its result reused for all\n * levels — the composition arrives whole, so calling it per layout would be\n * one network waterfall per level for no new information.\n */\nexport async function buildHydratedTree(\n pages: readonly ClientPageEntry[],\n payload: HydrationDocumentPayloadSource,\n): Promise<ReactNode> {\n const entry = findEntryByName(pages, payload.name);\n const composition = await loadClientRouteComposition(entry);\n const errorPageProps = payload.errorPage;\n const selectedPageModule =\n errorPageProps === undefined ? composition.Page : composition.ErrorPage;\n\n if (selectedPageModule === undefined) {\n throw new MissingHydrationErrorPageError(payload.name);\n }\n\n // Registration is the first lifecycle action after the real namespaces have\n // loaded. Keep server order: root/App, layouts outermost-to-innermost, page.\n // On the error path the selected error module replaces the ordinary Page in\n // that order; registering Page as well would run code the server did not run.\n // Component extraction and React element creation intentionally happen only\n // after every registration hook has completed synchronously.\n registerModules([\n ...(composition.App === undefined ? [] : [composition.App]),\n ...composition.layouts,\n selectedPageModule,\n ]);\n\n const { shared } = payload;\n let element: ReactNode;\n\n if (errorPageProps === undefined) {\n const Page = componentOf<HydratedPageProps>(selectedPageModule);\n element =\n Page === undefined\n ? null\n : createElement(Page, {\n data: payload.pageData,\n shared,\n params: payload.params ?? {},\n });\n } else {\n const ErrorPage = componentOf<SerializedErrorPageProps>(selectedPageModule);\n element =\n ErrorPage === undefined ? null : createElement(ErrorPage, errorPageProps);\n }\n\n // Innermost layout wraps the page, so walk the outermost-first list backwards.\n for (let index = composition.layouts.length - 1; index >= 0; index -= 1) {\n element = wrap(\n composition.layouts[index]!,\n payload.layoutData,\n shared,\n element,\n );\n }\n\n return element;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAsCA,SAAS,mBAAmB,gBAA2C;CACrE,IAAI,eAAe,WAAW,GAAG,OAAO;CAExC,OAAO,uBAAuB,eAAe,KAAK,SAAS,KAAK,UAAU,IAAI,CAAC,EAAE,KAAK,IAAI,EAAE;AAC9F;;;;;;;;;;;AAYA,IAAa,gCAAb,cAAmD,MAAM;CAErC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,gBAChB;EACA,MACE,qDAAqD,KAAK,UAAU,QAAQ,EAAE,8CACxC,mBAAmB,cAAc,EAAE,8OAI3E;EATgB;EACA;EAShB,KAAK,OAAO;CACd;AACF;;;;;;;AAQA,IAAa,iCAAb,cAAoD,MAAM;CACrB;CAAnC,AAAO,YAAY,AAAgB,UAAkB;EACnD,MACE,2EACK,KAAK,UAAU,QAAQ,EAAE,0IAEhC;EALiC;EAMjC,KAAK,OAAO;CACd;AACF;AAEA,SAAS,gBACP,OACA,MACiB;CACjB,MAAM,QAAQ,MAAM,MAAM,cAAc,UAAU,SAAS,IAAI;CAE/D,IAAI,UAAU,QACZ,MAAM,IAAI,8BACR,MACA,MAAM,KAAK,cAAc,UAAU,IAAI,CACzC;CAGF,OAAO;AACT;;;;;;;;;AAUA,SAAS,YACP,QACkC;CAClC,MAAM,YAAY,OAAO;CAEzB,OAAO,OAAO,cAAc,aACvB,YACD;AACN;AAEA,SAAS,KACP,QACA,MACA,QACA,UACW;CACX,MAAM,YAAY,YAAgC,MAAM;CAExD,IAAI,cAAc,QAAW,OAAO;CAEpC,OAAO,cAAc,WAAW;EAAE;EAAM;EAAQ;CAAS,CAAC;AAC5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,eAAsB,kBACpB,OACA,SACoB;CAEpB,MAAM,cAAc,MAAM,2BADZ,gBAAgB,OAAO,QAAQ,IACY,CAAC;CAC1D,MAAM,iBAAiB,QAAQ;CAC/B,MAAM,qBACJ,mBAAmB,SAAY,YAAY,OAAO,YAAY;CAEhE,IAAI,uBAAuB,QACzB,MAAM,IAAI,+BAA+B,QAAQ,IAAI;CASvD,gBAAgB;EACd,GAAI,YAAY,QAAQ,SAAY,CAAC,IAAI,CAAC,YAAY,GAAG;EACzD,GAAG,YAAY;EACf;CACF,CAAC;CAED,MAAM,EAAE,WAAW;CACnB,IAAI;CAEJ,IAAI,mBAAmB,QAAW;EAChC,MAAM,OAAO,YAA+B,kBAAkB;EAC9D,UACE,SAAS,SACL,OACA,cAAc,MAAM;GAClB,MAAM,QAAQ;GACd;GACA,QAAQ,QAAQ,UAAU,CAAC;EAC7B,CAAC;CACT,OAAO;EACL,MAAM,YAAY,YAAsC,kBAAkB;EAC1E,UACE,cAAc,SAAY,OAAO,cAAc,WAAW,cAAc;CAC5E;CAGA,KAAK,IAAI,QAAQ,YAAY,QAAQ,SAAS,GAAG,SAAS,GAAG,SAAS,GACpE,UAAU,KACR,YAAY,QAAQ,QACpB,QAAQ,YACR,QACA,OACF;CAGF,OAAO;AACT"}
@@ -4,7 +4,7 @@ import { readHydrationPayload } from "../hydration-payload.mjs";
4
4
  import { hydrateRoot } from "react-dom/client";
5
5
  import { jsx } from "react/jsx-runtime";
6
6
 
7
- //#region ../web/src/client/hydrate-page.tsx
7
+ //#region web/src/client/hydrate-page.tsx
8
8
  /**
9
9
  * The hydration MOUNT point — a different id from the payload script's id.
10
10
  * Not exported anywhere as a named constant (`default-app.tsx:39` only
@@ -1 +1 @@
1
- {"version":3,"file":"hydrate-page.mjs","names":[],"sources":["../../../../../../../web/src/client/hydrate-page.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { hydrateRoot } from \"react-dom/client\";\nimport { DocumentContext, type DocumentContextValue } from \"../components/document-context\";\nimport { readHydrationPayload, type HydrationDocumentPayloadSource } from \"../hydration-payload\";\nimport { hydrateShared } from \"../shared\";\n\n/**\n * The hydration MOUNT point — a different id from the payload script's id.\n * Not exported anywhere as a named constant (`default-app.tsx:39` only\n * renders the literal `<div id=\"root\">`), so a local literal is fine here:\n * the contract's no-duplicate-literal rule is specifically about the payload\n * script id, which `readHydrationPayload` already owns exclusively\n * (hydration-payload-contract-2026-08-22.md §4).\n */\nconst MOUNT_ELEMENT_ID = \"root\";\n\n/**\n * Receives the VALIDATED payload and returns the ReactNode to hydrate. A\n * callback rather than a ready-made ReactNode: composing the real Layout(Page)\n * tree needs `payload.layoutData`/`pageData`/`shared`, which only exist after\n * `readHydrationPayload()` has already succeeded — building the tree first and\n * validating second would get the order backwards.\n *\n * A Promise is allowed because the real composer resolves the page's chunk\n * through the registry's dynamic `import()`. Returning it does NOT move the\n * payload check later: the callback is still only reached once the payload has\n * validated, and only the tree it produces is awaited.\n */\nexport type BuildHydratedTree = (\n payload: HydrationDocumentPayloadSource,\n) => ReactNode | Promise<ReactNode>;\n\nfunction isPromise(value: ReactNode | Promise<ReactNode>): value is Promise<ReactNode> {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as { then?: unknown }).then === \"function\"\n );\n}\n\n/**\n * The awaited path's failure sink. By the time a `buildTree` promise rejects\n * the synchronous stack is gone, so there is no caller left to throw at — and\n * an un-attached rejection is a blank console, which is the silent-failure\n * class this pipeline keeps regressing into. Reported loudly, and deliberately\n * WITHOUT touching `#root`: the server's markup is correct and visible, it is\n * only un-hydrated, so clearing it would turn a degraded page into a blank one.\n */\nfunction reportHydrationFailure(error: unknown): void {\n console.error(\n \"Warlock hydration failed after the page tree was requested. The server-rendered \" +\n \"markup is left on screen un-hydrated; #root was not cleared.\",\n error,\n );\n}\n\n/**\n * The one hydration entry point. Mounts at `#root` only — the page subtree —\n * never `document`/`html`/`head`/`body` (hydration-payload-contract-2026-08-22.md\n * §2): `metadata`, `lang`, `dir`, `nonce` are not in the client payload and are\n * never touched here. `readHydrationPayload` (web/src/hydration-payload.ts,\n * Vega's slice B) is the one place ABSENT/MALFORMED are decided, so this\n * function does not re-implement that check — reusing it is what keeps the\n * two throw messages from drifting apart at a second site. On ABSENT/MALFORMED\n * it throws before touching `#root`, so the server-rendered markup stays\n * visible; nothing is cleared or re-rendered.\n *\n * Order is load-bearing and unchanged by the async tree: payload validated,\n * shared snapshot installed, `#root` resolved and its absence thrown on — all\n * SYNCHRONOUSLY, so those three failures still reject the call itself — and\n * only then is the tree built and, if it is a promise, awaited. Nothing is\n * cleared on any failure path.\n */\nexport function hydratePage(buildTree: BuildHydratedTree): void {\n const payload = readHydrationPayload(document);\n\n hydrateShared(payload.shared);\n\n const mountElement = document.getElementById(MOUNT_ELEMENT_ID);\n\n if (mountElement === null) {\n throw new Error(\n `Warlock hydration aborted: no element with id \"${MOUNT_ELEMENT_ID}\" was found. The ` +\n 'server is expected to render <div id=\"root\"> as the hydration mount point ' +\n \"(web/src/components/default-app.tsx:39).\",\n );\n }\n\n const value: DocumentContextValue = { metadata: undefined, payload };\n\n const mount = (tree: ReactNode): void => {\n hydrateRoot(\n mountElement,\n <DocumentContext.Provider value={value}>{tree}</DocumentContext.Provider>,\n );\n };\n\n const tree = buildTree(payload);\n\n if (isPromise(tree)) {\n // `void` on an ALREADY-handled chain: the rejection handler is attached\n // here, so nothing escapes as an unhandled rejection.\n void tree.then(mount, reportHydrationFailure);\n\n return;\n }\n\n mount(tree);\n}"],"mappings":";;;;;;;;;;;;;;;AAcA,MAAM,mBAAmB;AAkBzB,SAAS,UAAU,OAAoE;CACrF,OACE,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAA6B,SAAS;AAElD;;;;;;;;;AAUA,SAAS,uBAAuB,OAAsB;CACpD,QAAQ,MACN,gJAEA,KACF;AACF;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,YAAY,WAAoC;CAC9D,MAAM,UAAU,qBAAqB,QAAQ;CAE7C,cAAc,QAAQ,MAAM;CAE5B,MAAM,eAAe,SAAS,eAAe,gBAAgB;CAE7D,IAAI,iBAAiB,MACnB,MAAM,IAAI,MACR,kDAAkD,iBAAiB,oIAGrE;CAGF,MAAM,QAA8B;EAAE,UAAU;EAAW;CAAQ;CAEnE,MAAM,SAAS,SAA0B;EACvC,YACE,cACA,oBAAC,gBAAgB,UAAjB;GAAiC;aAAQ;EAA+B,EAC1E;CACF;CAEA,MAAM,OAAO,UAAU,OAAO;CAE9B,IAAI,UAAU,IAAI,GAAG;EAGnB,AAAK,KAAK,KAAK,OAAO,sBAAsB;EAE5C;CACF;CAEA,MAAM,IAAI;AACZ"}
1
+ {"version":3,"file":"hydrate-page.mjs","names":[],"sources":["C:/Work/Warlock.js/web/src/client/hydrate-page.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { hydrateRoot } from \"react-dom/client\";\nimport { DocumentContext, type DocumentContextValue } from \"../components/document-context\";\nimport { readHydrationPayload, type HydrationDocumentPayloadSource } from \"../hydration-payload\";\nimport { hydrateShared } from \"../shared\";\n\n/**\n * The hydration MOUNT point — a different id from the payload script's id.\n * Not exported anywhere as a named constant (`default-app.tsx:39` only\n * renders the literal `<div id=\"root\">`), so a local literal is fine here:\n * the contract's no-duplicate-literal rule is specifically about the payload\n * script id, which `readHydrationPayload` already owns exclusively\n * (hydration-payload-contract-2026-08-22.md §4).\n */\nconst MOUNT_ELEMENT_ID = \"root\";\n\n/**\n * Receives the VALIDATED payload and returns the ReactNode to hydrate. A\n * callback rather than a ready-made ReactNode: composing the real Layout(Page)\n * tree needs `payload.layoutData`/`pageData`/`shared`, which only exist after\n * `readHydrationPayload()` has already succeeded — building the tree first and\n * validating second would get the order backwards.\n *\n * A Promise is allowed because the real composer resolves the page's chunk\n * through the registry's dynamic `import()`. Returning it does NOT move the\n * payload check later: the callback is still only reached once the payload has\n * validated, and only the tree it produces is awaited.\n */\nexport type BuildHydratedTree = (\n payload: HydrationDocumentPayloadSource,\n) => ReactNode | Promise<ReactNode>;\n\nfunction isPromise(value: ReactNode | Promise<ReactNode>): value is Promise<ReactNode> {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as { then?: unknown }).then === \"function\"\n );\n}\n\n/**\n * The awaited path's failure sink. By the time a `buildTree` promise rejects\n * the synchronous stack is gone, so there is no caller left to throw at — and\n * an un-attached rejection is a blank console, which is the silent-failure\n * class this pipeline keeps regressing into. Reported loudly, and deliberately\n * WITHOUT touching `#root`: the server's markup is correct and visible, it is\n * only un-hydrated, so clearing it would turn a degraded page into a blank one.\n */\nfunction reportHydrationFailure(error: unknown): void {\n console.error(\n \"Warlock hydration failed after the page tree was requested. The server-rendered \" +\n \"markup is left on screen un-hydrated; #root was not cleared.\",\n error,\n );\n}\n\n/**\n * The one hydration entry point. Mounts at `#root` only — the page subtree —\n * never `document`/`html`/`head`/`body` (hydration-payload-contract-2026-08-22.md\n * §2): `metadata`, `lang`, `dir`, `nonce` are not in the client payload and are\n * never touched here. `readHydrationPayload` (web/src/hydration-payload.ts,\n * Vega's slice B) is the one place ABSENT/MALFORMED are decided, so this\n * function does not re-implement that check — reusing it is what keeps the\n * two throw messages from drifting apart at a second site. On ABSENT/MALFORMED\n * it throws before touching `#root`, so the server-rendered markup stays\n * visible; nothing is cleared or re-rendered.\n *\n * Order is load-bearing and unchanged by the async tree: payload validated,\n * shared snapshot installed, `#root` resolved and its absence thrown on — all\n * SYNCHRONOUSLY, so those three failures still reject the call itself — and\n * only then is the tree built and, if it is a promise, awaited. Nothing is\n * cleared on any failure path.\n */\nexport function hydratePage(buildTree: BuildHydratedTree): void {\n const payload = readHydrationPayload(document);\n\n hydrateShared(payload.shared);\n\n const mountElement = document.getElementById(MOUNT_ELEMENT_ID);\n\n if (mountElement === null) {\n throw new Error(\n `Warlock hydration aborted: no element with id \"${MOUNT_ELEMENT_ID}\" was found. The ` +\n 'server is expected to render <div id=\"root\"> as the hydration mount point ' +\n \"(web/src/components/default-app.tsx:39).\",\n );\n }\n\n const value: DocumentContextValue = { metadata: undefined, payload };\n\n const mount = (tree: ReactNode): void => {\n hydrateRoot(\n mountElement,\n <DocumentContext.Provider value={value}>{tree}</DocumentContext.Provider>,\n );\n };\n\n const tree = buildTree(payload);\n\n if (isPromise(tree)) {\n // `void` on an ALREADY-handled chain: the rejection handler is attached\n // here, so nothing escapes as an unhandled rejection.\n void tree.then(mount, reportHydrationFailure);\n\n return;\n }\n\n mount(tree);\n}"],"mappings":";;;;;;;;;;;;;;;AAcA,MAAM,mBAAmB;AAkBzB,SAAS,UAAU,OAAoE;CACrF,OACE,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAA6B,SAAS;AAElD;;;;;;;;;AAUA,SAAS,uBAAuB,OAAsB;CACpD,QAAQ,MACN,gJAEA,KACF;AACF;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,YAAY,WAAoC;CAC9D,MAAM,UAAU,qBAAqB,QAAQ;CAE7C,cAAc,QAAQ,MAAM;CAE5B,MAAM,eAAe,SAAS,eAAe,gBAAgB;CAE7D,IAAI,iBAAiB,MACnB,MAAM,IAAI,MACR,kDAAkD,iBAAiB,oIAGrE;CAGF,MAAM,QAA8B;EAAE,UAAU;EAAW;CAAQ;CAEnE,MAAM,SAAS,SAA0B;EACvC,YACE,cACA,oBAAC,gBAAgB,UAAjB;GAAiC;aAAQ;EAA+B,EAC1E;CACF;CAEA,MAAM,OAAO,UAAU,OAAO;CAE9B,IAAI,UAAU,IAAI,GAAG;EAGnB,AAAK,KAAK,KAAK,OAAO,sBAAsB;EAE5C;CACF;CAEA,MAAM,IAAI;AACZ"}
@@ -1,4 +1,4 @@
1
- //#region ../web/src/client/navigation/current-route.d.ts
1
+ //#region web/src/client/navigation/current-route.d.ts
2
2
  /**
3
3
  * "Which route am I on?", under the name MRR spells it
4
4
  * (`@mongez/react-router` — `src/utilities.tsx`).
@@ -1,4 +1,4 @@
1
- //#region ../web/src/client/navigation/current-route.ts
1
+ //#region web/src/client/navigation/current-route.ts
2
2
  let current;
3
3
  let previous;
4
4
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"current-route.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/current-route.ts"],"sourcesContent":["import type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\n\n/**\n * \"Which route am I on?\", under the name MRR spells it\n * (`@mongez/react-router` — `src/utilities.tsx`).\n *\n * ## The shift, and it is the whole point of this file\n *\n * MRR's `currentRoute()` returns the object ITS MATCHER produced: MRR is a CSR\n * router, so the browser matched the URL and the answer is the browser's own\n * conclusion. Warlock has no client matcher and must never grow one (canon\n * `9c8f878b`) — the SERVER router is the only matcher, and a second one can\n * disagree with it about the very request it is hydrating.\n *\n * So the name is MRR's because the QUESTION is the same one, but the ANSWER\n * comes from somewhere else: the matched entry's name AND ITS PARAMS travel on\n * the hydration payload (`web/src/components/document-context.ts`), and this\n * module reports what arrived. Nothing here parses a path, compares a URL to a\n * pattern, or knows that routes have shapes.\n *\n * The practical consequence for a caller: this is the server's match, so it is\n * as correct as the page on screen and it cannot drift from it — including on\n * the very first render, before any client navigation, which is most page\n * views.\n *\n * ## The params, and where they come from\n *\n * `bundle.route.params` (`web/src/server/execute-page-request.ts:288`) is the\n * router's own answer; `buildHydrationPayload` puts it on the payload and the\n * projection below hands it over unchanged. Deriving `{ id: \"42\" }` from\n * `location.pathname` here would BE the second matcher this file exists to\n * refuse — a URL and a pattern are exactly what it must never compare.\n *\n * ## Why nothing here touches `window`\n *\n * Both readers are importable from universal modules, so both can be CALLED\n * during the server render and in the gap between first paint and hydration.\n * They read module state and nothing else, so there is no browser to be missing\n * — the answer in those contexts is `undefined`, meaning \"no page has been\n * rendered into this module\", which is exactly true.\n */\n\n/**\n * What the SERVER matched for the page currently on screen.\n *\n * Deliberately not `ClientRouteMatch` (`web/src/client/runtime/types.ts`): that\n * is the deprecated client matcher's output shape, entry object and all, and\n * this is the opposite claim — a name the server sent us.\n */\nexport type MatchedRoute = {\n /** The matched page manifest entry's stable `name`, e.g. `products.details`. */\n readonly name: string;\n /**\n * The params the SERVER matched, e.g. `{ id: \"42\" }` for `/users/:id`, and\n * `{}` for a route with no dynamic segments.\n *\n * `undefined` means the payload carried no `params` key — an older build, or\n * a document cached across a deploy. It is NOT the same answer as `{}`, and\n * this module will not collapse the two: `{}` is the server saying \"this\n * route has no params\", `undefined` is the server not having said. Inventing\n * the first from the second would be a lie a caller cannot detect, which is\n * the same standard {@link previousRoute} is held to below. Every payload a\n * current server produces carries the key.\n *\n * A COPY of the payload's object, so a caller writing to it cannot reach the\n * payload the page was built from.\n */\n readonly params?: Readonly<Record<string, string>>;\n};\n\nlet current: MatchedRoute | undefined;\nlet previous: MatchedRoute | undefined;\n\n/**\n * The payload `current` was projected from, kept ONLY to recognise it again.\n *\n * `NavigationRoot` records on every render pass, because a page component must\n * be able to call `currentRoute()` while it is itself rendering — including on\n * the initial mount, where no effect has run yet. Renders are not navigations\n * though: StrictMode invokes them twice, and a parent re-render invokes them\n * again for free. One swap is one payload OBJECT, so identity is what separates\n * \"we moved\" from \"we rendered again\". Comparing names instead would both miss\n * a `/users/1` → `/users/2` move and invent one out of a double render.\n */\nlet source: HydrationDocumentPayloadSource | undefined;\n\n/**\n * Record the payload the page on screen was built from.\n *\n * Called by `NavigationRoot` during render — at mount with the hydration\n * payload, and after each swap with the fetched one. Not part of the public\n * surface: the payload is the navigation runtime's to hand over, and a caller\n * setting the current route by hand would be asserting a match that never\n * happened.\n *\n * Idempotent per payload object, so re-rendering the same page never shifts\n * {@link previousRoute}.\n */\nexport function recordCurrentRoute(payload: HydrationDocumentPayloadSource): void {\n if (source === payload) return;\n\n source = payload;\n previous = current;\n // A COPY when the payload carried params, and no key at all when it did not\n // — the projection reports what arrived and never fills a gap in.\n current =\n payload.params === undefined\n ? { name: payload.name }\n : { name: payload.name, params: { ...payload.params } };\n}\n\n/**\n * @returns what the SERVER matched for the page on screen — see this file's\n * header for why that is the answer and not a client-side match. Correct from\n * the first render of the initial page, since the hydration payload carried the\n * match with it.\n *\n * `undefined` means no page has been rendered into this module: a server\n * render, or an import evaluated before hydration mounted. Safe to call in\n * either — it does not throw and does not touch `window`.\n */\nexport function currentRoute(): MatchedRoute | undefined {\n return current;\n}\n\n/**\n * @returns the entry that was on screen BEFORE the current one, or `undefined`\n * when the current page is the one the user landed on. That `undefined` is a\n * real answer, not a missing one — there is no previous route on a first visit,\n * and reporting the current one would be a lie a caller cannot detect.\n *\n * This is the previously SWAPPED page, not the previous history entry: pressing\n * Back is itself a navigation here, so going A → B → Back leaves the previous\n * route as B. Safe to call with no browser.\n */\nexport function previousRoute(): MatchedRoute | undefined {\n return previous;\n}\n"],"mappings":";AAsEA,IAAI;AACJ,IAAI;;;;;;;;;;;;AAaJ,IAAI;;;;;;;;;;;;;AAcJ,SAAgB,mBAAmB,SAA+C;CAChF,IAAI,WAAW,SAAS;CAExB,SAAS;CACT,WAAW;CAGX,UACE,QAAQ,WAAW,SACf,EAAE,MAAM,QAAQ,KAAK,IACrB;EAAE,MAAM,QAAQ;EAAM,QAAQ,EAAE,GAAG,QAAQ,OAAO;CAAE;AAC5D;;;;;;;;;;;AAYA,SAAgB,eAAyC;CACvD,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,gBAA0C;CACxD,OAAO;AACT"}
1
+ {"version":3,"file":"current-route.mjs","names":[],"sources":["C:/Work/Warlock.js/web/src/client/navigation/current-route.ts"],"sourcesContent":["import type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\n\n/**\n * \"Which route am I on?\", under the name MRR spells it\n * (`@mongez/react-router` — `src/utilities.tsx`).\n *\n * ## The shift, and it is the whole point of this file\n *\n * MRR's `currentRoute()` returns the object ITS MATCHER produced: MRR is a CSR\n * router, so the browser matched the URL and the answer is the browser's own\n * conclusion. Warlock has no client matcher and must never grow one (canon\n * `9c8f878b`) — the SERVER router is the only matcher, and a second one can\n * disagree with it about the very request it is hydrating.\n *\n * So the name is MRR's because the QUESTION is the same one, but the ANSWER\n * comes from somewhere else: the matched entry's name AND ITS PARAMS travel on\n * the hydration payload (`web/src/components/document-context.ts`), and this\n * module reports what arrived. Nothing here parses a path, compares a URL to a\n * pattern, or knows that routes have shapes.\n *\n * The practical consequence for a caller: this is the server's match, so it is\n * as correct as the page on screen and it cannot drift from it — including on\n * the very first render, before any client navigation, which is most page\n * views.\n *\n * ## The params, and where they come from\n *\n * `bundle.route.params` (`web/src/server/execute-page-request.ts:288`) is the\n * router's own answer; `buildHydrationPayload` puts it on the payload and the\n * projection below hands it over unchanged. Deriving `{ id: \"42\" }` from\n * `location.pathname` here would BE the second matcher this file exists to\n * refuse — a URL and a pattern are exactly what it must never compare.\n *\n * ## Why nothing here touches `window`\n *\n * Both readers are importable from universal modules, so both can be CALLED\n * during the server render and in the gap between first paint and hydration.\n * They read module state and nothing else, so there is no browser to be missing\n * — the answer in those contexts is `undefined`, meaning \"no page has been\n * rendered into this module\", which is exactly true.\n */\n\n/**\n * What the SERVER matched for the page currently on screen.\n *\n * Deliberately not `ClientRouteMatch` (`web/src/client/runtime/types.ts`): that\n * is the deprecated client matcher's output shape, entry object and all, and\n * this is the opposite claim — a name the server sent us.\n */\nexport type MatchedRoute = {\n /** The matched page manifest entry's stable `name`, e.g. `products.details`. */\n readonly name: string;\n /**\n * The params the SERVER matched, e.g. `{ id: \"42\" }` for `/users/:id`, and\n * `{}` for a route with no dynamic segments.\n *\n * `undefined` means the payload carried no `params` key — an older build, or\n * a document cached across a deploy. It is NOT the same answer as `{}`, and\n * this module will not collapse the two: `{}` is the server saying \"this\n * route has no params\", `undefined` is the server not having said. Inventing\n * the first from the second would be a lie a caller cannot detect, which is\n * the same standard {@link previousRoute} is held to below. Every payload a\n * current server produces carries the key.\n *\n * A COPY of the payload's object, so a caller writing to it cannot reach the\n * payload the page was built from.\n */\n readonly params?: Readonly<Record<string, string>>;\n};\n\nlet current: MatchedRoute | undefined;\nlet previous: MatchedRoute | undefined;\n\n/**\n * The payload `current` was projected from, kept ONLY to recognise it again.\n *\n * `NavigationRoot` records on every render pass, because a page component must\n * be able to call `currentRoute()` while it is itself rendering — including on\n * the initial mount, where no effect has run yet. Renders are not navigations\n * though: StrictMode invokes them twice, and a parent re-render invokes them\n * again for free. One swap is one payload OBJECT, so identity is what separates\n * \"we moved\" from \"we rendered again\". Comparing names instead would both miss\n * a `/users/1` → `/users/2` move and invent one out of a double render.\n */\nlet source: HydrationDocumentPayloadSource | undefined;\n\n/**\n * Record the payload the page on screen was built from.\n *\n * Called by `NavigationRoot` during render — at mount with the hydration\n * payload, and after each swap with the fetched one. Not part of the public\n * surface: the payload is the navigation runtime's to hand over, and a caller\n * setting the current route by hand would be asserting a match that never\n * happened.\n *\n * Idempotent per payload object, so re-rendering the same page never shifts\n * {@link previousRoute}.\n */\nexport function recordCurrentRoute(payload: HydrationDocumentPayloadSource): void {\n if (source === payload) return;\n\n source = payload;\n previous = current;\n // A COPY when the payload carried params, and no key at all when it did not\n // — the projection reports what arrived and never fills a gap in.\n current =\n payload.params === undefined\n ? { name: payload.name }\n : { name: payload.name, params: { ...payload.params } };\n}\n\n/**\n * @returns what the SERVER matched for the page on screen — see this file's\n * header for why that is the answer and not a client-side match. Correct from\n * the first render of the initial page, since the hydration payload carried the\n * match with it.\n *\n * `undefined` means no page has been rendered into this module: a server\n * render, or an import evaluated before hydration mounted. Safe to call in\n * either — it does not throw and does not touch `window`.\n */\nexport function currentRoute(): MatchedRoute | undefined {\n return current;\n}\n\n/**\n * @returns the entry that was on screen BEFORE the current one, or `undefined`\n * when the current page is the one the user landed on. That `undefined` is a\n * real answer, not a missing one — there is no previous route on a first visit,\n * and reporting the current one would be a lie a caller cannot detect.\n *\n * This is the previously SWAPPED page, not the previous history entry: pressing\n * Back is itself a navigation here, so going A → B → Back leaves the previous\n * route as B. Safe to call with no browser.\n */\nexport function previousRoute(): MatchedRoute | undefined {\n return previous;\n}\n"],"mappings":";AAsEA,IAAI;AACJ,IAAI;;;;;;;;;;;;AAaJ,IAAI;;;;;;;;;;;;;AAcJ,SAAgB,mBAAmB,SAA+C;CAChF,IAAI,WAAW,SAAS;CAExB,SAAS;CACT,WAAW;CAGX,UACE,QAAQ,WAAW,SACf,EAAE,MAAM,QAAQ,KAAK,IACrB;EAAE,MAAM,QAAQ;EAAM,QAAQ,EAAE,GAAG,QAAQ,OAAO;CAAE;AAC5D;;;;;;;;;;;AAYA,SAAgB,eAAyC;CACvD,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,gBAA0C;CACxD,OAAO;AACT"}
@@ -1,6 +1,6 @@
1
1
  import { DATA_RESPONSE_CONTENT_TYPE, WARLOCK_DATA_REQUEST_HEADER } from "../../routing/data-request.mjs";
2
2
 
3
- //#region ../web/src/client/navigation/fetch-page-data.ts
3
+ //#region web/src/client/navigation/fetch-page-data.ts
4
4
  /**
5
5
  * Ask the server for a URL's page data instead of its document.
6
6
  *
@@ -1 +1 @@
1
- {"version":3,"file":"fetch-page-data.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/fetch-page-data.ts"],"sourcesContent":["/**\n * Ask the server for a URL's page data instead of its document.\n *\n * This is the browser half of the representation split: the same route the user\n * would have loaded, asked for as JSON via the `x-warlock-data` marker. What\n * comes back is exactly the payload a full page load embeds, so the caller can\n * rebuild the tree from it with no second code path.\n *\n * ## Every failure degrades to a REAL navigation, never to an error screen\n *\n * A client navigation is an OPTIMISATION over what the browser already does\n * perfectly well. So nothing here reports a failure to the user — it reports\n * `hard-navigate`, and the caller hands the URL back to the browser. The user\n * gets the page; they just get it the slow way.\n *\n * That is what makes the whole feature safe to add: the worst case of a bug in\n * this file is the behaviour we had before the file existed. Rendering our own\n * \"navigation failed\" state would be strictly worse than the fallback we\n * already have, and would turn every unhandled edge — an auth redirect to an\n * external IdP, a maintenance page, a proxy that strips the header, a deploy\n * that changed the payload shape mid-session — into a dead end.\n */\nimport {\n DATA_RESPONSE_CONTENT_TYPE,\n WARLOCK_DATA_REQUEST_HEADER,\n WARLOCK_DATA_REQUEST_VALUE,\n} from \"../../routing/data-request\";\nimport type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\n\nexport type PageDataResult =\n | {\n type: \"payload\";\n /**\n * The payload to rebuild the tree from.\n */\n payload: HydrationDocumentPayloadSource;\n /**\n * The URL the response actually came from — NOT the one requested. A\n * redirect is followed by `fetch` transparently, so a login-required page\n * answers from `/login`, and pushing the requested URL into history would\n * leave the address bar lying about what is on screen.\n */\n url: string;\n }\n | {\n type: \"hard-navigate\";\n url: string;\n /** Why, for a console warning — never shown to the user. */\n reason: string;\n };\n\n/**\n * Whether the body is the payload we asked for.\n *\n * Checked rather than assumed because a 200 does not mean \"this came from the\n * page pipeline\": a captive portal, an SSO interstitial or a proxy error page\n * all answer 200 with HTML. Parsing that as JSON would throw; treating a\n * successful parse of *something else* as a payload would render garbage.\n */\nfunction isPayloadResponse(response: Response): boolean {\n return (response.headers.get(\"content-type\") ?? \"\").includes(DATA_RESPONSE_CONTENT_TYPE);\n}\n\n/**\n * The shape check, kept deliberately narrow: `name` is the only field the tree\n * builder cannot proceed without — it selects the page. The data fields are\n * page-defined and may legitimately be anything, including `null`.\n */\nfunction isPayloadShape(value: unknown): value is HydrationDocumentPayloadSource {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as { name?: unknown }).name === \"string\"\n );\n}\n\nexport async function fetchPageData(url: string): Promise<PageDataResult> {\n let response: Response;\n\n try {\n response = await fetch(url, {\n headers: {\n [WARLOCK_DATA_REQUEST_HEADER]: WARLOCK_DATA_REQUEST_VALUE,\n accept: DATA_RESPONSE_CONTENT_TYPE,\n },\n // Same-origin credentials so a navigation carries the session exactly as\n // a document request would. Without this a client navigation could be\n // logged out while a full load of the same URL is not.\n credentials: \"same-origin\",\n // Redirects are FOLLOWED, not intercepted: the marker header is re-sent,\n // so the destination answers with a payload too, and `response.url` tells\n // us where we ended up. Handling redirects ourselves would mean\n // re-implementing the rules the browser already has.\n redirect: \"follow\",\n });\n } catch (error) {\n // Offline, DNS, CORS, an aborted connection. The browser can render its own\n // network error far better than we can fake one.\n return { type: \"hard-navigate\", url, reason: `request failed: ${String(error)}` };\n }\n\n if (!response.ok) {\n // 404, 500, 403 — all of these have a real page the server renders. Letting\n // the browser load it gets the correct status AND the correct document,\n // rather than us inventing a client-side error state that the server's own\n // error page already covers.\n return { type: \"hard-navigate\", url, reason: `status ${response.status}` };\n }\n\n if (!isPayloadResponse(response)) {\n return {\n type: \"hard-navigate\",\n url,\n reason: `unexpected content-type \"${response.headers.get(\"content-type\") ?? \"none\"}\"`,\n };\n }\n\n let parsed: unknown;\n\n try {\n parsed = await response.json();\n } catch (error) {\n return { type: \"hard-navigate\", url, reason: `malformed JSON: ${String(error)}` };\n }\n\n if (!isPayloadShape(parsed)) {\n return { type: \"hard-navigate\", url, reason: \"payload has no route name\" };\n }\n\n // `response.url` is absolute and reflects any redirect that was followed.\n // Falling back to the requested URL keeps this working under test doubles\n // that do not set it.\n return { type: \"payload\", payload: parsed, url: response.url || url };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2DA,SAAS,kBAAkB,UAA6B;CACtD,QAAQ,SAAS,QAAQ,IAAI,cAAc,KAAK,GAAE,CAAE,SAAS,0BAA0B;AACzF;;;;;;AAOA,SAAS,eAAe,OAAyD;CAC/E,OACE,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAA6B,SAAS;AAElD;AAEA,eAAsB,cAAc,KAAsC;CACxE,IAAI;CAEJ,IAAI;EACF,WAAW,MAAM,MAAM,KAAK;GAC1B,SAAS;KACN;IACD,QAAQ;GACV;GAIA,aAAa;GAKb,UAAU;EACZ,CAAC;CACH,SAAS,OAAO;EAGd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,SAAS,IAKZ,OAAO;EAAE,MAAM;EAAiB;EAAK,QAAQ,UAAU,SAAS;CAAS;CAG3E,IAAI,CAAC,kBAAkB,QAAQ,GAC7B,OAAO;EACL,MAAM;EACN;EACA,QAAQ,4BAA4B,SAAS,QAAQ,IAAI,cAAc,KAAK,OAAO;CACrF;CAGF,IAAI;CAEJ,IAAI;EACF,SAAS,MAAM,SAAS,KAAK;CAC/B,SAAS,OAAO;EACd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,eAAe,MAAM,GACxB,OAAO;EAAE,MAAM;EAAiB;EAAK,QAAQ;CAA4B;CAM3E,OAAO;EAAE,MAAM;EAAW,SAAS;EAAQ,KAAK,SAAS,OAAO;CAAI;AACtE"}
1
+ {"version":3,"file":"fetch-page-data.mjs","names":[],"sources":["C:/Work/Warlock.js/web/src/client/navigation/fetch-page-data.ts"],"sourcesContent":["/**\n * Ask the server for a URL's page data instead of its document.\n *\n * This is the browser half of the representation split: the same route the user\n * would have loaded, asked for as JSON via the `x-warlock-data` marker. What\n * comes back is exactly the payload a full page load embeds, so the caller can\n * rebuild the tree from it with no second code path.\n *\n * ## Every failure degrades to a REAL navigation, never to an error screen\n *\n * A client navigation is an OPTIMISATION over what the browser already does\n * perfectly well. So nothing here reports a failure to the user — it reports\n * `hard-navigate`, and the caller hands the URL back to the browser. The user\n * gets the page; they just get it the slow way.\n *\n * That is what makes the whole feature safe to add: the worst case of a bug in\n * this file is the behaviour we had before the file existed. Rendering our own\n * \"navigation failed\" state would be strictly worse than the fallback we\n * already have, and would turn every unhandled edge — an auth redirect to an\n * external IdP, a maintenance page, a proxy that strips the header, a deploy\n * that changed the payload shape mid-session — into a dead end.\n */\nimport {\n DATA_RESPONSE_CONTENT_TYPE,\n WARLOCK_DATA_REQUEST_HEADER,\n WARLOCK_DATA_REQUEST_VALUE,\n} from \"../../routing/data-request\";\nimport type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\n\nexport type PageDataResult =\n | {\n type: \"payload\";\n /**\n * The payload to rebuild the tree from.\n */\n payload: HydrationDocumentPayloadSource;\n /**\n * The URL the response actually came from — NOT the one requested. A\n * redirect is followed by `fetch` transparently, so a login-required page\n * answers from `/login`, and pushing the requested URL into history would\n * leave the address bar lying about what is on screen.\n */\n url: string;\n }\n | {\n type: \"hard-navigate\";\n url: string;\n /** Why, for a console warning — never shown to the user. */\n reason: string;\n };\n\n/**\n * Whether the body is the payload we asked for.\n *\n * Checked rather than assumed because a 200 does not mean \"this came from the\n * page pipeline\": a captive portal, an SSO interstitial or a proxy error page\n * all answer 200 with HTML. Parsing that as JSON would throw; treating a\n * successful parse of *something else* as a payload would render garbage.\n */\nfunction isPayloadResponse(response: Response): boolean {\n return (response.headers.get(\"content-type\") ?? \"\").includes(DATA_RESPONSE_CONTENT_TYPE);\n}\n\n/**\n * The shape check, kept deliberately narrow: `name` is the only field the tree\n * builder cannot proceed without — it selects the page. The data fields are\n * page-defined and may legitimately be anything, including `null`.\n */\nfunction isPayloadShape(value: unknown): value is HydrationDocumentPayloadSource {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as { name?: unknown }).name === \"string\"\n );\n}\n\nexport async function fetchPageData(url: string): Promise<PageDataResult> {\n let response: Response;\n\n try {\n response = await fetch(url, {\n headers: {\n [WARLOCK_DATA_REQUEST_HEADER]: WARLOCK_DATA_REQUEST_VALUE,\n accept: DATA_RESPONSE_CONTENT_TYPE,\n },\n // Same-origin credentials so a navigation carries the session exactly as\n // a document request would. Without this a client navigation could be\n // logged out while a full load of the same URL is not.\n credentials: \"same-origin\",\n // Redirects are FOLLOWED, not intercepted: the marker header is re-sent,\n // so the destination answers with a payload too, and `response.url` tells\n // us where we ended up. Handling redirects ourselves would mean\n // re-implementing the rules the browser already has.\n redirect: \"follow\",\n });\n } catch (error) {\n // Offline, DNS, CORS, an aborted connection. The browser can render its own\n // network error far better than we can fake one.\n return { type: \"hard-navigate\", url, reason: `request failed: ${String(error)}` };\n }\n\n if (!response.ok) {\n // 404, 500, 403 — all of these have a real page the server renders. Letting\n // the browser load it gets the correct status AND the correct document,\n // rather than us inventing a client-side error state that the server's own\n // error page already covers.\n return { type: \"hard-navigate\", url, reason: `status ${response.status}` };\n }\n\n if (!isPayloadResponse(response)) {\n return {\n type: \"hard-navigate\",\n url,\n reason: `unexpected content-type \"${response.headers.get(\"content-type\") ?? \"none\"}\"`,\n };\n }\n\n let parsed: unknown;\n\n try {\n parsed = await response.json();\n } catch (error) {\n return { type: \"hard-navigate\", url, reason: `malformed JSON: ${String(error)}` };\n }\n\n if (!isPayloadShape(parsed)) {\n return { type: \"hard-navigate\", url, reason: \"payload has no route name\" };\n }\n\n // `response.url` is absolute and reflects any redirect that was followed.\n // Falling back to the requested URL keeps this working under test doubles\n // that do not set it.\n return { type: \"payload\", payload: parsed, url: response.url || url };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2DA,SAAS,kBAAkB,UAA6B;CACtD,QAAQ,SAAS,QAAQ,IAAI,cAAc,KAAK,IAAI,SAAS,0BAA0B;AACzF;;;;;;AAOA,SAAS,eAAe,OAAyD;CAC/E,OACE,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAA6B,SAAS;AAElD;AAEA,eAAsB,cAAc,KAAsC;CACxE,IAAI;CAEJ,IAAI;EACF,WAAW,MAAM,MAAM,KAAK;GAC1B,SAAS;KACN;IACD,QAAQ;GACV;GAIA,aAAa;GAKb,UAAU;EACZ,CAAC;CACH,SAAS,OAAO;EAGd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,SAAS,IAKZ,OAAO;EAAE,MAAM;EAAiB;EAAK,QAAQ,UAAU,SAAS;CAAS;CAG3E,IAAI,CAAC,kBAAkB,QAAQ,GAC7B,OAAO;EACL,MAAM;EACN;EACA,QAAQ,4BAA4B,SAAS,QAAQ,IAAI,cAAc,KAAK,OAAO;CACrF;CAGF,IAAI;CAEJ,IAAI;EACF,SAAS,MAAM,SAAS,KAAK;CAC/B,SAAS,OAAO;EACd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,eAAe,MAAM,GACxB,OAAO;EAAE,MAAM;EAAiB;EAAK,QAAQ;CAA4B;CAM3E,OAAO;EAAE,MAAM;EAAW,SAAS;EAAQ,KAAK,SAAS,OAAO;CAAI;AACtE"}
@@ -5,10 +5,12 @@ import { connectNavigator } from "../../routing/navigator.mjs";
5
5
  import { recordCurrentRoute } from "./current-route.mjs";
6
6
  import { connectRefresher, createRefresher } from "./refresh.mjs";
7
7
  import { DocumentContext } from "../../components/document-context.mjs";
8
- import { useEffect, useRef, useState } from "react";
8
+ import { fragmentOf, samePageFragment, withFragmentFrom, withoutFragment } from "../../routing/url-fragment.mjs";
9
+ import { scrollToFragment } from "./scroll-to-fragment.mjs";
10
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
9
11
  import { jsx } from "react/jsx-runtime";
10
12
 
11
- //#region ../web/src/client/navigation/navigation-root.tsx
13
+ //#region web/src/client/navigation/navigation-root.tsx
12
14
  function metaTag(attribute, key) {
13
15
  return {
14
16
  selector: `meta[${attribute}="${key}"]`,
@@ -120,10 +122,23 @@ function NavigationRoot({ pages, initialPayload, initialTree, buildTree }) {
120
122
  });
121
123
  const currentRef = useRef(current);
122
124
  currentRef.current = current;
125
+ const pendingFragment = useRef(void 0);
123
126
  useEffect(() => {
124
127
  let token = 0;
125
128
  let disposed = false;
126
- const apply = async (url, replace) => {
129
+ let committedUrl = window.location.href;
130
+ /**
131
+ * @param honourFragment whether the URL's fragment should be SCROLLED to
132
+ * once the new page is on screen. True for a navigation the app asked for
133
+ * — a `<Link>` click, `navigateTo` — and false for Back/Forward, where the
134
+ * browser has already restored the scroll position of the entry being
135
+ * returned to and moving the page again would overwrite the user's own
136
+ * position with the anchor they had scrolled away from. (Restoration is
137
+ * the browser's, deliberately: canon `0342c0d4`.)
138
+ *
139
+ * The fragment is still PRESERVED in the URL in both cases — see below.
140
+ */
141
+ const apply = async (url, replace, honourFragment) => {
127
142
  const ticket = ++token;
128
143
  const result = takePrefetchedPageData(url) ?? await fetchPageData(url);
129
144
  if (disposed || ticket !== token) return;
@@ -142,8 +157,11 @@ function NavigationRoot({ pages, initialPayload, initialTree, buildTree }) {
142
157
  }
143
158
  if (disposed || ticket !== token) return;
144
159
  hydrateShared(result.payload.shared);
145
- if (replace) window.history.replaceState(null, "", result.url);
146
- else window.history.pushState(null, "", result.url);
160
+ const finalUrl = withFragmentFrom(result.url, url);
161
+ if (replace) window.history.replaceState(null, "", finalUrl);
162
+ else window.history.pushState(null, "", finalUrl);
163
+ committedUrl = finalUrl;
164
+ pendingFragment.current = honourFragment ? fragmentOf(finalUrl) : void 0;
147
165
  setCurrent({
148
166
  payload: result.payload,
149
167
  tree,
@@ -161,11 +179,24 @@ function NavigationRoot({ pages, initialPayload, initialTree, buildTree }) {
161
179
  claimTicket
162
180
  }));
163
181
  const previousNavigator = connectNavigator((url, options) => {
164
- apply(url, options?.replace === true);
182
+ const replace = options?.replace === true;
183
+ const fragment = samePageFragment(url, window.location.href);
184
+ if (fragment !== void 0) {
185
+ if (replace) window.history.replaceState(null, "", url);
186
+ else window.history.pushState(null, "", url);
187
+ committedUrl = window.location.href;
188
+ scrollToFragment(document, fragment);
189
+ return true;
190
+ }
191
+ apply(url, replace, true);
165
192
  return true;
166
193
  });
167
194
  const onPopState = () => {
168
- apply(window.location.href, true);
195
+ const target = window.location.href;
196
+ const hashOnlyMove = withoutFragment(target) === withoutFragment(committedUrl);
197
+ committedUrl = target;
198
+ if (hashOnlyMove) return;
199
+ apply(target, true, false);
169
200
  };
170
201
  window.addEventListener("popstate", onPopState);
171
202
  return () => {
@@ -175,6 +206,12 @@ function NavigationRoot({ pages, initialPayload, initialTree, buildTree }) {
175
206
  connectRefresher(previousRefresher);
176
207
  };
177
208
  }, [pages, buildTree]);
209
+ useLayoutEffect(() => {
210
+ const fragment = pendingFragment.current;
211
+ if (fragment === void 0) return;
212
+ pendingFragment.current = void 0;
213
+ scrollToFragment(document, fragment);
214
+ }, [current]);
178
215
  const appliedMetadataSource = useRef(current.payload);
179
216
  useEffect(() => {
180
217
  if (appliedMetadataSource.current === current.payload) return;