@warlock.js/web 5.2.4 → 5.3.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 (62) hide show
  1. package/esm/build/discover-pages.mjs +5 -7
  2. package/esm/build/discover-pages.mjs.map +1 -1
  3. package/esm/client/hydrate-page.mjs +4 -3
  4. package/esm/client/hydrate-page.mjs.map +1 -1
  5. package/esm/client/navigation/fetch-page-data.mjs +2 -2
  6. package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
  7. package/esm/client/navigation/navigation-root.mjs +5 -1
  8. package/esm/client/navigation/navigation-root.mjs.map +1 -1
  9. package/esm/components/document-context.mjs.map +1 -1
  10. package/esm/hydration-payload.mjs +6 -2
  11. package/esm/hydration-payload.mjs.map +1 -1
  12. package/esm/index.d.mts +4 -2
  13. package/esm/index.mjs +2 -1
  14. package/esm/localization.d.mts +21 -0
  15. package/esm/localization.mjs +28 -0
  16. package/esm/localization.mjs.map +1 -0
  17. package/esm/routing/data-request.mjs +5 -3
  18. package/esm/routing/data-request.mjs.map +1 -1
  19. package/esm/routing/filesystem-route.mjs +36 -7
  20. package/esm/routing/filesystem-route.mjs.map +1 -1
  21. package/esm/routing/page-file-segment.mjs +66 -0
  22. package/esm/routing/page-file-segment.mjs.map +1 -0
  23. package/esm/routing/page-route-grammar.mjs +79 -0
  24. package/esm/routing/page-route-grammar.mjs.map +1 -0
  25. package/esm/routing/route-identity.d.mts +69 -0
  26. package/esm/routing/route-identity.mjs +100 -44
  27. package/esm/routing/route-identity.mjs.map +1 -1
  28. package/esm/server/build-hydration-payload.mjs +3 -2
  29. package/esm/server/build-hydration-payload.mjs.map +1 -1
  30. package/esm/server/create-page-route-handler.d.mts +34 -1
  31. package/esm/server/create-page-route-handler.mjs +35 -4
  32. package/esm/server/create-page-route-handler.mjs.map +1 -1
  33. package/esm/server/framework-default-not-found-stylesheet.mjs +102 -0
  34. package/esm/server/framework-default-not-found-stylesheet.mjs.map +1 -0
  35. package/esm/server/install-page-routes-from-manifest.mjs +13 -16
  36. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
  37. package/esm/server/install-page-routes.d.mts +3 -1
  38. package/esm/server/install-page-routes.mjs +11 -11
  39. package/esm/server/install-page-routes.mjs.map +1 -1
  40. package/esm/server/not-found-page.d.mts +1 -13
  41. package/esm/server/not-found-page.mjs +50 -5
  42. package/esm/server/not-found-page.mjs.map +1 -1
  43. package/esm/server/register-production-public-files.mjs +16 -1
  44. package/esm/server/register-production-public-files.mjs.map +1 -1
  45. package/esm/server/render-page.d.mts +1 -8
  46. package/esm/server/render-page.mjs +24 -21
  47. package/esm/server/render-page.mjs.map +1 -1
  48. package/esm/server/response-cache-floor.mjs +79 -0
  49. package/esm/server/response-cache-floor.mjs.map +1 -0
  50. package/esm/server/set-cookie-cache-floor-hook.mjs +41 -0
  51. package/esm/server/set-cookie-cache-floor-hook.mjs.map +1 -0
  52. package/esm/server/web-connector.d.mts +1 -1
  53. package/esm/server/web-connector.mjs +23 -4
  54. package/esm/server/web-connector.mjs.map +1 -1
  55. package/llms-full.txt +62 -33
  56. package/llms.txt +1 -1
  57. package/package.json +4 -3
  58. package/skills/create-a-page/SKILL.md +59 -28
  59. package/skills/navigate-on-the-client/SKILL.md +16 -11
  60. package/skills/serve-styles/SKILL.md +2 -1
  61. package/skills/use-layouts/SKILL.md +3 -6
  62. package/skills/write-the-root/SKILL.md +0 -2
package/llms-full.txt CHANGED
@@ -116,12 +116,12 @@ The desired result is one page route at `/` and, when the stock JSON route exist
116
116
 
117
117
  ---
118
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, a default component, an optional typed `loader`, page `metadata`, the `error.page.tsx` boundary, and the universal `register()` hook. Triggers: `*.page.tsx`, `route`, `PageLoader`, `PageProps`, `PageMetadata`, `error.page.tsx`, `register`, `[...slug]`; "create a 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.'
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
120
  ---
121
121
 
122
122
  # Warlock — create a page
123
123
 
124
- A page is any `*.page.tsx` beneath `src/web/` — the only page root. (A per-module `src/app/<module>/web/` tree is not scanned; move any page that lived there into `src/web/`, a subdirectory if you like.) Its URL is either a declared `route` or one derived from its own location; its default export renders React.
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
125
 
126
126
  ## The shape
127
127
 
@@ -131,13 +131,12 @@ import type { PageLoader, PageMetadata, PageProps } from "@warlock.js/web";
131
131
  export const route = {
132
132
  path: "/products/:id",
133
133
  name: "products.details",
134
+ cache: { public: true, maxAge: 60 },
134
135
  } as const;
135
136
 
136
- export const loader = (async ({ request, response }) => {
137
+ export const loader = (async ({ request }) => {
137
138
  const id = request.input("id");
138
139
 
139
- response.header("cache-control", "private, max-age=60");
140
-
141
140
  return {
142
141
  product: {
143
142
  id,
@@ -180,8 +179,6 @@ export default function ContactPage() {
180
179
 
181
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.
182
181
 
183
- Changed in 5.2: through 5.1 an omitted `route` was refused — `warlock dev` and the production discovery pass threw `MissingRouteExportError`, naming the file. That error class is gone; the same file now resolves to a real, browsable URL.
184
-
185
182
  ### The default export is required
186
183
 
187
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**:
@@ -192,8 +189,6 @@ file must default-export the React component it renders. For example:
192
189
  `export default function Page() { return <main />; }`
193
190
  ```
194
191
 
195
- Changed in 5.2: such a file used to be discovered and registered anyway, then serve a blank `200` at its URL — a page that looked deployed and rendered nothing, with no error anywhere. It now stops the build.
196
-
197
192
  Two details worth knowing:
198
193
 
199
194
  - **`export { Page as default }` satisfies the rule** — the check is for a runtime default binding, not for the `export default` keyword form specifically.
@@ -213,6 +208,7 @@ export const route = "/products";
213
208
  export const route = {
214
209
  path: "/products/:id",
215
210
  name: "products.details",
211
+ cache: { public: true, maxAge: 60 },
216
212
  } as const;
217
213
  ```
218
214
 
@@ -222,25 +218,53 @@ Every segment of a page's URL is written down somewhere: `route.path` (or the de
222
218
 
223
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.
224
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
+
225
249
  ## Filesystem routing
226
250
 
227
251
  Omit `route` and the URL comes from the page's own path beneath `src/web`:
228
252
 
229
253
  - Every directory contributes a segment, in order — `src/web/products/featured.page.tsx` derives `/products/featured`.
230
- - A `(group)` directory — parentheses, not braces — contributes nothing to the URL, only to organization: `src/web/(marketing)/pricing.page.tsx` derives `/pricing`.
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`.
231
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`.
232
256
  - `[id]` becomes `:id`: `src/web/products/[id].page.tsx` derives `/products/:id`.
233
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)).
234
258
 
235
259
  Two pages that derive (or declare) the same effective path is a build error naming both files.
236
260
 
237
- ### Catch-all segments are NOT supported
238
-
239
- **`[...slug].page.tsx` does not do what it looks like.** There is no catch-all / rest-parameter form in filesystem routing. Only `[name]` — a plain identifier in square brackets — is recognized as a dynamic segment; `[...slug]` fails that pattern and is taken as an ordinary **literal** segment.
240
-
241
- So `src/web/docs/[...slug].page.tsx` derives the route path `/docs/[...slug]` and the route name `docs.[...slug]`. It registers, it builds, and it serves — at the literal URL `/docs/%5B...slug%5D` and nowhere else. `/docs/a/b` does not match it.
261
+ ### Catch-all segments are refused
242
262
 
243
- ⚠ **Nothing warns you.** There is no build error, no dev warning, and no refusal; the page simply answers a URL nobody will ever request. If you came from Next.js or Remix expecting `[...slug]` to work, this is the failure mode to recognize.
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.
244
268
 
245
269
  Until a catch-all exists, use the terminal wildcard with an explicit `route`:
246
270
 
@@ -257,6 +281,11 @@ Page routes deliberately accept less than API routes:
257
281
 
258
282
  Write two pages for an optional segment. Validate a constrained parameter in the page instead of putting a regex in its path.
259
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
+
260
289
  ## Metadata
261
290
 
262
291
  `metadata` may be a static object or a function of the resolved loader data and readonly `shared` payload:
@@ -296,6 +325,10 @@ export default function ErrorPage({ error, status }: ErrorPageProps) {
296
325
 
297
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.
298
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
+
299
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.
300
333
 
301
334
  ## The `register()` hook
@@ -321,16 +354,16 @@ The browser boundary is decided by the import graph, not by the file's location.
321
354
 
322
355
  Keep server-only repository and service reads inside `loader`. Do not read them from module-scope initializers or the default component.
323
356
 
324
- ### `.client` does not isolate SSR in 5.2
357
+ ### `.client` does not isolate SSR
325
358
 
326
359
  A `.client.tsx` suffix is a naming convention, not an SSR-isolation boundary. A
327
360
  module statically imported by a page, layout, root, or any of their imports is
328
361
  still evaluated by the server. Top-level browser globals such as `window`
329
- therefore crash SSR boot. Warlock 5.2 does not ship a client-only component
362
+ therefore crash SSR boot. Warlock does not ship a client-only component
330
363
  primitive; code that requires browser globals at module scope cannot be part of
331
364
  the SSR page graph.
332
365
 
333
- ### Static assets use `public/` in 5.2
366
+ ### Static assets use `public/`
334
367
 
335
368
  The production server build does not support imported non-stylesheet assets. An
336
369
  import such as `import logo from "./logo.svg"` works under Vite in development
@@ -341,7 +374,7 @@ are the exception and remain supported.
341
374
 
342
375
  ## Editing a page in development
343
376
 
344
- `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.
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.
345
378
 
346
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.
347
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.
@@ -349,21 +382,19 @@ are the exception and remain supported.
349
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.
350
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.
351
384
 
352
- Changed in 5.1: a metadata-only edit previously left a stale `<head>` until you refreshed the browser by hand. The current skeleton-comparison rule replaces that earlier, narrower "metadata-only" special case.
353
-
354
385
  ## Route-table changes in development
355
386
 
356
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.
357
388
 
358
- Changed in 5.2: through 5.1 the route table was built once at boot and never again. Creating a page 404'd forever; deleting one kept it reachable at its old URL; editing `route` served the OLD path from the old file and 404'd the new one — the running server silently disagreed with the source on disk. All three are now live.
359
-
360
389
  ## Gotchas
361
390
 
362
- - **A page with no `route` is not unreachable.** It derives a real URL from its file location — see [Filesystem routing](#filesystem-routing). Nothing about a route-less page is refused anymore.
391
+ - **A page with no `route` is not unreachable.** It derives a real URL from its file location — see [Filesystem routing](#filesystem-routing).
363
392
  - **A page with no default export IS refused.** Named exports alone fail the build naming the file, instead of serving a blank 200.
364
- - **`.client.tsx` does not prevent SSR evaluation.** It is a naming convention in 5.2, not a client-only component primitive.
365
- - **Imported static assets do not build in 5.2.** Put them in `public/` and reference their root URL; CSS imports remain supported.
366
- - **`[...slug]` is not a catch-all.** It is read as a literal path segment and silently produces an unreachable route — see [Catch-all segments are NOT supported](#catch-all-segments-are-not-supported).
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).
367
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).
368
399
  - **Keep `route` literal.** A computed route cannot be discovered without executing app code and is refused.
369
400
  - **Do not annotate the loader with `: PageLoader`.** That erases the return type `PageProps` needs.
@@ -656,7 +687,7 @@ description: 'Navigate hydrated pages with `<Link>`, resolve named URLs with `hr
656
687
 
657
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.
658
689
 
659
- Every behaviour on this page requires 5.1 in an installed app. In 5.0.0–5.0.2 no client JavaScript executed at all — `react-dom/client` was served as raw CommonJS and hydration never mounted — so `<Link>` degraded to its underlying anchor and every click was a full page load. See [write-the-root](../write-the-root/SKILL.md#root-is-the-hydration-boundary).
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).
660
691
 
661
692
  ## The shape
662
693
 
@@ -926,7 +957,7 @@ export default function ProductsPage() {
926
957
 
927
958
  Recognized stylesheet imports survive the page's client projection. The client boundary is determined by the import graph, not by the file living under `web/`.
928
959
 
929
- That support is specific to stylesheets in the production page graph. An imported non-stylesheet asset such as `import logo from "./logo.svg"` works under Vite in development but is refused by `warlock build` in 5.2. Put it in the application's `public/` directory and reference its root URL instead: `public/logo.svg` is `/logo.svg`.
960
+ That support is specific to stylesheets in the production page graph. An imported non-stylesheet asset such as `import logo from "./logo.svg"` works under Vite in development but is refused by `warlock build`. Put it in the application's `public/` directory and reference its root URL instead: `public/logo.svg` is `/logo.svg`.
930
961
 
931
962
  For each matched handler, Warlock builds one ordered CSS chain: `[root, ...matched layouts, page]`. A stylesheet imported directly by any member of that chain becomes a render-blocking link in the initial document in both development and production. Unrelated pages and layouts do not contribute CSS to this response.
932
963
 
@@ -1079,7 +1110,7 @@ Non-rendering layouts may carry prefixes and middleware and may nest freely. Do
1079
1110
 
1080
1111
  ## `404.page.tsx` never gets a layout
1081
1112
 
1082
- A `404.page.tsx` renders with no layouts, even when it sits in a directory with a rendering `layout.tsx` above it. Discovery reports an empty layout chain for the not-found page only, and both the client hydration registry and the production route table read from that same chain — the server has always rendered it with no layout wrapper, so the client no longer hydrates one either. A page that exists to handle failure must not depend on app chrome that can itself throw or need data.
1113
+ A `404.page.tsx` renders with no layouts, even when it sits in a directory with a rendering `layout.tsx` above it. Discovery reports an empty layout chain for the not-found page only, and both the client hydration registry and the production route table read from that same chain — the server renders it with no layout wrapper, and the client hydrates it the same way, with no layout of its own. A page that exists to handle failure must not depend on app chrome that can itself throw or need data.
1083
1114
 
1084
1115
  This is scoped to the not-found page: an ordinary page in the same directory still gets its full layout chain, and nested-layout refusal on the 404's own path is still enforced exactly as it is for any other page.
1085
1116
 
@@ -1167,8 +1198,6 @@ The browser hydrates `#root`, not the whole document. The client tree deliberate
1167
1198
 
1168
1199
  Because App is outside the hydrated subtree, put client state that must survive navigation in a layout or component beneath `#root`, not in the document root.
1169
1200
 
1170
- **Requires 5.1 in an installed app.** In 5.0.0–5.0.2 the dev server handed the browser `react-dom/client` as raw CommonJS, so `hydrateRoot` was not there as a named export and the hydration entry died on import — no client JavaScript ran at all. The symptoms were a page that server-rendered correctly but had dead `useState`, no HMR, and `<Link>` doing full page loads. The fix pre-bundles React through Vite's `optimizeDeps`. If you are debugging this against a checkout of the framework itself, note that the defect never reproduced there: inside the monorepo the hydration entry resolves outside `node_modules` and React was always optimized normally.
1171
-
1172
1201
  If `root.tsx` (or any module it needs) fails to load or its `register()` throws, there is no trustworthy Layout+Page composition left to hydrate — Warlock falls back to a plain document with no hydration script at all rather than hydrate the browser against markup nothing can vouch for. See [create-a-page](../create-a-page/SKILL.md) for the app's own `error.page.tsx` boundary, which is tried first.
1173
1202
 
1174
1203
  ## `<Head />`
package/llms.txt CHANGED
@@ -17,7 +17,7 @@
17
17
  ## Skills
18
18
 
19
19
  - [add-web-to-an-app](@warlock.js/web/add-web-to-an-app/SKILL.md): Install the SSR page layer with `warlock add web`: add React/Vite peers, scaffold `src/web/root.tsx` and `src/web/index.page.tsx`, register `webConnector()`, and safely relocate the stock top-level `GET "/"` JSON route to `/welcome`. Triggers: `warlock add web`, `webConnector`, `src/web/root.tsx`, `src/web/index.page.tsx`, `GET "/welcome"`; "add web to an app", "install Warlock web", "scaffold SSR", "homepage route collision". Skip: author a page — `@warlock.js/web/create-a-page/SKILL.md`; customize the document — `@warlock.js/web/write-the-root/SKILL.md`; dev/build/start commands — `@warlock.js/core/run-app/SKILL.md`; competing installers `create-next-app`, `vite create`, `remix init`.
20
- - [create-a-page](@warlock.js/web/create-a-page/SKILL.md): Create an SSR React page under `src/web/**`, with either a literal `route` or a filesystem-derived one, a default component, an optional typed `loader`, page `metadata`, the `error.page.tsx` boundary, and the universal `register()` hook. Triggers: `*.page.tsx`, `route`, `PageLoader`, `PageProps`, `PageMetadata`, `error.page.tsx`, `register`, `[...slug]`; "create a 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.
20
+ - [create-a-page](@warlock.js/web/create-a-page/SKILL.md): 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.
21
21
  - [load-page-data](@warlock.js/web/load-page-data/SKILL.md): Load App, Layout, and Page data with `AppLoader`, `LayoutLoader`, and `PageLoader`; type component `data`, validate page input, short-circuit with the buffered response, and publish request-scoped browser-safe values through `shared`. Triggers: `PageLoader`, `LayoutLoader`, `AppLoader`, `PageProps`, `shared`, `useShared`, `validation`, `request.validated`, `process.env`, `env("PUBLIC_...")`, `import.meta.env.PUBLIC_`; "load page data", "pass server data to React", "share request data", "redirect from a loader", "read an environment variable in a page", "process.env refused in the client build", "loader execution order", "return a Response from a loader". Skip: page module basics — `@warlock.js/web/create-a-page/SKILL.md`; layouts — `@warlock.js/web/use-layouts/SKILL.md`; mutation follow-up — `@warlock.js/web/navigate-on-the-client/SKILL.md`; competing loaders Next data functions, Remix loaders, React Server Components.
22
22
  - [navigate-on-the-client](@warlock.js/web/navigate-on-the-client/SKILL.md): 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.
23
23
  - [serve-styles](@warlock.js/web/serve-styles/SKILL.md): Serve CSS imported by `root.tsx` or `*.page.tsx`, with render-blocking `<link rel="stylesheet">` delivery from Vite source URLs in development and Vite manifest assets in production. Triggers: `import "./app.css"`, page CSS, `?direct`, `manifest.json`, stylesheet flash, FOUC, `<head>`; "add global styles", "style a page", "CSS missing in SSR", "page flashes unstyled", "serve CSS in production". Skip: root document markup — `@warlock.js/web/write-the-root/SKILL.md`; page authoring — `@warlock.js/web/create-a-page/SKILL.md`; client navigation — `@warlock.js/web/navigate-on-the-client/SKILL.md`; competing styling systems CSS-in-JS, Next CSS, styled-components.
package/package.json CHANGED
@@ -7,12 +7,13 @@
7
7
  "dependencies": {
8
8
  "@babel/parser": "^7.29.7",
9
9
  "@babel/types": "^7.29.7",
10
+ "@mongez/localization": "^3.4.7",
10
11
  "magic-string": "^0.30.21"
11
12
  },
12
13
  "peerDependencies": {
13
14
  "@vitejs/plugin-react": "^5.2.0",
14
- "@warlock.js/core": "5.2.4",
15
- "@warlock.js/seal": "5.2.4",
15
+ "@warlock.js/core": "5.3.0",
16
+ "@warlock.js/seal": "5.3.0",
16
17
  "react": "*",
17
18
  "react-dom": "*",
18
19
  "vite": ">=7.3.5 <8"
@@ -37,7 +38,7 @@
37
38
  ],
38
39
  "author": "hassanzohdy",
39
40
  "license": "MIT",
40
- "version": "5.2.4",
41
+ "version": "5.3.0",
41
42
  "type": "module",
42
43
  "main": "./esm/index.mjs",
43
44
  "module": "./esm/index.mjs",
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: create-a-page
3
- description: 'Create an SSR React page under `src/web/**`, with either a literal `route` or a filesystem-derived one, a default component, an optional typed `loader`, page `metadata`, the `error.page.tsx` boundary, and the universal `register()` hook. Triggers: `*.page.tsx`, `route`, `PageLoader`, `PageProps`, `PageMetadata`, `error.page.tsx`, `register`, `[...slug]`; "create a 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.'
3
+ 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.'
4
4
  ---
5
5
 
6
6
  # Warlock — create a page
7
7
 
8
- A page is any `*.page.tsx` beneath `src/web/` — the only page root. (A per-module `src/app/<module>/web/` tree is not scanned; move any page that lived there into `src/web/`, a subdirectory if you like.) Its URL is either a declared `route` or one derived from its own location; its default export renders React.
8
+ 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.
9
9
 
10
10
  ## The shape
11
11
 
@@ -15,13 +15,12 @@ import type { PageLoader, PageMetadata, PageProps } from "@warlock.js/web";
15
15
  export const route = {
16
16
  path: "/products/:id",
17
17
  name: "products.details",
18
+ cache: { public: true, maxAge: 60 },
18
19
  } as const;
19
20
 
20
- export const loader = (async ({ request, response }) => {
21
+ export const loader = (async ({ request }) => {
21
22
  const id = request.input("id");
22
23
 
23
- response.header("cache-control", "private, max-age=60");
24
-
25
24
  return {
26
25
  product: {
27
26
  id,
@@ -64,8 +63,6 @@ export default function ContactPage() {
64
63
 
65
64
  `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.
66
65
 
67
- Changed in 5.2: through 5.1 an omitted `route` was refused — `warlock dev` and the production discovery pass threw `MissingRouteExportError`, naming the file. That error class is gone; the same file now resolves to a real, browsable URL.
68
-
69
66
  ### The default export is required
70
67
 
71
68
  `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**:
@@ -76,8 +73,6 @@ file must default-export the React component it renders. For example:
76
73
  `export default function Page() { return <main />; }`
77
74
  ```
78
75
 
79
- Changed in 5.2: such a file used to be discovered and registered anyway, then serve a blank `200` at its URL — a page that looked deployed and rendered nothing, with no error anywhere. It now stops the build.
80
-
81
76
  Two details worth knowing:
82
77
 
83
78
  - **`export { Page as default }` satisfies the rule** — the check is for a runtime default binding, not for the `export default` keyword form specifically.
@@ -97,6 +92,7 @@ export const route = "/products";
97
92
  export const route = {
98
93
  path: "/products/:id",
99
94
  name: "products.details",
95
+ cache: { public: true, maxAge: 60 },
100
96
  } as const;
101
97
  ```
102
98
 
@@ -106,25 +102,53 @@ Every segment of a page's URL is written down somewhere: `route.path` (or the de
106
102
 
107
103
  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.
108
104
 
105
+ ## Page caching
106
+
107
+ Page documents and their `x-warlock-data` representations are `no-store` by
108
+ default. Opt a public page into shared caching on its route:
109
+
110
+ ```tsx
111
+ export const route = {
112
+ path: "/products",
113
+ name: "products.index",
114
+ cache: { public: true, maxAge: 60 },
115
+ } as const;
116
+ ```
117
+
118
+ `maxAge` is seconds. Both keys are required: `cache: { public: true }` is a
119
+ boot-time `InvalidPageCacheOptInError`, because the framework will not invent
120
+ a freshness window. Remove `cache` entirely to keep the safe `no-store`
121
+ default.
122
+
123
+ The declaration is an opt-in, not an override of request safety. A response
124
+ that sets or clears a cookie, or a request that used authenticated state,
125
+ emits `Cache-Control: private, no-store`. If Warlock cannot determine whether
126
+ the request used authenticated state, it revokes the opt-in and emits
127
+ `Cache-Control: no-store`. Only a provably unauthenticated request with no
128
+ `Set-Cookie` can emit `public, max-age=<maxAge>`.
129
+
130
+ This decision is applied once after loaders finish, to both representations.
131
+ Setting `Cache-Control` manually in a loader cannot bypass the floor.
132
+
109
133
  ## Filesystem routing
110
134
 
111
135
  Omit `route` and the URL comes from the page's own path beneath `src/web`:
112
136
 
113
137
  - Every directory contributes a segment, in order — `src/web/products/featured.page.tsx` derives `/products/featured`.
114
- - A `(group)` directory — parentheses, not braces — contributes nothing to the URL, only to organization: `src/web/(marketing)/pricing.page.tsx` derives `/pricing`.
138
+ - 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`.
115
139
  - `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`.
116
140
  - `[id]` becomes `:id`: `src/web/products/[id].page.tsx` derives `/products/:id`.
117
141
  - 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)).
118
142
 
119
143
  Two pages that derive (or declare) the same effective path is a build error naming both files.
120
144
 
121
- ### Catch-all segments are NOT supported
122
-
123
- **`[...slug].page.tsx` does not do what it looks like.** There is no catch-all / rest-parameter form in filesystem routing. Only `[name]` — a plain identifier in square brackets — is recognized as a dynamic segment; `[...slug]` fails that pattern and is taken as an ordinary **literal** segment.
124
-
125
- So `src/web/docs/[...slug].page.tsx` derives the route path `/docs/[...slug]` and the route name `docs.[...slug]`. It registers, it builds, and it serves — at the literal URL `/docs/%5B...slug%5D` and nowhere else. `/docs/a/b` does not match it.
145
+ ### Catch-all segments are refused
126
146
 
127
- ⚠ **Nothing warns you.** There is no build error, no dev warning, and no refusal; the page simply answers a URL nobody will ever request. If you came from Next.js or Remix expecting `[...slug]` to work, this is the failure mode to recognize.
147
+ There is no catch-all / rest-parameter form in filesystem routing. Only
148
+ `[name]` — a plain identifier in square brackets — is recognized as a
149
+ dynamic segment. `src/web/docs/[...slug].page.tsx` raises
150
+ `PageFileSegmentNotSupportedError` at boot and names both the page file and
151
+ the rejected segment.
128
152
 
129
153
  Until a catch-all exists, use the terminal wildcard with an explicit `route`:
130
154
 
@@ -141,6 +165,11 @@ Page routes deliberately accept less than API routes:
141
165
 
142
166
  Write two pages for an optional segment. Validate a constrained parameter in the page instead of putting a regex in its path.
143
167
 
168
+ An unsupported declared path is a boot-time
169
+ `PageRoutePathNotSupportedError`, never a literal or normalized route.
170
+ Examples that fail include `/users/:id?`, `/users/:id(\\d+)`,
171
+ `/near/:lat-:lng`, `/a//b`, and `/users/`.
172
+
144
173
  ## Metadata
145
174
 
146
175
  `metadata` may be a static object or a function of the resolved loader data and readonly `shared` payload:
@@ -180,6 +209,10 @@ export default function ErrorPage({ error, status }: ErrorPageProps) {
180
209
 
181
210
  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.
182
211
 
212
+ Every unhandled error response is also forced to
213
+ `Cache-Control: private, no-store` at the framework's shared error funnel.
214
+ This includes page and API-route failures.
215
+
183
216
  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.
184
217
 
185
218
  ## The `register()` hook
@@ -205,16 +238,16 @@ The browser boundary is decided by the import graph, not by the file's location.
205
238
 
206
239
  Keep server-only repository and service reads inside `loader`. Do not read them from module-scope initializers or the default component.
207
240
 
208
- ### `.client` does not isolate SSR in 5.2
241
+ ### `.client` does not isolate SSR
209
242
 
210
243
  A `.client.tsx` suffix is a naming convention, not an SSR-isolation boundary. A
211
244
  module statically imported by a page, layout, root, or any of their imports is
212
245
  still evaluated by the server. Top-level browser globals such as `window`
213
- therefore crash SSR boot. Warlock 5.2 does not ship a client-only component
246
+ therefore crash SSR boot. Warlock does not ship a client-only component
214
247
  primitive; code that requires browser globals at module scope cannot be part of
215
248
  the SSR page graph.
216
249
 
217
- ### Static assets use `public/` in 5.2
250
+ ### Static assets use `public/`
218
251
 
219
252
  The production server build does not support imported non-stylesheet assets. An
220
253
  import such as `import logo from "./logo.svg"` works under Vite in development
@@ -225,7 +258,7 @@ are the exception and remain supported.
225
258
 
226
259
  ## Editing a page in development
227
260
 
228
- `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.
261
+ `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.
229
262
 
230
263
  - **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.
231
264
  - **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.
@@ -233,21 +266,19 @@ are the exception and remain supported.
233
266
  - **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.
234
267
  - 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.
235
268
 
236
- Changed in 5.1: a metadata-only edit previously left a stale `<head>` until you refreshed the browser by hand. The current skeleton-comparison rule replaces that earlier, narrower "metadata-only" special case.
237
-
238
269
  ## Route-table changes in development
239
270
 
240
271
  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.
241
272
 
242
- Changed in 5.2: through 5.1 the route table was built once at boot and never again. Creating a page 404'd forever; deleting one kept it reachable at its old URL; editing `route` served the OLD path from the old file and 404'd the new one — the running server silently disagreed with the source on disk. All three are now live.
243
-
244
273
  ## Gotchas
245
274
 
246
- - **A page with no `route` is not unreachable.** It derives a real URL from its file location — see [Filesystem routing](#filesystem-routing). Nothing about a route-less page is refused anymore.
275
+ - **A page with no `route` is not unreachable.** It derives a real URL from its file location — see [Filesystem routing](#filesystem-routing).
247
276
  - **A page with no default export IS refused.** Named exports alone fail the build naming the file, instead of serving a blank 200.
248
- - **`.client.tsx` does not prevent SSR evaluation.** It is a naming convention in 5.2, not a client-only component primitive.
249
- - **Imported static assets do not build in 5.2.** Put them in `public/` and reference their root URL; CSS imports remain supported.
250
- - **`[...slug]` is not a catch-all.** It is read as a literal path segment and silently produces an unreachable route — see [Catch-all segments are NOT supported](#catch-all-segments-are-not-supported).
277
+ - **`.client.tsx` does not prevent SSR evaluation.** It is a naming convention, not a client-only component primitive.
278
+ - **Imported static assets do not build.** Put them in `public/` and reference their root URL; CSS imports remain supported.
279
+ - **`[...slug]` is not a catch-all.** It fails boot with
280
+ `PageFileSegmentNotSupportedError`; use an explicit terminal `*` route
281
+ instead — see [Catch-all segments are refused](#catch-all-segments-are-refused).
251
282
  - **`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).
252
283
  - **Keep `route` literal.** A computed route cannot be discovered without executing app code and is refused.
253
284
  - **Do not annotate the loader with `: PageLoader`.** That erases the return type `PageProps` needs.
@@ -7,7 +7,7 @@ description: 'Navigate hydrated pages with `<Link>`, resolve named URLs with `hr
7
7
 
8
8
  `<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.
9
9
 
10
- Every behaviour on this page requires 5.1 in an installed app. In 5.0.0–5.0.2 no client JavaScript executed at all — `react-dom/client` was served as raw CommonJS and hydration never mounted — so `<Link>` degraded to its underlying anchor and every click was a full page load. See [write-the-root](../write-the-root/SKILL.md#root-is-the-hydration-boundary).
10
+ Every behaviour on this page depends on hydration having mounted. See [write-the-root](../write-the-root/SKILL.md#root-is-the-hydration-boundary).
11
11
 
12
12
  ## The shape
13
13
 
@@ -16,12 +16,7 @@ import { Link } from "@warlock.js/web";
16
16
 
17
17
  export function ProductLink({ id }: { id: string }) {
18
18
  return (
19
- <Link
20
- to="products.details"
21
- params={{ id }}
22
- query={{ tab: "specifications" }}
23
- prefetch
24
- >
19
+ <Link to="products.details" params={{ id }} query={{ tab: "specifications" }} prefetch>
25
20
  View product
26
21
  </Link>
27
22
  );
@@ -42,7 +37,9 @@ export function NavigationLinks() {
42
37
  <nav>
43
38
  <Link to="products.index">Products</Link>
44
39
  <Link href="/pricing">Pricing</Link>
45
- <Link href="https://example.com/docs" newTab>External docs</Link>
40
+ <Link href="https://example.com/docs" newTab>
41
+ External docs
42
+ </Link>
46
43
  <Link email="sales@example.com">Email sales</Link>
47
44
  <Link tel="+201000000000">Call sales</Link>
48
45
  </nav>
@@ -93,8 +90,12 @@ export function CheckoutButtons() {
93
90
 
94
91
  return (
95
92
  <div>
96
- <button type="button" onClick={navigateBack}>Back</button>
97
- <button type="button" onClick={openCheckout}>Checkout</button>
93
+ <button type="button" onClick={navigateBack}>
94
+ Back
95
+ </button>
96
+ <button type="button" onClick={openCheckout}>
97
+ Checkout
98
+ </button>
98
99
  </div>
99
100
  );
100
101
  }
@@ -126,7 +127,11 @@ export function DeleteProductButton({ id }: { id: string }) {
126
127
  }
127
128
  };
128
129
 
129
- return <button type="button" onClick={deleteProduct}>Delete product</button>;
130
+ return (
131
+ <button type="button" onClick={deleteProduct}>
132
+ Delete product
133
+ </button>
134
+ );
130
135
  }
131
136
  ```
132
137
 
@@ -52,6 +52,7 @@ Use a bare side-effect import in `root.tsx`. In development the server parses th
52
52
 
53
53
  Development has no client manifest, so Warlock reads `root.tsx`, finds bare stylesheet imports, resolves them relative to the root file, and emits a link before `</head>`:
54
54
 
55
+ <!-- prettier-ignore -->
55
56
  ```html
56
57
  <link rel="stylesheet" href="/src/web/app.css?direct">
57
58
  ```
@@ -90,7 +91,7 @@ export default function ProductsPage() {
90
91
 
91
92
  Recognized stylesheet imports survive the page's client projection. The client boundary is determined by the import graph, not by the file living under `web/`.
92
93
 
93
- That support is specific to stylesheets in the production page graph. An imported non-stylesheet asset such as `import logo from "./logo.svg"` works under Vite in development but is refused by `warlock build` in 5.2. Put it in the application's `public/` directory and reference its root URL instead: `public/logo.svg` is `/logo.svg`.
94
+ That support is specific to stylesheets in the production page graph. An imported non-stylesheet asset such as `import logo from "./logo.svg"` works under Vite in development but is refused by `warlock build`. Put it in the application's `public/` directory and reference its root URL instead: `public/logo.svg` is `/logo.svg`.
94
95
 
95
96
  For each matched handler, Warlock builds one ordered CSS chain: `[root, ...matched layouts, page]`. A stylesheet imported directly by any member of that chain becomes a render-blocking link in the initial document in both development and production. Unrelated pages and layouts do not contribute CSS to this response.
96
97
 
@@ -23,14 +23,11 @@ export const loader = (async () => {
23
23
  };
24
24
  }) satisfies LayoutLoader;
25
25
 
26
- export default function ProductsLayout({
27
- data,
28
- children,
29
- }: LayoutProps<typeof loader>) {
26
+ export default function ProductsLayout({ data, children }: LayoutProps<typeof loader>) {
30
27
  return (
31
28
  <section>
32
29
  <nav aria-label="Products">
33
- {data.navigation.map(item => (
30
+ {data.navigation.map((item) => (
34
31
  <a key={item.href} href={item.href}>
35
32
  {item.label}
36
33
  </a>
@@ -94,7 +91,7 @@ Non-rendering layouts may carry prefixes and middleware and may nest freely. Do
94
91
 
95
92
  ## `404.page.tsx` never gets a layout
96
93
 
97
- A `404.page.tsx` renders with no layouts, even when it sits in a directory with a rendering `layout.tsx` above it. Discovery reports an empty layout chain for the not-found page only, and both the client hydration registry and the production route table read from that same chain — the server has always rendered it with no layout wrapper, so the client no longer hydrates one either. A page that exists to handle failure must not depend on app chrome that can itself throw or need data.
94
+ A `404.page.tsx` renders with no layouts, even when it sits in a directory with a rendering `layout.tsx` above it. Discovery reports an empty layout chain for the not-found page only, and both the client hydration registry and the production route table read from that same chain — the server renders it with no layout wrapper, and the client hydrates it the same way, with no layout of its own. A page that exists to handle failure must not depend on app chrome that can itself throw or need data.
98
95
 
99
96
  This is scoped to the not-found page: an ordinary page in the same directory still gets its full layout chain, and nested-layout refusal on the 404's own path is still enforced exactly as it is for any other page.
100
97
 
@@ -46,8 +46,6 @@ The browser hydrates `#root`, not the whole document. The client tree deliberate
46
46
 
47
47
  Because App is outside the hydrated subtree, put client state that must survive navigation in a layout or component beneath `#root`, not in the document root.
48
48
 
49
- **Requires 5.1 in an installed app.** In 5.0.0–5.0.2 the dev server handed the browser `react-dom/client` as raw CommonJS, so `hydrateRoot` was not there as a named export and the hydration entry died on import — no client JavaScript ran at all. The symptoms were a page that server-rendered correctly but had dead `useState`, no HMR, and `<Link>` doing full page loads. The fix pre-bundles React through Vite's `optimizeDeps`. If you are debugging this against a checkout of the framework itself, note that the defect never reproduced there: inside the monorepo the hydration entry resolves outside `node_modules` and React was always optimized normally.
50
-
51
49
  If `root.tsx` (or any module it needs) fails to load or its `register()` throws, there is no trustworthy Layout+Page composition left to hydrate — Warlock falls back to a plain document with no hydration script at all rather than hydrate the browser against markup nothing can vouch for. See [create-a-page](../create-a-page/SKILL.md) for the app's own `error.page.tsx` boundary, which is tried first.
52
50
 
53
51
  ## `<Head />`