@warlock.js/web 5.5.0 → 5.7.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 (153) hide show
  1. package/CHANGELOG.md +53 -5
  2. package/esm/build/contribution.d.mts +2 -2
  3. package/esm/build/contribution.mjs +1 -1
  4. package/esm/build/contribution.mjs.map +1 -1
  5. package/esm/build/discover-pages.mjs +130 -208
  6. package/esm/build/discover-pages.mjs.map +1 -1
  7. package/esm/build/generate-pages-barrel.mjs +7 -5
  8. package/esm/build/generate-pages-barrel.mjs.map +1 -1
  9. package/esm/build/page-routes-manifest.mjs +1 -1
  10. package/esm/build/public-files.mjs +30 -2
  11. package/esm/build/public-files.mjs.map +1 -1
  12. package/esm/build/read-metadata-keys.mjs +225 -0
  13. package/esm/build/read-metadata-keys.mjs.map +1 -0
  14. package/esm/build/read-route-exports.mjs.map +1 -1
  15. package/esm/client/build-hydrated-tree.mjs +1 -1
  16. package/esm/client/build-hydrated-tree.mjs.map +1 -1
  17. package/esm/client/navigation/document-metadata.mjs +108 -0
  18. package/esm/client/navigation/document-metadata.mjs.map +1 -0
  19. package/esm/client/navigation/fetch-page-data.mjs +3 -10
  20. package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
  21. package/esm/client/navigation/navigation-root.mjs +24 -104
  22. package/esm/client/navigation/navigation-root.mjs.map +1 -1
  23. package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -1
  24. package/esm/client/navigation/use-query-string.d.mts +35 -0
  25. package/esm/client/navigation/use-query-string.mjs +68 -0
  26. package/esm/client/navigation/use-query-string.mjs.map +1 -0
  27. package/esm/client/runtime/index.d.mts +1 -1
  28. package/esm/client/runtime/index.mjs +1 -1
  29. package/esm/client/runtime/manifest.mjs.map +1 -1
  30. package/esm/client/runtime/matcher.mjs.map +1 -1
  31. package/esm/client/runtime/types.d.mts +1 -1
  32. package/esm/components/link.mjs.map +1 -1
  33. package/esm/core/src/router/normalize-route-path.mjs +33 -0
  34. package/esm/core/src/router/normalize-route-path.mjs.map +1 -0
  35. package/esm/{hydration → entry}/index.mjs +1 -1
  36. package/esm/entry/index.mjs.map +1 -0
  37. package/esm/hydration-payload.mjs +19 -10
  38. package/esm/hydration-payload.mjs.map +1 -1
  39. package/esm/index.d.mts +3 -2
  40. package/esm/index.mjs +3 -2
  41. package/esm/loaders.d.mts +13 -1
  42. package/esm/localization.mjs.map +1 -1
  43. package/esm/metadata.d.mts +4 -4
  44. package/esm/metadata.mjs.map +1 -1
  45. package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs +33 -0
  46. package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs.map +1 -0
  47. package/esm/{runtime/register-modules.d.mts → register-modules.d.mts} +1 -1
  48. package/esm/{runtime/register-modules.mjs → register-modules.mjs} +1 -1
  49. package/esm/register-modules.mjs.map +1 -0
  50. package/esm/route.d.mts +9 -0
  51. package/esm/routing/duplicate-route-path.mjs +22 -0
  52. package/esm/routing/duplicate-route-path.mjs.map +1 -0
  53. package/esm/routing/layout-level.mjs +50 -0
  54. package/esm/routing/layout-level.mjs.map +1 -0
  55. package/esm/routing/layout-policy.mjs.map +1 -1
  56. package/esm/routing/query-string.d.mts +23 -1
  57. package/esm/routing/query-string.mjs +23 -1
  58. package/esm/routing/query-string.mjs.map +1 -1
  59. package/esm/routing/route-identity.mjs +27 -2
  60. package/esm/routing/route-identity.mjs.map +1 -1
  61. package/esm/routing/route-table.mjs.map +1 -1
  62. package/esm/routing/router-events.d.mts +5 -4
  63. package/esm/routing/router-events.mjs.map +1 -1
  64. package/esm/server/create-page-route-handler.d.mts +2 -2
  65. package/esm/server/create-page-route-handler.mjs +18 -12
  66. package/esm/server/create-page-route-handler.mjs.map +1 -1
  67. package/esm/server/{dev-server.mjs → dev-error-transport.mjs} +2 -2
  68. package/esm/server/dev-error-transport.mjs.map +1 -0
  69. package/esm/server/execute-page-request.mjs +91 -35
  70. package/esm/server/execute-page-request.mjs.map +1 -1
  71. package/esm/server/execute-page-request.types.d.mts +21 -1
  72. package/esm/server/fold-layout-loaders.mjs +36 -0
  73. package/esm/server/fold-layout-loaders.mjs.map +1 -0
  74. package/esm/server/hydration-client-url.mjs +3 -3
  75. package/esm/server/hydration-client-url.mjs.map +1 -1
  76. package/esm/server/index.d.mts +4 -3
  77. package/esm/server/index.mjs +6 -6
  78. package/esm/server/install-page-routes-from-manifest.d.mts +10 -1
  79. package/esm/server/install-page-routes-from-manifest.mjs +61 -58
  80. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
  81. package/esm/server/install-page-routes.d.mts +24 -26
  82. package/esm/server/install-page-routes.mjs +149 -60
  83. package/esm/server/install-page-routes.mjs.map +1 -1
  84. package/esm/server/install-production-page-routes.mjs.map +1 -1
  85. package/esm/server/match-page-route.mjs +6 -10
  86. package/esm/server/match-page-route.mjs.map +1 -1
  87. package/esm/server/not-found-page.d.mts +1 -0
  88. package/esm/server/not-found-page.mjs +8 -1
  89. package/esm/server/not-found-page.mjs.map +1 -1
  90. package/esm/server/page-file-change.mjs +1 -1
  91. package/esm/server/page-file-change.mjs.map +1 -1
  92. package/esm/server/page-module-shapes.d.mts +24 -0
  93. package/esm/server/page-route-reload.mjs +4 -3
  94. package/esm/server/page-route-reload.mjs.map +1 -1
  95. package/esm/server/register-production-public-files.mjs +26 -2
  96. package/esm/server/register-production-public-files.mjs.map +1 -1
  97. package/esm/server/render-page.d.mts +13 -49
  98. package/esm/server/render-page.mjs +14 -55
  99. package/esm/server/render-page.mjs.map +1 -1
  100. package/esm/server/resolve-page-metadata.mjs.map +1 -1
  101. package/esm/server/resolve-route-validation-input.mjs +11 -0
  102. package/esm/server/resolve-route-validation-input.mjs.map +1 -0
  103. package/esm/server/resolve-validation-data.mjs +1 -1
  104. package/esm/server/resolve-validation-data.mjs.map +1 -1
  105. package/esm/server/settle-page-response.mjs +5 -3
  106. package/esm/server/settle-page-response.mjs.map +1 -1
  107. package/esm/server/stylesheet-urls.mjs +78 -19
  108. package/esm/server/stylesheet-urls.mjs.map +1 -1
  109. package/esm/server/unregistered-pages.mjs +2 -1
  110. package/esm/server/unregistered-pages.mjs.map +1 -1
  111. package/esm/server/web-connector-factory.mjs +1 -1
  112. package/esm/server/web-connector-factory.mjs.map +1 -1
  113. package/esm/server/web-connector.mjs +25 -322
  114. package/esm/server/web-connector.mjs.map +1 -1
  115. package/esm/shared/module-key.mjs +15 -0
  116. package/esm/shared/module-key.mjs.map +1 -0
  117. package/esm/shared/to-posix.mjs +13 -0
  118. package/esm/shared/to-posix.mjs.map +1 -0
  119. package/esm/shared.mjs +24 -0
  120. package/esm/shared.mjs.map +1 -1
  121. package/esm/validation.d.mts +12 -1
  122. package/esm/vite/build-client.mjs +1 -1
  123. package/esm/vite/build-client.mjs.map +1 -1
  124. package/esm/vite/dev-server-config.mjs +240 -0
  125. package/esm/vite/dev-server-config.mjs.map +1 -0
  126. package/esm/vite/gate-a-resolve.d.mts +5 -4
  127. package/esm/vite/gate-a-resolve.mjs +32 -71
  128. package/esm/vite/gate-a-resolve.mjs.map +1 -1
  129. package/esm/vite/gate-b-secrets.mjs.map +1 -1
  130. package/esm/vite/gate-c-verify.d.mts +1 -1
  131. package/esm/vite/gate-c-verify.mjs +1 -1
  132. package/esm/vite/gate-c-verify.mjs.map +1 -1
  133. package/esm/vite/hydration-entries.mjs +6 -8
  134. package/esm/vite/hydration-entries.mjs.map +1 -1
  135. package/esm/vite/index.d.mts +1 -1
  136. package/esm/vite/index.mjs +4 -110
  137. package/esm/vite/index.mjs.map +1 -1
  138. package/esm/vite/page-registry-plugin.mjs +9 -26
  139. package/esm/vite/page-registry-plugin.mjs.map +1 -1
  140. package/esm/vite/projection.mjs.map +1 -1
  141. package/esm/vite/react-refresh-preamble.mjs +90 -0
  142. package/esm/vite/react-refresh-preamble.mjs.map +1 -0
  143. package/esm/vite/ssr-client-view.mjs +135 -0
  144. package/esm/vite/ssr-client-view.mjs.map +1 -0
  145. package/llms-full.txt +564 -482
  146. package/llms.txt +2 -2
  147. package/package.json +6 -6
  148. package/skills/create-a-page/SKILL.md +354 -295
  149. package/skills/navigate-on-the-client/SKILL.md +212 -189
  150. package/esm/hydration/index.mjs.map +0 -1
  151. package/esm/runtime/register-modules.mjs.map +0 -1
  152. package/esm/server/dev-server.mjs.map +0 -1
  153. /package/esm/{hydration → entry}/index.d.mts +0 -0
package/llms-full.txt CHANGED
@@ -114,300 +114,359 @@ The desired result is one page route at `/` and, when the stock JSON route exist
114
114
 
115
115
  ## create-a-page `@warlock.js/web/create-a-page/SKILL.md`
116
116
 
117
- ---
118
- name: create-a-page
119
- description: 'Create an SSR React page under `src/web/**`, with either a literal `route` or a filesystem-derived one, an explicit public-cache opt-in, a default component, an optional typed `loader`, page `metadata`, the `error.page.tsx` boundary, and the universal `register()` hook. Triggers: `*.page.tsx`, `route`, `route.cache`, `maxAge`, `PageLoader`, `PageProps`, `PageMetadata`, `error.page.tsx`, `register`, `[...slug]`; "create a page", "cache a public page", "add an SSR route", "make a React page", "type page loader data", "add an error boundary", "catch-all route", "page renders blank 200", "page has no default export"; typical import `import type { PageLoader, PageProps } from "@warlock.js/web"`. Skip: root document shell — `@warlock.js/web/write-the-root/SKILL.md`; layout wrappers and prefixes — `@warlock.js/web/use-layouts/SKILL.md`; loader lifecycle and `shared` — `@warlock.js/web/load-page-data/SKILL.md`; competing frameworks `next`, `remix`, `react-router` file routes.'
120
- ---
121
-
122
- # Warlock — create a page
123
-
124
- A page is any `*.page.tsx` beneath `src/web/` — the page root. Its URL is either a declared `route` or one derived from its own location; its default export renders React.
125
-
126
- ## The shape
127
-
128
- ```tsx title="src/web/products/product-details.page.tsx"
129
- import type { PageLoader, PageMetadata, PageProps } from "@warlock.js/web";
130
-
131
- export const route = {
132
- path: "/products/:id",
133
- name: "products.details",
134
- cache: { public: true, maxAge: 60 },
135
- } as const;
136
-
137
- export const loader = (async ({ request }) => {
138
- const id = request.input("id");
139
-
140
- return {
141
- product: {
142
- id,
143
- name: `Product ${id}`,
144
- },
145
- };
146
- }) satisfies PageLoader<undefined, typeof route>;
147
-
148
- export const metadata: PageMetadata<typeof loader> = ({ data }) => ({
149
- title: data.product.name,
150
- description: `Details for ${data.product.name}`,
151
- });
152
-
153
- export default function ProductDetailsPage({ data }: PageProps<typeof loader>) {
154
- return (
155
- <main>
156
- <h1>{data.product.name}</h1>
157
- <p>Product id: {data.product.id}</p>
158
- </main>
159
- );
160
- }
161
- ```
162
-
163
- Use `satisfies PageLoader`, not `: PageLoader`. `satisfies` checks the context contract while retaining the loader's concrete return type, which is how `PageProps<typeof loader>` knows the shape of `data`.
164
-
165
- ## The minimum page
166
-
167
- ```tsx title="src/web/contact.page.tsx"
168
- export const route = "/contact";
169
-
170
- export default function ContactPage() {
171
- return (
172
- <main>
173
- <h1>Contact</h1>
174
- <a href="mailto:support@example.com">support@example.com</a>
175
- </main>
176
- );
177
- }
178
- ```
179
-
180
- `route` is optional. A page that omits it derives both its path and its name from where the file sits beneath `src/web` ([filesystem routing](#filesystem-routing), below). A page that declares `route` uses that instead — an explicit `route` always wins over the derived one, for both the path and (when it sets `name`) the name.
181
-
182
- ### The default export is required
183
-
184
- `route` is optional; the default export is not. A `*.page.tsx` that exports only named bindings is a **hard discovery/build failure naming the file**:
185
-
186
- ```
187
- The page "src/web/contact.page.tsx" has no runtime default export. Every `*.page.tsx`
188
- file must default-export the React component it renders. For example:
189
- `export default function Page() { return <main />; }`
190
- ```
191
-
192
- Two details worth knowing:
193
-
194
- - **`export { Page as default }` satisfies the rule** — the check is for a runtime default binding, not for the `export default` keyword form specifically.
195
- - **`export default interface Page {}` does not.** A type-only default is erased at compile time, so there is no component at runtime; it is treated as missing.
196
-
197
- A file that cannot be parsed reports separately — `Cannot inspect the default export of "<file>": the file could not be parsed (…). Fix the syntax error and discovery will continue.` — so a syntax error never masquerades as a missing export.
198
-
199
- ## Route declarations
200
-
201
- Use either a bare path or a literal object:
202
-
203
- ```ts
204
- export const route = "/products";
205
- ```
206
-
207
- ```ts
208
- export const route = {
209
- path: "/products/:id",
210
- name: "products.details",
211
- cache: { public: true, maxAge: 60 },
212
- } as const;
213
- ```
214
-
215
- Prefer an explicit stable `name` for links. Without one, Warlock derives a name from the declared path — a global root page gets `index`, another global page gets its dotted path — the same derivation [filesystem routing](#filesystem-routing) uses when there is no `route` at all.
216
-
217
- Every segment of a page's URL is written down somewhere: `route.path` (or the derived filesystem path), prefixed by the literal `prefix` exports of the positional layouts above it ([use-layouts](../use-layouts/SKILL.md)). Where the file sits always decides which layouts are above it, and — only when `route` is absent — the path segments too.
218
-
219
- The build reads `route` without executing application code. Declare it directly with `export const` and literal strings. Variables, function calls, computed object keys, spreads, and `export { route }` are refused.
220
-
221
- ## Page caching
222
-
223
- Page documents and their `x-warlock-data` representations are `no-store` by
224
- default. Opt a public page into shared caching on its route:
225
-
226
- ```tsx
227
- export const route = {
228
- path: "/products",
229
- name: "products.index",
230
- cache: { public: true, maxAge: 60 },
231
- } as const;
232
- ```
233
-
234
- `maxAge` is seconds. Both keys are required: `cache: { public: true }` is a
235
- boot-time `InvalidPageCacheOptInError`, because the framework will not invent
236
- a freshness window. Remove `cache` entirely to keep the safe `no-store`
237
- default.
238
-
239
- The declaration is an opt-in, not an override of request safety. A response
240
- that sets or clears a cookie, or a request that used authenticated state,
241
- emits `Cache-Control: private, no-store`. If Warlock cannot determine whether
242
- the request used authenticated state, it revokes the opt-in and emits
243
- `Cache-Control: no-store`. Only a provably unauthenticated request with no
244
- `Set-Cookie` can emit `public, max-age=<maxAge>`.
245
-
246
- This decision is applied once after loaders finish, to both representations.
247
- Setting `Cache-Control` manually in a loader cannot bypass the floor.
248
-
249
- ## Filesystem routing
250
-
251
- Omit `route` and the URL comes from the page's own path beneath `src/web`:
252
-
253
- - Every directory contributes a segment, in order — `src/web/products/featured.page.tsx` derives `/products/featured`.
254
- - A `(group)` directory parentheses, not braces — contributes nothing to the URL, only to organization: `src/web/(marketing)/pricing.page.tsx` derives `/pricing`. Bracket syntax inside a group name is refused at boot because it can never contribute a dynamic segment; use `(marketing)/[id]/page.page.tsx`, not `(marketing[id])/page.page.tsx`.
255
- - `index.page.tsx` claims its own directory rather than adding a segment: `src/web/products/index.page.tsx` derives `/products`. This is the ONLY filename with special meaning — `home.page.tsx` is not magic and derives `/home`.
256
- - `[id]` becomes `:id`: `src/web/products/[id].page.tsx` derives `/products/:id`.
257
- - A layout `prefix` on the page's ancestry composes in front of the derived path exactly as it does for an explicit `route.path` ([use-layouts](../use-layouts/SKILL.md)).
258
-
259
- Two pages that derive (or declare) the same effective path is a build error naming both files.
260
-
261
- ### Catch-all segments are refused
262
-
263
- There is no catch-all / rest-parameter form in filesystem routing. Only
264
- `[name]` — a plain identifier in square brackets — is recognized as a
265
- dynamic segment. `src/web/docs/[...slug].page.tsx` raises
266
- `PageFileSegmentNotSupportedError` at boot and names both the page file and
267
- the rejected segment.
268
-
269
- Until a catch-all exists, use the terminal wildcard with an explicit `route`:
270
-
271
- ```tsx
272
- export const route = { path: "/docs/*", name: "docs.catchAll" } as const;
273
- ```
274
-
275
- ## Page-route grammar
276
-
277
- Page routes deliberately accept less than API routes:
278
-
279
- - Supported: `/`, static segments, whole-segment params such as `/products/:id`, the exact wildcard `*`, and a terminal wildcard such as `/docs/*`.
280
- - Not supported: regex params, optional params, multiple params in one segment, params mixed with text, doubled or trailing slashes, non-terminal wildcards, and catch-all/rest segments (`[...slug]`).
281
-
282
- Write two pages for an optional segment. Validate a constrained parameter in the page instead of putting a regex in its path.
283
-
284
- An unsupported declared path is a boot-time
285
- `PageRoutePathNotSupportedError`, never a literal or normalized route.
286
- Examples that fail include `/users/:id?`, `/users/:id(\\d+)`,
287
- `/near/:lat-:lng`, `/a//b`, and `/users/`.
288
-
289
- ## Metadata
290
-
291
- `metadata` may be a static object or a function of the resolved loader data and readonly `shared` payload:
292
-
293
- ```tsx
294
- import type { PageMetadata } from "@warlock.js/web";
295
-
296
- export const metadata: PageMetadata = {
297
- title: "Products",
298
- description: "Browse the product catalogue",
299
- robots: "index,follow",
300
- openGraph: {
301
- type: "website",
302
- image: "/images/catalogue-card.png",
303
- },
304
- };
305
- ```
306
-
307
- Supported fields are `title`, `description`, `keywords`, `canonical`, `robots`, `openGraph`, and `twitter`. Function metadata runs after a successful loader. If a loader fails, Warlock uses error metadata instead of calling the page function with missing data.
308
-
309
- ## The error boundary — `error.page.tsx`
310
-
311
- `error.page.tsx` anywhere beneath `src/web` is the application's one error boundary — exactly two page filenames are special in Warlock, this and `404.page.tsx`. A second `error.page.tsx` is a build error naming both files. Like `404.page.tsx`, it declares no `route` — it has no URL of its own and is reached only when something throws.
312
-
313
- ```tsx title="src/web/error.page.tsx"
314
- import type { ErrorPageProps } from "@warlock.js/web";
315
-
316
- export default function ErrorPage({ error, status }: ErrorPageProps) {
317
- return (
318
- <main>
319
- <h1>Something went wrong</h1>
320
- <p>Status: {status}</p>
321
- </main>
322
- );
323
- }
324
- ```
325
-
326
- During SSR the component receives the real thrown value in `error`. After hydration it receives the JSON-safe `{ name, message, stack? }` shape instead — the original value does not survive the wire. `robots: noindex` is a framework default on this path that an app-supplied `metadata` cannot remove.
327
-
328
- Every unhandled error response is also forced to
329
- `Cache-Control: private, no-store` at the framework's shared error funnel.
330
- This includes page and API-route failures.
331
-
332
- If the failure happens before any page module could even load — a module-load or `register()` throw — there is no trustworthy server composition left to hydrate against, so Warlock renders a plain framework fallback (your `error.page.tsx` if it can still be loaded, otherwise a minimal built-in boundary) with no hydration script at all rather than risk hydrating client code against markup nothing can vouch for.
333
-
334
- ## The `register()` hook
335
-
336
- `root.tsx`, `layout.tsx`, and `*.page.tsx` may each export `register`: a synchronous, no-argument, side-effect hook that runs once per module namespace instance, on both the server and the browser, before that module's middleware or loader. Unlike `route`/`middleware`/`validation`/`loader`/`metadata`, it is not stripped from the client — it is meant to run on both sides.
337
-
338
- ```tsx
339
- export function register() {
340
- // one-time setup for this module namespace; must not return a Promise
341
- }
342
- ```
343
-
344
- Returning a Promise (or anything thenable) throws — `register()` must finish before the module is usable.
345
-
346
- ## The client boundary
347
-
348
- The browser boundary is decided by the import graph, not by the file's location. A `*.page.tsx` is universal:
349
-
350
- - `route`, `middleware`, `validation`, `loader`, and `metadata` are stripped from the client projection.
351
- - The default component and any other surviving exports form the client graph.
352
- - An import used only by a stripped server export is removed with it.
353
- - An import also used by the component survives and therefore must be browser-safe.
354
-
355
- Keep server-only repository and service reads inside `loader`. Do not read them from module-scope initializers or the default component.
356
-
357
- ### `.client` does not isolate SSR
358
-
359
- A `.client.tsx` suffix is a naming convention, not an SSR-isolation boundary. A
360
- module statically imported by a page, layout, root, or any of their imports is
361
- still evaluated by the server. Top-level browser globals such as `window`
362
- therefore crash SSR boot. Warlock does not ship a client-only component
363
- primitive; code that requires browser globals at module scope cannot be part of
364
- the SSR page graph.
365
-
366
- ### Static assets use `public/`
367
-
368
- The production server build does not support imported non-stylesheet assets. An
369
- import such as `import logo from "./logo.svg"` works under Vite in development
370
- but `warlock build` refuses it rather than emit a server URL that disagrees with
371
- the client bundle. Put the file in the application's `public/` directory and
372
- reference it by root URL: `public/logo.svg` is `/logo.svg`. Stylesheet imports
373
- are the exception and remain supported.
374
-
375
- ## Editing a page in development
376
-
377
- `warlock dev` decides Fast Refresh vs. a full reload by comparing the module's _skeleton_ — its source with every component body masked out — across the edit. Everything outside a component body is part of the skeleton: imports, module-level declarations, and all server exports (`route`, `middleware`, `validation`, `loader`, `metadata`). The skeleton moving, with or without a simultaneous JSX change, forces a full reload; the skeleton holding still defers to Fast Refresh.
378
-
379
- - **A JSX-only edit hot-updates.** The skeleton is unchanged, so Vite's Fast Refresh applies the projected client code with no reload and no lost component state.
380
- - **A `metadata`-only edit reloads the document.** `metadata` sits outside the skeleton's masked region, so the edit moves it. Warlock sends a full reload, which re-runs SSR and rebuilds `<head>`. Component state is lost — that is the price of seeing the new `<title>` without touching the browser.
381
- - **Any module-level change reloads, not just `metadata`.** An edited import, a module-level declaration, or an edit confined to `route`, `middleware`, `validation`, or `loader` all move the skeleton the same way and take the same full-reload path.
382
- - **A helper function used only by the JSX still reloads if it is declared at module level.** The rule does not try to prove which half of a shared declaration the edit was "really" for — it over-approximates deliberately, because a false reload only costs component state, while a missed one ships a stale `<head>` and calls it a hot update.
383
- - Creating, deleting, or renaming a page file, or editing its `route` export, is page-GRAPH churn, not a skeleton edit — see below, not Fast Refresh.
384
-
385
- ## Route-table changes in development
386
-
387
- Creating a page, deleting one, or editing its `route` export's path is a different kind of dev edit from the skeleton comparison above — it changes which URLs exist, not just how one already-registered URL renders. `warlock dev` re-registers the affected route(s) in the live route table, atomically and with no dev-server restart, so the new file (or new path) is reachable on the very next request with no manual restart.
388
-
389
- ## Gotchas
390
-
391
- - **A page with no `route` is not unreachable.** It derives a real URL from its file location see [Filesystem routing](#filesystem-routing).
392
- - **A page with no default export IS refused.** Named exports alone fail the build naming the file, instead of serving a blank 200.
393
- - **`.client.tsx` does not prevent SSR evaluation.** It is a naming convention, not a client-only component primitive.
394
- - **Imported static assets do not build.** Put them in `public/` and reference their root URL; CSS imports remain supported.
395
- - **`[...slug]` is not a catch-all.** It fails boot with
396
- `PageFileSegmentNotSupportedError`; use an explicit terminal `*` route
397
- instead — see [Catch-all segments are refused](#catch-all-segments-are-refused).
398
- - **`process.env` is refused in the client/universal graph, `PUBLIC_` prefix included.** Read env values in a loader and return them as page data; see [`load-page-data/SKILL.md`](../load-page-data/SKILL.md).
399
- - **Keep `route` literal.** A computed route cannot be discovered without executing app code and is refused.
400
- - **Do not annotate the loader with `: PageLoader`.** That erases the return type `PageProps` needs.
401
- - **Components receive data, not HTTP objects.** `request` and `response` belong to loaders; the component also renders in the browser.
402
- - **A default component is synchronous.** Fetch in the loader, then render its result.
403
- - **There are no server actions.** Mutations remain ordinary API requests; call `refresh()` after a successful mutation.
404
- - **Do not import the hydration entry.** `esm/hydration/index.mjs` is a framework build input, not a consumer API. The public low-level runtime subpath is `@warlock.js/web/client/runtime`.
405
-
406
- ## See also
407
-
408
- - [`load-page-data/SKILL.md`](../load-page-data/SKILL.md) — validation, loader context, response short-circuits, and `shared`.
409
- - [`use-layouts/SKILL.md`](../use-layouts/SKILL.md) positional layouts, prefixes, and persistence.
410
- - [`navigate-on-the-client/SKILL.md`](../navigate-on-the-client/SKILL.md) `Link`, `href`, navigation, and `refresh()`.
117
+ ---
118
+ name: create-a-page
119
+ description: 'Create an SSR React page under `src/web/**`, with either a literal `route` or a filesystem-derived one, an explicit public-cache opt-in, a `validation` schema and `middleware` guards, a default component, an optional typed `loader`, page `metadata`, the `error.page.tsx` boundary, and the universal `register()` hook. Triggers: `*.page.tsx`, `route`, `route.cache`, `maxAge`, `validation`, `middleware`, `route.validate`, `route.middleware`, `PageLoader`, `PageProps`, `PageMetadata`, `error.page.tsx`, `register`, `[...slug]`; "create a page", "cache a public page", "add an SSR route", "make a React page", "type page loader data", "add an error boundary", "catch-all route", "page renders blank 200", "page has no default export", "validate route params and query", "page-level middleware"; typical import `import type { PageLoader, PageProps } from "@warlock.js/web"`. Skip: root document shell — `@warlock.js/web/write-the-root/SKILL.md`; layout wrappers and prefixes — `@warlock.js/web/use-layouts/SKILL.md`; loader lifecycle and `shared` — `@warlock.js/web/load-page-data/SKILL.md`; competing frameworks `next`, `remix`, `react-router` file routes.'
120
+ ---
121
+
122
+ # Warlock — create a page
123
+
124
+ A page is any `*.page.tsx` beneath `src/web/` — the page root. Its URL is either a declared `route` or one derived from its own location; its default export renders React.
125
+
126
+ ## The shape
127
+
128
+ ```tsx title="src/web/products/product-details.page.tsx"
129
+ import type { PageLoader, PageMetadata, PageProps } from "@warlock.js/web";
130
+
131
+ export const route = {
132
+ path: "/products/:id",
133
+ name: "products.details",
134
+ cache: { public: true, maxAge: 60 },
135
+ } as const;
136
+
137
+ export const loader = (async ({ request }) => {
138
+ const id = request.input("id");
139
+
140
+ return {
141
+ product: {
142
+ id,
143
+ name: `Product ${id}`,
144
+ },
145
+ };
146
+ }) satisfies PageLoader<undefined, typeof route>;
147
+
148
+ export const metadata: PageMetadata<typeof loader> = ({ data }) => ({
149
+ title: data.product.name,
150
+ description: `Details for ${data.product.name}`,
151
+ });
152
+
153
+ export default function ProductDetailsPage({ data }: PageProps<typeof loader>) {
154
+ return (
155
+ <main>
156
+ <h1>{data.product.name}</h1>
157
+ <p>Product id: {data.product.id}</p>
158
+ </main>
159
+ );
160
+ }
161
+ ```
162
+
163
+ Use `satisfies PageLoader`, not `: PageLoader`. `satisfies` checks the context contract while retaining the loader's concrete return type, which is how `PageProps<typeof loader>` knows the shape of `data`.
164
+
165
+ ## The minimum page
166
+
167
+ ```tsx title="src/web/contact.page.tsx"
168
+ export const route = "/contact";
169
+
170
+ export default function ContactPage() {
171
+ return (
172
+ <main>
173
+ <h1>Contact</h1>
174
+ <a href="mailto:support@example.com">support@example.com</a>
175
+ </main>
176
+ );
177
+ }
178
+ ```
179
+
180
+ `route` is optional. A page that omits it derives both its path and its name from where the file sits beneath `src/web` ([filesystem routing](#filesystem-routing), below). A page that declares `route` uses that instead — an explicit `route` always wins over the derived one, for both the path and (when it sets `name`) the name.
181
+
182
+ ### The default export is required
183
+
184
+ `route` is optional; the default export is not. A `*.page.tsx` that exports only named bindings is a **hard discovery/build failure naming the file**:
185
+
186
+ ```
187
+ The page "src/web/contact.page.tsx" has no runtime default export. Every `*.page.tsx`
188
+ file must default-export the React component it renders. For example:
189
+ `export default function Page() { return <main />; }`
190
+ ```
191
+
192
+ Two details worth knowing:
193
+
194
+ - **`export { Page as default }` satisfies the rule** — the check is for a runtime default binding, not for the `export default` keyword form specifically.
195
+ - **`export default interface Page {}` does not.** A type-only default is erased at compile time, so there is no component at runtime; it is treated as missing.
196
+
197
+ A file that cannot be parsed reports separately — `Cannot inspect the default export of "<file>": the file could not be parsed (…). Fix the syntax error and discovery will continue.` — so a syntax error never masquerades as a missing export.
198
+
199
+ ## Route declarations
200
+
201
+ Use either a bare path or a literal object:
202
+
203
+ ```ts
204
+ export const route = "/products";
205
+ ```
206
+
207
+ ```ts
208
+ export const route = {
209
+ path: "/products/:id",
210
+ name: "products.details",
211
+ cache: { public: true, maxAge: 60 },
212
+ } as const;
213
+ ```
214
+
215
+ Prefer an explicit stable `name` for links. Without one, Warlock derives a name from the declared path — a global root page gets `index`, another global page gets its dotted path — the same derivation [filesystem routing](#filesystem-routing) uses when there is no `route` at all.
216
+
217
+ Every segment of a page's URL is written down somewhere: `route.path` (or the derived filesystem path), prefixed by the literal `prefix` exports of the positional layouts above it ([use-layouts](../use-layouts/SKILL.md)). Where the file sits always decides which layouts are above it, and — only when `route` is absent — the path segments too.
218
+
219
+ The build reads `route` without executing application code. Declare it directly with `export const` and literal strings. Variables, function calls, computed object keys, spreads, and `export { route }` are refused.
220
+
221
+ ### Validate the page's input — the `validation` export
222
+
223
+ Declare `validation` as its own top-level export: a [Seal](https://www.npmjs.com/package/@warlock.js/seal) schema per source, `params` and `query` kept as two separate keys — never merged into one bag, so a `:id` path segment and a `?id=` query key can never collide or silently shadow one another:
224
+
225
+ > **Withdrawn after 5.6.0: `route.validate`.** A page that still declares it does not silently lose its validation — the app **refuses to boot** and names the file. Move the schema to the `validation` export shown below; the shape and the 400 are unchanged.
226
+
227
+ ```tsx title="src/web/products/product-details.page.tsx"
228
+ import { v } from "@warlock.js/seal";
229
+ import type { PageLoader, PageProps } from "@warlock.js/web";
230
+
231
+ export const route = {
232
+ path: "/products/:id",
233
+ name: "products.details",
234
+ } as const;
235
+
236
+ export const validation = {
237
+ params: v.object({ id: v.string().minLength(2) }),
238
+ query: v.object({ tab: v.string().optional() }),
239
+ };
240
+
241
+ export const loader = (async ({ request }) => {
242
+ const { params, query } = request.validated();
243
+
244
+ return { id: params.id, tab: query.tab };
245
+ }) satisfies PageLoader<undefined, typeof route>;
246
+
247
+ export default function ProductDetailsPage({ data }: PageProps<typeof loader>) {
248
+ return <h1>Product {data.id}</h1>;
249
+ }
250
+ ```
251
+
252
+ `request.validated()` types `params` and `query` from the schema — never a flattened merge of the two. There is exactly ONE validation surface on a page; see [load-page-data](../load-page-data/SKILL.md) for how the validated data reaches the loader.
253
+
254
+ Rejected input never reaches the loader. It renders the application's `error.page.tsx` boundary at status 400 carrying the failure — a page is a document, not an API endpoint, so invalid input never gets a raw JSON body. The same 400 travels the same way over the `_loader` client-navigation wire.
255
+
256
+ ### `middleware` a page's own guard, run last
257
+
258
+ Declare `middleware` as its own top-level export: an array of `(ctx) => unknown | Promise<unknown>` guards on the page itself, alongside any layout `middleware` above it ([use-layouts](../use-layouts/SKILL.md)). Ordering is fixed pipeline-wide: every layout on the chain runs outermost-first, and the page's own `middleware` runs LAST, closest to the loader — **a layout's auth gate can never be bypassed by a page declaring its own middleware.**
259
+
260
+ > **Withdrawn after 5.6.0: `route.middleware`.** As with `route.validate`, a page still declaring it **refuses to boot** rather than quietly running without its guards — which for an auth guard is the difference between a broken deploy and an open door.
261
+
262
+ ```tsx
263
+ export const route = {
264
+ path: "/account",
265
+ } as const;
266
+
267
+ export const middleware = [
268
+ async ({ request, response }) => {
269
+ if (!request.header("authorization")) {
270
+ response.setStatusCode(401);
271
+
272
+ return { error: "Unauthorized" };
273
+ }
274
+ },
275
+ ];
276
+ ```
277
+
278
+ Returning anything other than `undefined` from a middleware short-circuits the request with that value, exactly as an app or layout middleware does.
279
+
280
+ ## Page caching
281
+
282
+ Page documents and their `x-warlock-data` representations are `no-store` by
283
+ default. Opt a public page into shared caching on its route:
284
+
285
+ ```tsx
286
+ export const route = {
287
+ path: "/products",
288
+ name: "products.index",
289
+ cache: { public: true, maxAge: 60 },
290
+ } as const;
291
+ ```
292
+
293
+ `maxAge` is seconds. Both keys are required: `cache: { public: true }` is a
294
+ boot-time `InvalidPageCacheOptInError`, because the framework will not invent
295
+ a freshness window. Remove `cache` entirely to keep the safe `no-store`
296
+ default.
297
+
298
+ The declaration is an opt-in, not an override of request safety. A response
299
+ that sets or clears a cookie, or a request that used authenticated state,
300
+ emits `Cache-Control: private, no-store`. If Warlock cannot determine whether
301
+ the request used authenticated state, it revokes the opt-in and emits
302
+ `Cache-Control: no-store`. Only a provably unauthenticated request with no
303
+ `Set-Cookie` can emit `public, max-age=<maxAge>`.
304
+
305
+ This decision is applied once after loaders finish, to both representations.
306
+ Setting `Cache-Control` manually in a loader cannot bypass the floor.
307
+
308
+ ## Filesystem routing
309
+
310
+ Omit `route` and the URL comes from the page's own path beneath `src/web`:
311
+
312
+ - Every directory contributes a segment, in order — `src/web/products/featured.page.tsx` derives `/products/featured`.
313
+ - A `(group)` directory — parentheses, not braces — contributes nothing to the URL, only to organization: `src/web/(marketing)/pricing.page.tsx` derives `/pricing`. Bracket syntax inside a group name is refused at boot because it can never contribute a dynamic segment; use `(marketing)/[id]/page.page.tsx`, not `(marketing[id])/page.page.tsx`.
314
+ - `index.page.tsx` claims its own directory rather than adding a segment: `src/web/products/index.page.tsx` derives `/products`. This is the ONLY filename with special meaning — `home.page.tsx` is not magic and derives `/home`.
315
+ - `[id]` becomes `:id`: `src/web/products/[id].page.tsx` derives `/products/:id`.
316
+ - A layout `prefix` on the page's ancestry composes in front of the derived path exactly as it does for an explicit `route.path` ([use-layouts](../use-layouts/SKILL.md)).
317
+
318
+ Two pages that derive (or declare) the same effective path is a build error naming both files.
319
+
320
+ ### Catch-all segments are refused
321
+
322
+ There is no catch-all / rest-parameter form in filesystem routing. Only
323
+ `[name]` — a plain identifier in square brackets — is recognized as a
324
+ dynamic segment. `src/web/docs/[...slug].page.tsx` raises
325
+ `PageFileSegmentNotSupportedError` at boot and names both the page file and
326
+ the rejected segment.
327
+
328
+ Until a catch-all exists, use the terminal wildcard with an explicit `route`:
329
+
330
+ ```tsx
331
+ export const route = { path: "/docs/*", name: "docs.catchAll" } as const;
332
+ ```
333
+
334
+ ## Page-route grammar
335
+
336
+ Page routes deliberately accept less than API routes:
337
+
338
+ - Supported: `/`, static segments, whole-segment params such as `/products/:id`, the exact wildcard `*`, and a terminal wildcard such as `/docs/*`.
339
+ - Not supported: regex params, optional params, multiple params in one segment, params mixed with text, doubled or trailing slashes, non-terminal wildcards, and catch-all/rest segments (`[...slug]`).
340
+
341
+ Write two pages for an optional segment. Validate a constrained parameter in the page instead of putting a regex in its path.
342
+
343
+ An unsupported declared path is a boot-time
344
+ `PageRoutePathNotSupportedError`, never a literal or normalized route.
345
+ Examples that fail include `/users/:id?`, `/users/:id(\\d+)`,
346
+ `/near/:lat-:lng`, `/a//b`, and `/users/`.
347
+
348
+ ## Metadata
349
+
350
+ `metadata` may be a static object or a function of the resolved loader data and readonly `shared` payload:
351
+
352
+ ```tsx
353
+ import type { PageMetadata } from "@warlock.js/web";
354
+
355
+ export const metadata: PageMetadata = {
356
+ title: "Products",
357
+ description: "Browse the product catalogue",
358
+ robots: "index,follow",
359
+ openGraph: {
360
+ type: "website",
361
+ image: "/images/catalogue-card.png",
362
+ },
363
+ };
364
+ ```
365
+
366
+ Supported fields are `title`, `description`, `keywords`, `canonical`, `robots`, `openGraph`, and `twitter`. Function metadata runs after a successful loader. If a loader fails, Warlock uses error metadata instead of calling the page function with missing data.
367
+
368
+ ## The error boundary `error.page.tsx`
369
+
370
+ `error.page.tsx` anywhere beneath `src/web` is the application's one error boundary — exactly two page filenames are special in Warlock, this and `404.page.tsx`. A second `error.page.tsx` is a build error naming both files. Like `404.page.tsx`, it declares no `route` it has no URL of its own and is reached only when something throws.
371
+
372
+ ```tsx title="src/web/error.page.tsx"
373
+ import type { ErrorPageProps } from "@warlock.js/web";
374
+
375
+ export default function ErrorPage({ error, status }: ErrorPageProps) {
376
+ return (
377
+ <main>
378
+ <h1>Something went wrong</h1>
379
+ <p>Status: {status}</p>
380
+ </main>
381
+ );
382
+ }
383
+ ```
384
+
385
+ During SSR the component receives the real thrown value in `error`. After hydration it receives the JSON-safe `{ name, message, stack? }` shape instead — the original value does not survive the wire. `robots: noindex` is a framework default on this path that an app-supplied `metadata` cannot remove.
386
+
387
+ Every unhandled error response is also forced to
388
+ `Cache-Control: private, no-store` at the framework's shared error funnel.
389
+ This includes page and API-route failures.
390
+
391
+ If the failure happens before any page module could even load — a module-load or `register()` throw — there is no trustworthy server composition left to hydrate against, so Warlock renders a plain framework fallback (your `error.page.tsx` if it can still be loaded, otherwise a minimal built-in boundary) with no hydration script at all rather than risk hydrating client code against markup nothing can vouch for.
392
+
393
+ ## The `register()` hook
394
+
395
+ `root.tsx`, `layout.tsx`, and `*.page.tsx` may each export `register`: a synchronous, no-argument, side-effect hook that runs once per module namespace instance, on both the server and the browser, before that module's middleware or loader. Unlike `route`/`middleware`/`validation`/`loader`/`metadata`, it is not stripped from the client it is meant to run on both sides.
396
+
397
+ ```tsx
398
+ export function register() {
399
+ // one-time setup for this module namespace; must not return a Promise
400
+ }
401
+ ```
402
+
403
+ Returning a Promise (or anything thenable) throws `register()` must finish before the module is usable.
404
+
405
+ ## The client boundary
406
+
407
+ The browser boundary is decided by the import graph, not by the file's location. A `*.page.tsx` is universal:
408
+
409
+ - `route`, `middleware`, `validation`, `loader`, and `metadata` are stripped from the client projection.
410
+ - The default component and any other surviving exports form the client graph.
411
+ - An import used only by a stripped server export is removed with it.
412
+ - An import also used by the component survives and therefore must be browser-safe.
413
+
414
+ Keep server-only repository and service reads inside `loader`. Do not read them from module-scope initializers or the default component.
415
+
416
+ ### `.client` does not isolate SSR
417
+
418
+ A `.client.tsx` suffix is a naming convention, not an SSR-isolation boundary. A
419
+ module statically imported by a page, layout, root, or any of their imports is
420
+ still evaluated by the server. Top-level browser globals such as `window`
421
+ therefore crash SSR boot. Warlock does not ship a client-only component
422
+ primitive; code that requires browser globals at module scope cannot be part of
423
+ the SSR page graph.
424
+
425
+ ### Static assets use `public/`
426
+
427
+ The production server build does not support imported non-stylesheet assets. An
428
+ import such as `import logo from "./logo.svg"` works under Vite in development
429
+ but `warlock build` refuses it rather than emit a server URL that disagrees with
430
+ the client bundle. Put the file in the application's `public/` directory and
431
+ reference it by root URL: `public/logo.svg` is `/logo.svg`. Stylesheet imports
432
+ are the exception and remain supported.
433
+
434
+ ## Editing a page in development
435
+
436
+ `warlock dev` decides Fast Refresh vs. a full reload by comparing the module's _skeleton_ — its source with every component body masked out — across the edit. Everything outside a component body is part of the skeleton: imports, module-level declarations, and all server exports (`route`, `middleware`, `validation`, `loader`, `metadata`). The skeleton moving, with or without a simultaneous JSX change, forces a full reload; the skeleton holding still defers to Fast Refresh.
437
+
438
+ - **A JSX-only edit hot-updates.** The skeleton is unchanged, so Vite's Fast Refresh applies the projected client code with no reload and no lost component state.
439
+ - **A `metadata`-only edit reloads the document.** `metadata` sits outside the skeleton's masked region, so the edit moves it. Warlock sends a full reload, which re-runs SSR and rebuilds `<head>`. Component state is lost — that is the price of seeing the new `<title>` without touching the browser.
440
+ - **Any module-level change reloads, not just `metadata`.** An edited import, a module-level declaration, or an edit confined to `route`, `middleware`, `validation`, or `loader` all move the skeleton the same way and take the same full-reload path.
441
+ - **A helper function used only by the JSX still reloads if it is declared at module level.** The rule does not try to prove which half of a shared declaration the edit was "really" for — it over-approximates deliberately, because a false reload only costs component state, while a missed one ships a stale `<head>` and calls it a hot update.
442
+ - Creating, deleting, or renaming a page file, or editing its `route` export, is page-GRAPH churn, not a skeleton edit — see below, not Fast Refresh.
443
+
444
+ ## Route-table changes in development
445
+
446
+ Creating a page, deleting one, or editing its `route` export's path is a different kind of dev edit from the skeleton comparison above — it changes which URLs exist, not just how one already-registered URL renders. `warlock dev` re-registers the affected route(s) in the live route table, atomically and with no dev-server restart, so the new file (or new path) is reachable on the very next request with no manual restart.
447
+
448
+ ## Gotchas
449
+
450
+ - **A page with no `route` is not unreachable.** It derives a real URL from its file location — see [Filesystem routing](#filesystem-routing).
451
+ - **A page with no default export IS refused.** Named exports alone fail the build naming the file, instead of serving a blank 200.
452
+ - **`.client.tsx` does not prevent SSR evaluation.** It is a naming convention, not a client-only component primitive.
453
+ - **Imported static assets do not build.** Put them in `public/` and reference their root URL; CSS imports remain supported.
454
+ - **`[...slug]` is not a catch-all.** It fails boot with
455
+ `PageFileSegmentNotSupportedError`; use an explicit terminal `*` route
456
+ instead — see [Catch-all segments are refused](#catch-all-segments-are-refused).
457
+ - **`process.env` is refused in the client/universal graph, `PUBLIC_` prefix included.** Read env values in a loader and return them as page data; see [`load-page-data/SKILL.md`](../load-page-data/SKILL.md).
458
+ - **Keep `route` literal.** A computed route cannot be discovered without executing app code and is refused.
459
+ - **Do not annotate the loader with `: PageLoader`.** That erases the return type `PageProps` needs.
460
+ - **Components receive data, not HTTP objects.** `request` and `response` belong to loaders; the component also renders in the browser.
461
+ - **A default component is synchronous.** Fetch in the loader, then render its result.
462
+ - **There are no server actions.** Mutations remain ordinary API requests; call `refresh()` after a successful mutation.
463
+ - **Do not import the hydration entry.** `esm/hydration/index.mjs` is a framework build input, not a consumer API. The public low-level runtime subpath is `@warlock.js/web/client/runtime`.
464
+
465
+ ## See also
466
+
467
+ - [`load-page-data/SKILL.md`](../load-page-data/SKILL.md) — validation, loader context, response short-circuits, and `shared`.
468
+ - [`use-layouts/SKILL.md`](../use-layouts/SKILL.md) — positional layouts, prefixes, and persistence.
469
+ - [`navigate-on-the-client/SKILL.md`](../navigate-on-the-client/SKILL.md) — `Link`, `href`, navigation, and `refresh()`.
411
470
  - [`serve-styles/SKILL.md`](../serve-styles/SKILL.md) — CSS imports in page and root modules.
412
471
 
413
472
  ## load-page-data `@warlock.js/web/load-page-data/SKILL.md`
@@ -678,194 +737,217 @@ Only put browser-safe data in `shared`: scalars, arrays, plain objects, or value
678
737
 
679
738
  ## navigate-on-the-client `@warlock.js/web/navigate-on-the-client/SKILL.md`
680
739
 
681
- ---
682
- name: navigate-on-the-client
683
- description: 'Navigate hydrated pages with `<Link>`, resolve named URLs with `href()`, use `navigateTo` / `navigateBack`, prefetch on interaction, inspect the server match with `currentRoute()`, and re-fetch loaders after a mutation with `refresh()`. Triggers: `Link`, `href`, `navigateTo`, `navigateBack`, `refresh`, `currentRoute`, `previousRoute`; "navigate without a reload", "link to a named route", "refresh page data", "revalidate loaders", "client-side back"; typical import `import { Link, refresh } from "@warlock.js/web"`. Skip: define a page route — `@warlock.js/web/create-a-page/SKILL.md`; loader mechanics — `@warlock.js/web/load-page-data/SKILL.md`; root hydration boundary — `@warlock.js/web/write-the-root/SKILL.md`; competing routers `@mongez/react-router`, `react-router-dom`, Next navigation.'
684
- ---
685
-
686
- # Warlock — navigate on the client
687
-
688
- `<Link>` renders a real anchor for progressive enhancement and intercepts a plain in-app click after hydration. The server remains the only route matcher; client navigation fetches the page-data representation of the URL and swaps the Layout + Page tree.
689
-
690
- Every behaviour on this page depends on hydration having mounted. See [write-the-root](../write-the-root/SKILL.md#root-is-the-hydration-boundary).
691
-
692
- ## The shape
693
-
694
- ```tsx title="src/web/components/product-link.tsx"
695
- import { Link } from "@warlock.js/web";
696
-
697
- export function ProductLink({ id }: { id: string }) {
698
- return (
699
- <Link to="products.details" params={{ id }} query={{ tab: "specifications" }} prefetch>
700
- View product
701
- </Link>
702
- );
703
- }
704
- ```
705
-
706
- Route-name destinations resolve through the table published from the same page graph the server installs. Missing or extra params throw rather than producing a URL that silently 404s.
707
-
708
- ## Link destinations
709
-
710
- Pass exactly one destination prop:
711
-
712
- ```tsx
713
- import { Link } from "@warlock.js/web";
714
-
715
- export function NavigationLinks() {
716
- return (
717
- <nav>
718
- <Link to="products.index">Products</Link>
719
- <Link href="/pricing">Pricing</Link>
720
- <Link href="https://example.com/docs" newTab>
721
- External docs
722
- </Link>
723
- <Link email="sales@example.com">Email sales</Link>
724
- <Link tel="+201000000000">Call sales</Link>
725
- </nav>
726
- );
727
- }
728
- ```
729
-
730
- `to` and `href` both accept a route name or literal URL:
731
-
732
- - A value beginning with `/` or with a URI scheme is literal and is passed through.
733
- - Any other value is a route name and is resolved through `href()`.
734
- - `params` and `query` apply only to route names.
735
- - `newTab` supplies `_blank` and `noopener noreferrer` unless you provide your own `target` or `rel`.
736
-
737
- `prefetch` fetches in-app page data on hover or keyboard focus. It is ignored for external, email, telephone, new-tab, and explicitly targeted links. Prefetch is best-effort and never delays the interaction.
738
-
739
- Modified clicks, middle clicks, downloads, another browsing context, or an earlier `preventDefault()` remain browser-owned.
740
-
741
- ## Build a URL without React
742
-
743
- `href(name, params?, query?)` is the durable primitive for redirects, headers, email bodies, and other non-component callers:
744
-
745
- ```ts
746
- import { href } from "@warlock.js/web";
747
-
748
- const productUrl = href(
749
- "products.details",
750
- { id: "42" },
751
- { tab: "reviews", tags: ["featured", "sale"] },
752
- );
753
- ```
754
-
755
- Unknown route names throw at runtime with the known names. Call `href()` after the route table has been published—during a page render, event, or request—not from an eager module initializer before boot.
756
-
757
- ## Programmatic navigation
758
-
759
- ```tsx title="src/web/components/checkout-button.tsx"
760
- import { href, navigateBack, navigateTo } from "@warlock.js/web";
761
-
762
- export function CheckoutButtons() {
763
- const openCheckout = () => {
764
- const url = href("checkout.index");
765
-
766
- if (!navigateTo(url)) {
767
- window.location.assign(url);
768
- }
769
- };
770
-
771
- return (
772
- <div>
773
- <button type="button" onClick={navigateBack}>
774
- Back
775
- </button>
776
- <button type="button" onClick={openCheckout}>
777
- Checkout
778
- </button>
779
- </div>
780
- );
781
- }
782
- ```
783
-
784
- `navigateTo(path, { replace?: boolean })` accepts a path, not a route name. Resolve a name with `href()` first. It returns `false` when no client runtime accepted the navigation; use a real browser navigation when arrival is mandatory. `navigateBack()` is a no-op without a browser.
785
-
786
- ## Re-fetch after a mutation
787
-
788
- The public primitive is `refresh()`. There is no `revalidate()` export.
789
-
790
- ```tsx title="src/web/products/delete-product-button.tsx"
791
- import { refresh } from "@warlock.js/web";
792
-
793
- export function DeleteProductButton({ id }: { id: string }) {
794
- const deleteProduct = async () => {
795
- const response = await fetch(`/api/products/${encodeURIComponent(id)}`, {
796
- method: "DELETE",
797
- });
798
-
799
- if (!response.ok) {
800
- throw new Error(`Delete failed with status ${response.status}`);
801
- }
802
-
803
- const refreshed = await refresh();
804
-
805
- if (!refreshed) {
806
- window.location.reload();
807
- }
808
- };
809
-
810
- return (
811
- <button type="button" onClick={deleteProduct}>
812
- Delete product
813
- </button>
814
- );
815
- }
816
- ```
817
-
818
- `refresh()` re-fetches the current URL's App, Layout, and Page loaders and swaps fresh data without pushing history. It returns `true` only when fresh data reached the screen. On network/build failure it returns `false` and leaves the current page intact.
819
-
820
- This is an ordinary API mutation followed by a re-fetch. Server actions are not supported.
821
-
822
- ## Current and previous routes
823
-
824
- `currentRoute()` and `previousRoute()` report route names and params sent by the server; they never re-match the browser URL.
825
-
826
- ```tsx title="src/web/components/current-product-id.tsx"
827
- import { currentRoute, previousRoute } from "@warlock.js/web";
828
-
829
- export function CurrentProductId() {
830
- const current = currentRoute();
831
- const previous = previousRoute();
832
-
833
- return (
834
- <dl>
835
- <dt>Current product</dt>
836
- <dd>{current?.params?.id ?? "none"}</dd>
837
- <dt>Previous page</dt>
838
- <dd>{previous?.name ?? "first visit"}</dd>
839
- </dl>
840
- );
841
- }
842
- ```
843
-
844
- `previousRoute()` means the previously swapped page, not the previous browser-history entry. On the first page it is `undefined`.
845
-
846
- `routerEvents` is exported and `refresh()` emits its start/end/error lifecycle today. Ordinary Link and `navigateTo` swaps are not yet wired to that emitter, so do not use it as a complete global navigation progress signal yet.
847
-
848
- ## Failure behavior
849
-
850
- - A failed navigation falls back to a full browser load so the user still arrives.
851
- - A failed `refresh()` keeps the current page and reports `false`; it never spends the page the user already has.
852
- - Back/Forward uses the same data fetch and tree swap, replacing the existing history entry rather than pushing another.
853
- - Navigating within one layout preserves layout state through React reconciliation.
854
-
855
- ## Gotchas
856
-
857
- - **Use `refresh()`, not `revalidate()`.** `refresh` is the exported loader re-fetch primitive.
858
- - **`navigateTo` accepts a URL/path.** Resolve named routes with `href()` first.
859
- - **Pass exactly one Link destination.** Combining `to`, `href`, `email`, or `tel` throws.
860
- - **Do not add `params` or `query` to a literal URL.** Put them in the URL itself or use a route name.
861
- - **Do not build a client matcher.** The server's matched name and params travel in the payload.
862
- - **Do not import `esm/hydration/index.mjs`.** Normal consumers import navigation from `@warlock.js/web`; low-level runtime contracts live at `@warlock.js/web/client/runtime`.
863
-
864
- ## See also
865
-
866
- - [`create-a-page/SKILL.md`](../create-a-page/SKILL.md) — declare route names and params.
867
- - [`load-page-data/SKILL.md`](../load-page-data/SKILL.md) what `refresh()` re-runs.
868
- - [`use-layouts/SKILL.md`](../use-layouts/SKILL.md) — why layout state persists.
740
+ ---
741
+ name: navigate-on-the-client
742
+ description: 'Navigate hydrated pages with `<Link>`, resolve named URLs with `href()`, use `navigateTo` / `navigateBack`, prefetch on interaction, inspect the server match with `currentRoute()`, re-fetch loaders after a mutation with `refresh()`, and read one query-string key live with `useQueryString`. Triggers: `Link`, `href`, `navigateTo`, `navigateBack`, `refresh`, `currentRoute`, `previousRoute`, `useQueryString`; "navigate without a reload", "link to a named route", "refresh page data", "revalidate loaders", "client-side back", "read query string in a component", "query string stale after Link navigation"; typical import `import { Link, refresh, useQueryString } from "@warlock.js/web"`. Skip: define a page route — `@warlock.js/web/create-a-page/SKILL.md`; loader mechanics — `@warlock.js/web/load-page-data/SKILL.md`; root hydration boundary — `@warlock.js/web/write-the-root/SKILL.md`; competing routers `@mongez/react-router`, `react-router-dom`, Next navigation.'
743
+ ---
744
+
745
+ # Warlock — navigate on the client
746
+
747
+ `<Link>` renders a real anchor for progressive enhancement and intercepts a plain in-app click after hydration. The server remains the only route matcher; client navigation fetches the page-data representation of the URL and swaps the Layout + Page tree.
748
+
749
+ Every behaviour on this page depends on hydration having mounted. See [write-the-root](../write-the-root/SKILL.md#root-is-the-hydration-boundary).
750
+
751
+ ## The shape
752
+
753
+ ```tsx title="src/web/components/product-link.tsx"
754
+ import { Link } from "@warlock.js/web";
755
+
756
+ export function ProductLink({ id }: { id: string }) {
757
+ return (
758
+ <Link to="products.details" params={{ id }} query={{ tab: "specifications" }} prefetch>
759
+ View product
760
+ </Link>
761
+ );
762
+ }
763
+ ```
764
+
765
+ Route-name destinations resolve through the table published from the same page graph the server installs. Missing or extra params throw rather than producing a URL that silently 404s.
766
+
767
+ ## Link destinations
768
+
769
+ Pass exactly one destination prop:
770
+
771
+ ```tsx
772
+ import { Link } from "@warlock.js/web";
773
+
774
+ export function NavigationLinks() {
775
+ return (
776
+ <nav>
777
+ <Link to="products.index">Products</Link>
778
+ <Link href="/pricing">Pricing</Link>
779
+ <Link href="https://example.com/docs" newTab>
780
+ External docs
781
+ </Link>
782
+ <Link email="sales@example.com">Email sales</Link>
783
+ <Link tel="+201000000000">Call sales</Link>
784
+ </nav>
785
+ );
786
+ }
787
+ ```
788
+
789
+ `to` and `href` both accept a route name or literal URL:
790
+
791
+ - A value beginning with `/` or with a URI scheme is literal and is passed through.
792
+ - Any other value is a route name and is resolved through `href()`.
793
+ - `params` and `query` apply only to route names.
794
+ - `newTab` supplies `_blank` and `noopener noreferrer` unless you provide your own `target` or `rel`.
795
+
796
+ `prefetch` fetches in-app page data on hover or keyboard focus. It is ignored for external, email, telephone, new-tab, and explicitly targeted links. Prefetch is best-effort and never delays the interaction.
797
+
798
+ Modified clicks, middle clicks, downloads, another browsing context, or an earlier `preventDefault()` remain browser-owned.
799
+
800
+ ## Build a URL without React
801
+
802
+ `href(name, params?, query?)` is the durable primitive for redirects, headers, email bodies, and other non-component callers:
803
+
804
+ ```ts
805
+ import { href } from "@warlock.js/web";
806
+
807
+ const productUrl = href(
808
+ "products.details",
809
+ { id: "42" },
810
+ { tab: "reviews", tags: ["featured", "sale"] },
811
+ );
812
+ ```
813
+
814
+ Unknown route names throw at runtime with the known names. Call `href()` after the route table has been published—during a page render, event, or request—not from an eager module initializer before boot.
815
+
816
+ ## Programmatic navigation
817
+
818
+ ```tsx title="src/web/components/checkout-button.tsx"
819
+ import { href, navigateBack, navigateTo } from "@warlock.js/web";
820
+
821
+ export function CheckoutButtons() {
822
+ const openCheckout = () => {
823
+ const url = href("checkout.index");
824
+
825
+ if (!navigateTo(url)) {
826
+ window.location.assign(url);
827
+ }
828
+ };
829
+
830
+ return (
831
+ <div>
832
+ <button type="button" onClick={navigateBack}>
833
+ Back
834
+ </button>
835
+ <button type="button" onClick={openCheckout}>
836
+ Checkout
837
+ </button>
838
+ </div>
839
+ );
840
+ }
841
+ ```
842
+
843
+ `navigateTo(path, { replace?: boolean })` accepts a path, not a route name. Resolve a name with `href()` first. It returns `false` when no client runtime accepted the navigation; use a real browser navigation when arrival is mandatory. `navigateBack()` is a no-op without a browser.
844
+
845
+ ## Re-fetch after a mutation
846
+
847
+ The public primitive is `refresh()`. There is no `revalidate()` export.
848
+
849
+ ```tsx title="src/web/products/delete-product-button.tsx"
850
+ import { refresh } from "@warlock.js/web";
851
+
852
+ export function DeleteProductButton({ id }: { id: string }) {
853
+ const deleteProduct = async () => {
854
+ const response = await fetch(`/api/products/${encodeURIComponent(id)}`, {
855
+ method: "DELETE",
856
+ });
857
+
858
+ if (!response.ok) {
859
+ throw new Error(`Delete failed with status ${response.status}`);
860
+ }
861
+
862
+ const refreshed = await refresh();
863
+
864
+ if (!refreshed) {
865
+ window.location.reload();
866
+ }
867
+ };
868
+
869
+ return (
870
+ <button type="button" onClick={deleteProduct}>
871
+ Delete product
872
+ </button>
873
+ );
874
+ }
875
+ ```
876
+
877
+ `refresh()` re-fetches the current URL's App, Layout, and Page loaders and swaps fresh data without pushing history. It returns `true` only when fresh data reached the screen. On network/build failure it returns `false` and leaves the current page intact.
878
+
879
+ This is an ordinary API mutation followed by a re-fetch. Server actions are not supported.
880
+
881
+ ## Read the query string live — `useQueryString`
882
+
883
+ ```tsx title="src/web/products/product-list-filters.tsx"
884
+ import { useQueryString } from "@warlock.js/web";
885
+
886
+ export function ProductListFilters() {
887
+ const tab = useQueryString("tab");
888
+ // Narrow before rendering: anyone can type `?tab[]=a` or `?tab[x]=1`, so the
889
+ // hook's type says the value may not be a string.
890
+ const active = typeof tab === "string" ? tab : "all";
891
+
892
+ return <p>Tab: {active}</p>;
893
+ }
894
+ ```
895
+
896
+ `useQueryString(key)` returns the decoded value at `key` from the current query string — a string, an array (`key[]`), or a one-level bag (`key[sub]`). **A missing key returns `undefined`, never `""`**: an absent key and a present-but-empty one (`?q=`, which parses to `""`) are different values, and there is no default-value parameter — the caller decides what "absent" means for its own UI.
897
+
898
+ **The return type is a union, and you have to narrow it.** `QueryStringValue | undefined` is not a `ReactNode`, so putting the raw value in JSX does not compile — deliberately. The URL is user input: a page expecting `?tab=specs` can be handed `?tab[]=a&tab[]=b` by anyone with an address bar, and the compiler makes you decide what that means instead of rendering `[object Object]`. Narrow with `typeof value === "string"`; do not cast it away.
899
+
900
+ It re-renders the calling component after a client navigation completes — `<Link>`, `navigateTo()`, or Back/Forward — and the value it reads changed. A `<Link>` navigation moves the URL with `pushState`/`replaceState` directly rather than firing `popstate`, so a component that only read `location.search` once would render a stale value the moment a link changed just the query string; this hook exists because of that gap.
901
+
902
+ Safe to call during SSR: it reads the search string of the request being rendered rather than returning empty, so the server's answer matches the first client render and hydration does not mismatch.
903
+
904
+ ## Current and previous routes
905
+
906
+ `currentRoute()` and `previousRoute()` report route names and params sent by the server; they never re-match the browser URL.
907
+
908
+ ```tsx title="src/web/components/current-product-id.tsx"
909
+ import { currentRoute, previousRoute } from "@warlock.js/web";
910
+
911
+ export function CurrentProductId() {
912
+ const current = currentRoute();
913
+ const previous = previousRoute();
914
+
915
+ return (
916
+ <dl>
917
+ <dt>Current product</dt>
918
+ <dd>{current?.params?.id ?? "none"}</dd>
919
+ <dt>Previous page</dt>
920
+ <dd>{previous?.name ?? "first visit"}</dd>
921
+ </dl>
922
+ );
923
+ }
924
+ ```
925
+
926
+ `previousRoute()` means the previously swapped page, not the previous browser-history entry. On the first page it is `undefined`.
927
+
928
+ `routerEvents` is exported and `refresh()` emits its start/end/error lifecycle today. Ordinary Link and `navigateTo` swaps are not yet wired to that emitter, so do not use it as a complete global navigation progress signal yet.
929
+
930
+ ## Failure behavior
931
+
932
+ - A failed navigation falls back to a full browser load so the user still arrives.
933
+ - A failed `refresh()` keeps the current page and reports `false`; it never spends the page the user already has.
934
+ - Back/Forward uses the same data fetch and tree swap, replacing the existing history entry rather than pushing another.
935
+ - Navigating within one layout preserves layout state through React reconciliation.
936
+
937
+ ## Gotchas
938
+
939
+ - **Use `refresh()`, not `revalidate()`.** `refresh` is the exported loader re-fetch primitive.
940
+ - **`navigateTo` accepts a URL/path.** Resolve named routes with `href()` first.
941
+ - **Pass exactly one Link destination.** Combining `to`, `href`, `email`, or `tel` throws.
942
+ - **Do not add `params` or `query` to a literal URL.** Put them in the URL itself or use a route name.
943
+ - **Do not build a client matcher.** The server's matched name and params travel in the payload.
944
+ - **Do not import `esm/hydration/index.mjs`.** Normal consumers import navigation from `@warlock.js/web`; low-level runtime contracts live at `@warlock.js/web/client/runtime`.
945
+
946
+ ## See also
947
+
948
+ - [`create-a-page/SKILL.md`](../create-a-page/SKILL.md) — declare route names and params.
949
+ - [`load-page-data/SKILL.md`](../load-page-data/SKILL.md) — what `refresh()` re-runs.
950
+ - [`use-layouts/SKILL.md`](../use-layouts/SKILL.md) — why layout state persists.
869
951
  - [`write-the-root/SKILL.md`](../write-the-root/SKILL.md) — the `#root` swap boundary.
870
952
 
871
953
  ## serve-styles `@warlock.js/web/serve-styles/SKILL.md`