@warlock.js/web 5.5.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +27 -5
  2. package/esm/build/contribution.d.mts +2 -2
  3. package/esm/build/contribution.mjs.map +1 -1
  4. package/esm/build/discover-pages.mjs +127 -205
  5. package/esm/build/discover-pages.mjs.map +1 -1
  6. package/esm/build/generate-pages-barrel.mjs +5 -3
  7. package/esm/build/generate-pages-barrel.mjs.map +1 -1
  8. package/esm/build/public-files.mjs +29 -1
  9. package/esm/build/public-files.mjs.map +1 -1
  10. package/esm/build/read-metadata-keys.mjs +225 -0
  11. package/esm/build/read-metadata-keys.mjs.map +1 -0
  12. package/esm/build/read-route-exports.mjs.map +1 -1
  13. package/esm/client/build-hydrated-tree.mjs +1 -1
  14. package/esm/client/build-hydrated-tree.mjs.map +1 -1
  15. package/esm/client/navigation/document-metadata.mjs +108 -0
  16. package/esm/client/navigation/document-metadata.mjs.map +1 -0
  17. package/esm/client/navigation/navigation-root.mjs +23 -103
  18. package/esm/client/navigation/navigation-root.mjs.map +1 -1
  19. package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -1
  20. package/esm/client/navigation/use-query-string.d.mts +35 -0
  21. package/esm/client/navigation/use-query-string.mjs +68 -0
  22. package/esm/client/navigation/use-query-string.mjs.map +1 -0
  23. package/esm/client/runtime/index.d.mts +1 -1
  24. package/esm/client/runtime/index.mjs +1 -1
  25. package/esm/client/runtime/manifest.mjs.map +1 -1
  26. package/esm/client/runtime/matcher.mjs.map +1 -1
  27. package/esm/client/runtime/types.d.mts +1 -1
  28. package/esm/components/link.mjs.map +1 -1
  29. package/esm/core/src/router/normalize-route-path.mjs +33 -0
  30. package/esm/core/src/router/normalize-route-path.mjs.map +1 -0
  31. package/esm/{hydration → entry}/index.mjs +1 -1
  32. package/esm/entry/index.mjs.map +1 -0
  33. package/esm/hydration-payload.mjs.map +1 -1
  34. package/esm/index.d.mts +3 -2
  35. package/esm/index.mjs +3 -2
  36. package/esm/loaders.d.mts +10 -3
  37. package/esm/localization.mjs.map +1 -1
  38. package/esm/metadata.d.mts +4 -4
  39. package/esm/metadata.mjs.map +1 -1
  40. package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs +33 -0
  41. package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs.map +1 -0
  42. package/esm/{runtime/register-modules.d.mts → register-modules.d.mts} +1 -1
  43. package/esm/{runtime/register-modules.mjs → register-modules.mjs} +1 -1
  44. package/esm/register-modules.mjs.map +1 -0
  45. package/esm/route.d.mts +35 -2
  46. package/esm/routing/duplicate-route-path.mjs +22 -0
  47. package/esm/routing/duplicate-route-path.mjs.map +1 -0
  48. package/esm/routing/layout-level.mjs +50 -0
  49. package/esm/routing/layout-level.mjs.map +1 -0
  50. package/esm/routing/layout-policy.mjs.map +1 -1
  51. package/esm/routing/query-string.d.mts +23 -1
  52. package/esm/routing/query-string.mjs +23 -1
  53. package/esm/routing/query-string.mjs.map +1 -1
  54. package/esm/routing/route-identity.mjs +27 -2
  55. package/esm/routing/route-identity.mjs.map +1 -1
  56. package/esm/routing/route-table.mjs.map +1 -1
  57. package/esm/routing/router-events.d.mts +5 -4
  58. package/esm/routing/router-events.mjs.map +1 -1
  59. package/esm/server/create-page-route-handler.d.mts +2 -2
  60. package/esm/server/create-page-route-handler.mjs +6 -6
  61. package/esm/server/create-page-route-handler.mjs.map +1 -1
  62. package/esm/server/{dev-server.mjs → dev-error-transport.mjs} +2 -2
  63. package/esm/server/dev-error-transport.mjs.map +1 -0
  64. package/esm/server/execute-page-request.mjs +87 -23
  65. package/esm/server/execute-page-request.mjs.map +1 -1
  66. package/esm/server/execute-page-request.types.d.mts +10 -1
  67. package/esm/server/fold-layout-loaders.mjs +36 -0
  68. package/esm/server/fold-layout-loaders.mjs.map +1 -0
  69. package/esm/server/hydration-client-url.mjs +2 -2
  70. package/esm/server/hydration-client-url.mjs.map +1 -1
  71. package/esm/server/index.d.mts +2 -2
  72. package/esm/server/index.mjs +2 -2
  73. package/esm/server/install-page-routes-from-manifest.d.mts +1 -1
  74. package/esm/server/install-page-routes-from-manifest.mjs +43 -40
  75. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
  76. package/esm/server/install-page-routes.d.mts +22 -2
  77. package/esm/server/install-page-routes.mjs +108 -42
  78. package/esm/server/install-page-routes.mjs.map +1 -1
  79. package/esm/server/install-production-page-routes.mjs.map +1 -1
  80. package/esm/server/match-page-route.mjs.map +1 -1
  81. package/esm/server/page-file-change.mjs.map +1 -1
  82. package/esm/server/page-route-reload.mjs +4 -3
  83. package/esm/server/page-route-reload.mjs.map +1 -1
  84. package/esm/server/register-production-public-files.mjs +25 -1
  85. package/esm/server/register-production-public-files.mjs.map +1 -1
  86. package/esm/server/render-page.d.mts +11 -48
  87. package/esm/server/render-page.mjs +13 -55
  88. package/esm/server/render-page.mjs.map +1 -1
  89. package/esm/server/resolve-page-metadata.mjs.map +1 -1
  90. package/esm/server/resolve-route-validation-input.mjs +11 -0
  91. package/esm/server/resolve-route-validation-input.mjs.map +1 -0
  92. package/esm/server/route-validation-error.mjs +32 -0
  93. package/esm/server/route-validation-error.mjs.map +1 -0
  94. package/esm/server/settle-page-response.mjs +5 -3
  95. package/esm/server/settle-page-response.mjs.map +1 -1
  96. package/esm/server/unregistered-pages.mjs +2 -1
  97. package/esm/server/unregistered-pages.mjs.map +1 -1
  98. package/esm/server/web-connector-factory.mjs +1 -1
  99. package/esm/server/web-connector-factory.mjs.map +1 -1
  100. package/esm/server/web-connector.mjs +24 -321
  101. package/esm/server/web-connector.mjs.map +1 -1
  102. package/esm/shared/module-key.mjs +15 -0
  103. package/esm/shared/module-key.mjs.map +1 -0
  104. package/esm/shared/to-posix.mjs +13 -0
  105. package/esm/shared/to-posix.mjs.map +1 -0
  106. package/esm/shared.mjs +24 -0
  107. package/esm/shared.mjs.map +1 -1
  108. package/esm/vite/build-client.mjs.map +1 -1
  109. package/esm/vite/dev-server-config.mjs +240 -0
  110. package/esm/vite/dev-server-config.mjs.map +1 -0
  111. package/esm/vite/gate-a-resolve.d.mts +5 -4
  112. package/esm/vite/gate-a-resolve.mjs +31 -70
  113. package/esm/vite/gate-a-resolve.mjs.map +1 -1
  114. package/esm/vite/gate-b-secrets.mjs.map +1 -1
  115. package/esm/vite/gate-c-verify.d.mts +1 -1
  116. package/esm/vite/gate-c-verify.mjs +1 -1
  117. package/esm/vite/gate-c-verify.mjs.map +1 -1
  118. package/esm/vite/hydration-entries.mjs +5 -7
  119. package/esm/vite/hydration-entries.mjs.map +1 -1
  120. package/esm/vite/index.d.mts +1 -1
  121. package/esm/vite/index.mjs +4 -110
  122. package/esm/vite/index.mjs.map +1 -1
  123. package/esm/vite/page-registry-plugin.mjs +9 -26
  124. package/esm/vite/page-registry-plugin.mjs.map +1 -1
  125. package/esm/vite/projection.mjs.map +1 -1
  126. package/esm/vite/react-refresh-preamble.mjs +90 -0
  127. package/esm/vite/react-refresh-preamble.mjs.map +1 -0
  128. package/esm/vite/ssr-client-view.mjs +135 -0
  129. package/esm/vite/ssr-client-view.mjs.map +1 -0
  130. package/llms-full.txt +558 -482
  131. package/llms.txt +2 -2
  132. package/package.json +6 -6
  133. package/skills/create-a-page/SKILL.md +348 -295
  134. package/skills/navigate-on-the-client/SKILL.md +212 -189
  135. package/esm/hydration/index.mjs.map +0 -1
  136. package/esm/runtime/register-modules.mjs.map +0 -1
  137. package/esm/server/dev-server.mjs.map +0 -1
  138. /package/esm/{hydration → entry}/index.d.mts +0 -0
package/llms-full.txt CHANGED
@@ -114,300 +114,353 @@ 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 `route.validate` schema and `route.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`, `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 route's own input — `route.validate`
222
+
223
+ The object form also accepts `validate`: a [Seal](https://www.npmjs.com/package/@warlock.js/seal) object schema run against `{ params, 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
+ ```tsx title="src/web/products/product-details.page.tsx"
226
+ import { v } from "@warlock.js/seal";
227
+ import type { PageLoader, PageProps } from "@warlock.js/web";
228
+
229
+ export const route = {
230
+ path: "/products/:id",
231
+ name: "products.details",
232
+ validate: v.object({
233
+ params: v.object({ id: v.string().minLength(2) }),
234
+ query: v.object({ tab: v.string().optional() }),
235
+ }),
236
+ } as const;
237
+
238
+ export const loader = (async ({ request }) => {
239
+ const { params, query } = request.validated();
240
+
241
+ return { id: params.id, tab: query.tab };
242
+ }) satisfies PageLoader<undefined, typeof route>;
243
+
244
+ export default function ProductDetailsPage({ data }: PageProps<typeof loader>) {
245
+ return <h1>Product {data.id}</h1>;
246
+ }
247
+ ```
248
+
249
+ `request.validated()` types `params` and `query` from the schema — never a flattened merge of the two. If the page also declares the top-level `validation` export ([load-page-data](../load-page-data/SKILL.md)), both surfaces' fields are merged by intersection into the one `validated()` call; neither overwrites the other.
250
+
251
+ 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.
252
+
253
+ ### `route.middleware` a page's own guard, run last
254
+
255
+ The object form also accepts `middleware`: an array of `(ctx) => unknown | Promise<unknown>` guards declared 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 `route.middleware` runs LAST, closest to the loader — a layout's auth gate can never be bypassed by a page declaring its own middleware.
256
+
257
+ ```tsx
258
+ export const route = {
259
+ path: "/account",
260
+ middleware: [
261
+ async ({ request, response }) => {
262
+ if (!request.header("authorization")) {
263
+ response.setStatusCode(401);
264
+
265
+ return { error: "Unauthorized" };
266
+ }
267
+ },
268
+ ],
269
+ } as const;
270
+ ```
271
+
272
+ Returning anything other than `undefined` from a middleware short-circuits the request with that value, exactly as an app or layout middleware does.
273
+
274
+ ## Page caching
275
+
276
+ Page documents and their `x-warlock-data` representations are `no-store` by
277
+ default. Opt a public page into shared caching on its route:
278
+
279
+ ```tsx
280
+ export const route = {
281
+ path: "/products",
282
+ name: "products.index",
283
+ cache: { public: true, maxAge: 60 },
284
+ } as const;
285
+ ```
286
+
287
+ `maxAge` is seconds. Both keys are required: `cache: { public: true }` is a
288
+ boot-time `InvalidPageCacheOptInError`, because the framework will not invent
289
+ a freshness window. Remove `cache` entirely to keep the safe `no-store`
290
+ default.
291
+
292
+ The declaration is an opt-in, not an override of request safety. A response
293
+ that sets or clears a cookie, or a request that used authenticated state,
294
+ emits `Cache-Control: private, no-store`. If Warlock cannot determine whether
295
+ the request used authenticated state, it revokes the opt-in and emits
296
+ `Cache-Control: no-store`. Only a provably unauthenticated request with no
297
+ `Set-Cookie` can emit `public, max-age=<maxAge>`.
298
+
299
+ This decision is applied once after loaders finish, to both representations.
300
+ Setting `Cache-Control` manually in a loader cannot bypass the floor.
301
+
302
+ ## Filesystem routing
303
+
304
+ Omit `route` and the URL comes from the page's own path beneath `src/web`:
305
+
306
+ - Every directory contributes a segment, in order — `src/web/products/featured.page.tsx` derives `/products/featured`.
307
+ - 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`.
308
+ - `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`.
309
+ - `[id]` becomes `:id`: `src/web/products/[id].page.tsx` derives `/products/:id`.
310
+ - 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)).
311
+
312
+ Two pages that derive (or declare) the same effective path is a build error naming both files.
313
+
314
+ ### Catch-all segments are refused
315
+
316
+ There is no catch-all / rest-parameter form in filesystem routing. Only
317
+ `[name]` — a plain identifier in square brackets — is recognized as a
318
+ dynamic segment. `src/web/docs/[...slug].page.tsx` raises
319
+ `PageFileSegmentNotSupportedError` at boot and names both the page file and
320
+ the rejected segment.
321
+
322
+ Until a catch-all exists, use the terminal wildcard with an explicit `route`:
323
+
324
+ ```tsx
325
+ export const route = { path: "/docs/*", name: "docs.catchAll" } as const;
326
+ ```
327
+
328
+ ## Page-route grammar
329
+
330
+ Page routes deliberately accept less than API routes:
331
+
332
+ - Supported: `/`, static segments, whole-segment params such as `/products/:id`, the exact wildcard `*`, and a terminal wildcard such as `/docs/*`.
333
+ - 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]`).
334
+
335
+ Write two pages for an optional segment. Validate a constrained parameter in the page instead of putting a regex in its path.
336
+
337
+ An unsupported declared path is a boot-time
338
+ `PageRoutePathNotSupportedError`, never a literal or normalized route.
339
+ Examples that fail include `/users/:id?`, `/users/:id(\\d+)`,
340
+ `/near/:lat-:lng`, `/a//b`, and `/users/`.
341
+
342
+ ## Metadata
343
+
344
+ `metadata` may be a static object or a function of the resolved loader data and readonly `shared` payload:
345
+
346
+ ```tsx
347
+ import type { PageMetadata } from "@warlock.js/web";
348
+
349
+ export const metadata: PageMetadata = {
350
+ title: "Products",
351
+ description: "Browse the product catalogue",
352
+ robots: "index,follow",
353
+ openGraph: {
354
+ type: "website",
355
+ image: "/images/catalogue-card.png",
356
+ },
357
+ };
358
+ ```
359
+
360
+ 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.
361
+
362
+ ## The error boundary `error.page.tsx`
363
+
364
+ `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.
365
+
366
+ ```tsx title="src/web/error.page.tsx"
367
+ import type { ErrorPageProps } from "@warlock.js/web";
368
+
369
+ export default function ErrorPage({ error, status }: ErrorPageProps) {
370
+ return (
371
+ <main>
372
+ <h1>Something went wrong</h1>
373
+ <p>Status: {status}</p>
374
+ </main>
375
+ );
376
+ }
377
+ ```
378
+
379
+ 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.
380
+
381
+ Every unhandled error response is also forced to
382
+ `Cache-Control: private, no-store` at the framework's shared error funnel.
383
+ This includes page and API-route failures.
384
+
385
+ 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.
386
+
387
+ ## The `register()` hook
388
+
389
+ `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.
390
+
391
+ ```tsx
392
+ export function register() {
393
+ // one-time setup for this module namespace; must not return a Promise
394
+ }
395
+ ```
396
+
397
+ Returning a Promise (or anything thenable) throws — `register()` must finish before the module is usable.
398
+
399
+ ## The client boundary
400
+
401
+ The browser boundary is decided by the import graph, not by the file's location. A `*.page.tsx` is universal:
402
+
403
+ - `route`, `middleware`, `validation`, `loader`, and `metadata` are stripped from the client projection.
404
+ - The default component and any other surviving exports form the client graph.
405
+ - An import used only by a stripped server export is removed with it.
406
+ - An import also used by the component survives and therefore must be browser-safe.
407
+
408
+ Keep server-only repository and service reads inside `loader`. Do not read them from module-scope initializers or the default component.
409
+
410
+ ### `.client` does not isolate SSR
411
+
412
+ A `.client.tsx` suffix is a naming convention, not an SSR-isolation boundary. A
413
+ module statically imported by a page, layout, root, or any of their imports is
414
+ still evaluated by the server. Top-level browser globals such as `window`
415
+ therefore crash SSR boot. Warlock does not ship a client-only component
416
+ primitive; code that requires browser globals at module scope cannot be part of
417
+ the SSR page graph.
418
+
419
+ ### Static assets use `public/`
420
+
421
+ The production server build does not support imported non-stylesheet assets. An
422
+ import such as `import logo from "./logo.svg"` works under Vite in development
423
+ but `warlock build` refuses it rather than emit a server URL that disagrees with
424
+ the client bundle. Put the file in the application's `public/` directory and
425
+ reference it by root URL: `public/logo.svg` is `/logo.svg`. Stylesheet imports
426
+ are the exception and remain supported.
427
+
428
+ ## Editing a page in development
429
+
430
+ `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.
431
+
432
+ - **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.
433
+ - **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.
434
+ - **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.
435
+ - **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.
436
+ - 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.
437
+
438
+ ## Route-table changes in development
439
+
440
+ 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.
441
+
442
+ ## Gotchas
443
+
444
+ - **A page with no `route` is not unreachable.** It derives a real URL from its file location — see [Filesystem routing](#filesystem-routing).
445
+ - **A page with no default export IS refused.** Named exports alone fail the build naming the file, instead of serving a blank 200.
446
+ - **`.client.tsx` does not prevent SSR evaluation.** It is a naming convention, not a client-only component primitive.
447
+ - **Imported static assets do not build.** Put them in `public/` and reference their root URL; CSS imports remain supported.
448
+ - **`[...slug]` is not a catch-all.** It fails boot with
449
+ `PageFileSegmentNotSupportedError`; use an explicit terminal `*` route
450
+ instead — see [Catch-all segments are refused](#catch-all-segments-are-refused).
451
+ - **`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).
452
+ - **Keep `route` literal.** A computed route cannot be discovered without executing app code and is refused.
453
+ - **Do not annotate the loader with `: PageLoader`.** That erases the return type `PageProps` needs.
454
+ - **Components receive data, not HTTP objects.** `request` and `response` belong to loaders; the component also renders in the browser.
455
+ - **A default component is synchronous.** Fetch in the loader, then render its result.
456
+ - **There are no server actions.** Mutations remain ordinary API requests; call `refresh()` after a successful mutation.
457
+ - **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`.
458
+
459
+ ## See also
460
+
461
+ - [`load-page-data/SKILL.md`](../load-page-data/SKILL.md) — validation, loader context, response short-circuits, and `shared`.
462
+ - [`use-layouts/SKILL.md`](../use-layouts/SKILL.md) — positional layouts, prefixes, and persistence.
463
+ - [`navigate-on-the-client/SKILL.md`](../navigate-on-the-client/SKILL.md) — `Link`, `href`, navigation, and `refresh()`.
411
464
  - [`serve-styles/SKILL.md`](../serve-styles/SKILL.md) — CSS imports in page and root modules.
412
465
 
413
466
  ## load-page-data `@warlock.js/web/load-page-data/SKILL.md`
@@ -678,194 +731,217 @@ Only put browser-safe data in `shared`: scalars, arrays, plain objects, or value
678
731
 
679
732
  ## navigate-on-the-client `@warlock.js/web/navigate-on-the-client/SKILL.md`
680
733
 
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.
734
+ ---
735
+ name: navigate-on-the-client
736
+ 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.'
737
+ ---
738
+
739
+ # Warlock — navigate on the client
740
+
741
+ `<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.
742
+
743
+ Every behaviour on this page depends on hydration having mounted. See [write-the-root](../write-the-root/SKILL.md#root-is-the-hydration-boundary).
744
+
745
+ ## The shape
746
+
747
+ ```tsx title="src/web/components/product-link.tsx"
748
+ import { Link } from "@warlock.js/web";
749
+
750
+ export function ProductLink({ id }: { id: string }) {
751
+ return (
752
+ <Link to="products.details" params={{ id }} query={{ tab: "specifications" }} prefetch>
753
+ View product
754
+ </Link>
755
+ );
756
+ }
757
+ ```
758
+
759
+ 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.
760
+
761
+ ## Link destinations
762
+
763
+ Pass exactly one destination prop:
764
+
765
+ ```tsx
766
+ import { Link } from "@warlock.js/web";
767
+
768
+ export function NavigationLinks() {
769
+ return (
770
+ <nav>
771
+ <Link to="products.index">Products</Link>
772
+ <Link href="/pricing">Pricing</Link>
773
+ <Link href="https://example.com/docs" newTab>
774
+ External docs
775
+ </Link>
776
+ <Link email="sales@example.com">Email sales</Link>
777
+ <Link tel="+201000000000">Call sales</Link>
778
+ </nav>
779
+ );
780
+ }
781
+ ```
782
+
783
+ `to` and `href` both accept a route name or literal URL:
784
+
785
+ - A value beginning with `/` or with a URI scheme is literal and is passed through.
786
+ - Any other value is a route name and is resolved through `href()`.
787
+ - `params` and `query` apply only to route names.
788
+ - `newTab` supplies `_blank` and `noopener noreferrer` unless you provide your own `target` or `rel`.
789
+
790
+ `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.
791
+
792
+ Modified clicks, middle clicks, downloads, another browsing context, or an earlier `preventDefault()` remain browser-owned.
793
+
794
+ ## Build a URL without React
795
+
796
+ `href(name, params?, query?)` is the durable primitive for redirects, headers, email bodies, and other non-component callers:
797
+
798
+ ```ts
799
+ import { href } from "@warlock.js/web";
800
+
801
+ const productUrl = href(
802
+ "products.details",
803
+ { id: "42" },
804
+ { tab: "reviews", tags: ["featured", "sale"] },
805
+ );
806
+ ```
807
+
808
+ 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.
809
+
810
+ ## Programmatic navigation
811
+
812
+ ```tsx title="src/web/components/checkout-button.tsx"
813
+ import { href, navigateBack, navigateTo } from "@warlock.js/web";
814
+
815
+ export function CheckoutButtons() {
816
+ const openCheckout = () => {
817
+ const url = href("checkout.index");
818
+
819
+ if (!navigateTo(url)) {
820
+ window.location.assign(url);
821
+ }
822
+ };
823
+
824
+ return (
825
+ <div>
826
+ <button type="button" onClick={navigateBack}>
827
+ Back
828
+ </button>
829
+ <button type="button" onClick={openCheckout}>
830
+ Checkout
831
+ </button>
832
+ </div>
833
+ );
834
+ }
835
+ ```
836
+
837
+ `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.
838
+
839
+ ## Re-fetch after a mutation
840
+
841
+ The public primitive is `refresh()`. There is no `revalidate()` export.
842
+
843
+ ```tsx title="src/web/products/delete-product-button.tsx"
844
+ import { refresh } from "@warlock.js/web";
845
+
846
+ export function DeleteProductButton({ id }: { id: string }) {
847
+ const deleteProduct = async () => {
848
+ const response = await fetch(`/api/products/${encodeURIComponent(id)}`, {
849
+ method: "DELETE",
850
+ });
851
+
852
+ if (!response.ok) {
853
+ throw new Error(`Delete failed with status ${response.status}`);
854
+ }
855
+
856
+ const refreshed = await refresh();
857
+
858
+ if (!refreshed) {
859
+ window.location.reload();
860
+ }
861
+ };
862
+
863
+ return (
864
+ <button type="button" onClick={deleteProduct}>
865
+ Delete product
866
+ </button>
867
+ );
868
+ }
869
+ ```
870
+
871
+ `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.
872
+
873
+ This is an ordinary API mutation followed by a re-fetch. Server actions are not supported.
874
+
875
+ ## Read the query string live — `useQueryString`
876
+
877
+ ```tsx title="src/web/products/product-list-filters.tsx"
878
+ import { useQueryString } from "@warlock.js/web";
879
+
880
+ export function ProductListFilters() {
881
+ const tab = useQueryString("tab");
882
+ // Narrow before rendering: anyone can type `?tab[]=a` or `?tab[x]=1`, so the
883
+ // hook's type says the value may not be a string.
884
+ const active = typeof tab === "string" ? tab : "all";
885
+
886
+ return <p>Tab: {active}</p>;
887
+ }
888
+ ```
889
+
890
+ `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.
891
+
892
+ **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.
893
+
894
+ 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.
895
+
896
+ 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.
897
+
898
+ ## Current and previous routes
899
+
900
+ `currentRoute()` and `previousRoute()` report route names and params sent by the server; they never re-match the browser URL.
901
+
902
+ ```tsx title="src/web/components/current-product-id.tsx"
903
+ import { currentRoute, previousRoute } from "@warlock.js/web";
904
+
905
+ export function CurrentProductId() {
906
+ const current = currentRoute();
907
+ const previous = previousRoute();
908
+
909
+ return (
910
+ <dl>
911
+ <dt>Current product</dt>
912
+ <dd>{current?.params?.id ?? "none"}</dd>
913
+ <dt>Previous page</dt>
914
+ <dd>{previous?.name ?? "first visit"}</dd>
915
+ </dl>
916
+ );
917
+ }
918
+ ```
919
+
920
+ `previousRoute()` means the previously swapped page, not the previous browser-history entry. On the first page it is `undefined`.
921
+
922
+ `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.
923
+
924
+ ## Failure behavior
925
+
926
+ - A failed navigation falls back to a full browser load so the user still arrives.
927
+ - A failed `refresh()` keeps the current page and reports `false`; it never spends the page the user already has.
928
+ - Back/Forward uses the same data fetch and tree swap, replacing the existing history entry rather than pushing another.
929
+ - Navigating within one layout preserves layout state through React reconciliation.
930
+
931
+ ## Gotchas
932
+
933
+ - **Use `refresh()`, not `revalidate()`.** `refresh` is the exported loader re-fetch primitive.
934
+ - **`navigateTo` accepts a URL/path.** Resolve named routes with `href()` first.
935
+ - **Pass exactly one Link destination.** Combining `to`, `href`, `email`, or `tel` throws.
936
+ - **Do not add `params` or `query` to a literal URL.** Put them in the URL itself or use a route name.
937
+ - **Do not build a client matcher.** The server's matched name and params travel in the payload.
938
+ - **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`.
939
+
940
+ ## See also
941
+
942
+ - [`create-a-page/SKILL.md`](../create-a-page/SKILL.md) — declare route names and params.
943
+ - [`load-page-data/SKILL.md`](../load-page-data/SKILL.md) — what `refresh()` re-runs.
944
+ - [`use-layouts/SKILL.md`](../use-layouts/SKILL.md) — why layout state persists.
869
945
  - [`write-the-root/SKILL.md`](../write-the-root/SKILL.md) — the `#root` swap boundary.
870
946
 
871
947
  ## serve-styles `@warlock.js/web/serve-styles/SKILL.md`