@warlock.js/web 5.0.2 → 5.1.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 (46) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/esm/build/contribution.d.mts +10 -0
  3. package/esm/build/contribution.mjs +36 -0
  4. package/esm/build/contribution.mjs.map +1 -1
  5. package/esm/build/discover-pages.mjs +226 -12
  6. package/esm/build/discover-pages.mjs.map +1 -1
  7. package/esm/build/generate-client-registry.mjs.map +1 -1
  8. package/esm/client/navigation/navigation-root.mjs +43 -6
  9. package/esm/client/navigation/navigation-root.mjs.map +1 -1
  10. package/esm/client/navigation/scroll-to-fragment.mjs +26 -0
  11. package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -0
  12. package/esm/metadata.d.mts +14 -0
  13. package/esm/metadata.mjs +45 -0
  14. package/esm/metadata.mjs.map +1 -0
  15. package/esm/routing/url-fragment.mjs +120 -0
  16. package/esm/routing/url-fragment.mjs.map +1 -0
  17. package/esm/server/create-page-route-handler.d.mts +27 -0
  18. package/esm/server/create-page-route-handler.mjs +12 -10
  19. package/esm/server/create-page-route-handler.mjs.map +1 -1
  20. package/esm/server/index.d.mts +2 -1
  21. package/esm/server/index.mjs +2 -1
  22. package/esm/server/install-page-routes-from-manifest.mjs +23 -1
  23. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
  24. package/esm/server/install-page-routes.d.mts +4 -2
  25. package/esm/server/install-page-routes.mjs +33 -5
  26. package/esm/server/install-page-routes.mjs.map +1 -1
  27. package/esm/server/install-production-page-routes.mjs +6 -1
  28. package/esm/server/install-production-page-routes.mjs.map +1 -1
  29. package/esm/server/not-found-page.d.mts +126 -0
  30. package/esm/server/not-found-page.mjs +157 -0
  31. package/esm/server/not-found-page.mjs.map +1 -0
  32. package/esm/server/web-connector-factory.mjs +2 -1
  33. package/esm/server/web-connector-factory.mjs.map +1 -1
  34. package/esm/server/web-connector.mjs +95 -0
  35. package/esm/server/web-connector.mjs.map +1 -1
  36. package/esm/vite/hydration-entries.mjs +8 -4
  37. package/esm/vite/hydration-entries.mjs.map +1 -1
  38. package/esm/vite/page-registry-plugin.mjs +211 -0
  39. package/esm/vite/page-registry-plugin.mjs.map +1 -1
  40. package/llms-full.txt +33 -5
  41. package/llms.txt +4 -2
  42. package/package.json +3 -3
  43. package/skills/create-a-page/SKILL.md +18 -2
  44. package/skills/navigate-on-the-client/SKILL.md +2 -0
  45. package/skills/use-layouts/SKILL.md +6 -0
  46. package/skills/write-the-root/SKILL.md +2 -0
package/llms-full.txt CHANGED
@@ -6,11 +6,13 @@
6
6
 
7
7
  > First published release: 5.0.0. `@warlock.js/web` had never been published to npm before this release.
8
8
 
9
+ > Use 5.1 or newer. In an installed 5.0.0–5.0.2 app no client JavaScript ran at all: the dev server served `react-dom/client` as raw CommonJS, so `hydrateRoot` was missing and hydration failed on import — dead `useState`, no HMR, `<Link>` falling back to full page loads. Pages still server-rendered, which is why it looked like a React problem.
10
+
9
11
  > Consumer entry points: `@warlock.js/web`, `@warlock.js/web/client/runtime`, `@warlock.js/web/connector`, and `@warlock.js/web/vite`. The published `@warlock.js/web/hydration` subpath is a framework build input; application code never imports it.
10
12
 
11
13
  > Deliberate non-goals: server actions are a v2 design decision rather than a missing v1 feature; page routes reject regex parameters, optional parameters, and multiple parameters in one segment.
12
14
 
13
- > Generated artifact. Concatenates every SKILL.md and reference file under `@warlock.js/web/skills/`. Re-run `node scripts/generate-llms.mjs` after any change.
15
+ > Hand-maintained. Concatenates every SKILL.md and reference file under `@warlock.js/web/skills/`. Update this file by hand when the package's public API changes.
14
16
 
15
17
  ## add-web-to-an-app `@warlock.js/web/add-web-to-an-app/SKILL.md`
16
18
 
@@ -122,7 +124,7 @@ The desired result is one page route at `/` and, when the stock JSON route exist
122
124
 
123
125
  ---
124
126
  name: create-a-page
125
- description: 'Create an SSR React page under `src/web/**` or `src/app/<module>/web/**` with a literal `route`, a default component, an optional typed `loader`, and page `metadata`. Triggers: `*.page.tsx`, `route`, `PageLoader`, `PageProps`, `PageMetadata`; "create a page", "add an SSR route", "make a React page", "type page loader data"; 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.'
127
+ description: 'Create an SSR React page under `src/web/**` or `src/app/<module>/web/**` with a literal `route`, a default component, an optional typed `loader`, and page `metadata`; know when a dev edit gets Fast Refresh vs. a full reload. Triggers: `*.page.tsx`, `route`, `PageLoader`, `PageProps`, `PageMetadata`, Fast Refresh, HMR; "create a page", "add an SSR route", "make a React page", "type page loader data", "why did my edit reload the page"; 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.'
126
128
  ---
127
129
 
128
130
  # Warlock — create a page
@@ -184,7 +186,9 @@ export default function ContactPage() {
184
186
  }
185
187
  ```
186
188
 
187
- The `route` export is required. Development skips a page without it; the production discovery pass refuses to build it.
189
+ The `route` export is required, and a page without one is **refused, not skipped**. `warlock dev` and the production discovery pass throw the same `MissingRouteExportError`, naming the file, because a page with no route is a page nothing can ever reach.
190
+
191
+ Changed in 5.1: through 5.0.2 the dev server silently skipped a route-less page, so the file you had just written 404'd with nothing said. Dev and build now reach the same verdict from the same condition.
188
192
 
189
193
  ## Route declarations
190
194
 
@@ -203,6 +207,8 @@ export const route = {
203
207
 
204
208
  Prefer an explicit stable `name` for links. Without one, Warlock derives a name from the module and declared path: a module page gets `<module>.<path-as-dots>`, a global root page gets `index`, and another global page gets its dotted path.
205
209
 
210
+ **Only the route NAME is ever derived — never the route PATH.** There is no filename-to-URL convention in Warlock and there never has been. Omitting `route` does not fall back to the file's location; it fails (see above). Every segment of a page's URL is written down somewhere: the page's own `route.path`, prefixed by the literal `prefix` exports of the positional layouts above it ([use-layouts](../use-layouts/SKILL.md)). Where the file sits decides which layouts are above it — never what the path spells.
211
+
206
212
  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.
207
213
 
208
214
  ## Page-route grammar
@@ -245,9 +251,21 @@ The browser boundary is decided by the import graph, not by the file's location.
245
251
 
246
252
  Keep server-only repository and service reads inside `loader`. Do not read them from module-scope initializers or the default component.
247
253
 
254
+ ## Editing a page in development
255
+
256
+ `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.
257
+
258
+ - **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.
259
+ - **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.
260
+ - **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.
261
+ - **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.
262
+ - Creating or deleting a page file is page-graph churn, not an in-place edit; Vite handles it on its own.
263
+
264
+ 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.
265
+
248
266
  ## Gotchas
249
267
 
250
- - **Do not derive URLs from filenames.** The file chooses discovery; `route` chooses the public URL.
268
+ - **Do not expect URLs to be derived from filenames.** No such convention exists. The file's location chooses discovery and which layout prefixes apply; `route` chooses the rest of the public URL. A page with no `route` is refused, not mounted at its path.
251
269
  - **Keep `route` literal.** A computed route cannot be discovered without executing app code and is refused.
252
270
  - **Do not annotate the loader with `: PageLoader`.** That erases the return type `PageProps` needs.
253
271
  - **Components receive data, not HTTP objects.** `request` and `response` belong to loaders; the component also renders in the browser.
@@ -459,6 +477,8 @@ description: 'Navigate hydrated pages with `<Link>`, resolve named URLs with `hr
459
477
 
460
478
  `<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.
461
479
 
480
+ 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).
481
+
462
482
  ## The shape
463
483
 
464
484
  ```tsx title="src/web/components/product-link.tsx"
@@ -785,7 +805,7 @@ The hydration entry is built from the projected client graph. CSS imports are kn
785
805
 
786
806
  ---
787
807
  name: use-layouts
788
- description: 'Wrap pages with positional `layout.tsx` modules, compose literal `prefix` exports, load typed layout data with `LayoutLoader` / `LayoutProps`, and preserve layout state during client navigation. Triggers: `layout.tsx`, `prefix`, `LayoutLoader`, `LayoutProps`, `children`; "add a page layout", "share navigation between pages", "prefix page routes", "keep a layout mounted". Skip: full-document root — `@warlock.js/web/write-the-root/SKILL.md`; page route export — `@warlock.js/web/create-a-page/SKILL.md`; loader and shared lifecycle — `@warlock.js/web/load-page-data/SKILL.md`; competing layout systems `next/layout`, React Router outlets, Remix nested routes.'
808
+ description: 'Wrap pages with positional `layout.tsx` modules, compose literal `prefix` exports, load typed layout data with `LayoutLoader` / `LayoutProps`, preserve layout state during client navigation, and know why `404.page.tsx` never receives a layout. Triggers: `layout.tsx`, `prefix`, `LayoutLoader`, `LayoutProps`, `children`, `404.page.tsx`; "add a page layout", "share navigation between pages", "prefix page routes", "keep a layout mounted", "404 page has no layout". Skip: full-document root — `@warlock.js/web/write-the-root/SKILL.md`; page route export — `@warlock.js/web/create-a-page/SKILL.md`; loader and shared lifecycle — `@warlock.js/web/load-page-data/SKILL.md`; competing layout systems `next/layout`, React Router outlets, Remix nested routes.'
789
809
  ---
790
810
 
791
811
  # Warlock — use layouts
@@ -877,6 +897,12 @@ The two prefix-only layouts above are legal because neither renders. Add a defau
877
897
 
878
898
  Non-rendering layouts may carry prefixes and middleware and may nest freely. Do not delete a middleware-only authorization boundary to satisfy the rendering limit; consolidate only the default-export wrappers.
879
899
 
900
+ ## `404.page.tsx` never gets a layout
901
+
902
+ 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.
903
+
904
+ 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.
905
+
880
906
  ## Why layout state persists
881
907
 
882
908
  Client navigation rebuilds the Layout + Page element tree at the same `#root` position. When the next page uses the same layout component type in the same position, React reconciles it instead of remounting it. Layout state such as open menus, scroll containers, and media survives.
@@ -955,6 +981,8 @@ The browser hydrates `#root`, not the whole document. The client tree deliberate
955
981
 
956
982
  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.
957
983
 
984
+ **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.
985
+
958
986
  ## `<Head />`
959
987
 
960
988
  `<Head />` renders the resolved page metadata at that position. It takes no props and emits:
package/llms.txt CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  > First published release: 5.0.0. `@warlock.js/web` had never been published to npm before this release.
8
8
 
9
+ > Use 5.1 or newer. In an installed 5.0.0–5.0.2 app no client JavaScript ran at all: the dev server served `react-dom/client` as raw CommonJS, so `hydrateRoot` was missing and hydration failed on import — dead `useState`, no HMR, `<Link>` falling back to full page loads. Pages still server-rendered, which is why it looked like a React problem.
10
+
9
11
  > Consumer entry points: `@warlock.js/web`, `@warlock.js/web/client/runtime`, `@warlock.js/web/connector`, and `@warlock.js/web/vite`. The published `@warlock.js/web/hydration` subpath is a framework build input; application code never imports it.
10
12
 
11
13
  > Deliberate non-goals: server actions are a v2 design decision rather than a missing v1 feature; page routes reject regex parameters, optional parameters, and multiple parameters in one segment.
@@ -13,9 +15,9 @@
13
15
  ## Skills
14
16
 
15
17
  - [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/home.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/home.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`.
16
- - [create-a-page](@warlock.js/web/create-a-page/SKILL.md): Create an SSR React page under `src/web/**` or `src/app/<module>/web/**` with a literal `route`, a default component, an optional typed `loader`, and page `metadata`. Triggers: `*.page.tsx`, `route`, `PageLoader`, `PageProps`, `PageMetadata`; "create a page", "add an SSR route", "make a React page", "type page loader data"; 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.
18
+ - [create-a-page](@warlock.js/web/create-a-page/SKILL.md): Create an SSR React page under `src/web/**` or `src/app/<module>/web/**` with a literal `route`, a default component, an optional typed `loader`, and page `metadata`; know when a dev edit gets Fast Refresh vs. a full reload. Triggers: `*.page.tsx`, `route`, `PageLoader`, `PageProps`, `PageMetadata`, Fast Refresh, HMR; "create a page", "add an SSR route", "make a React page", "type page loader data", "why did my edit reload the page"; 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.
17
19
  - [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`; "load page data", "pass server data to React", "share request data", "redirect 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.
18
20
  - [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.
19
21
  - [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.
20
- - [use-layouts](@warlock.js/web/use-layouts/SKILL.md): Wrap pages with positional `layout.tsx` modules, compose literal `prefix` exports, load typed layout data with `LayoutLoader` / `LayoutProps`, and preserve layout state during client navigation. Triggers: `layout.tsx`, `prefix`, `LayoutLoader`, `LayoutProps`, `children`; "add a page layout", "share navigation between pages", "prefix page routes", "keep a layout mounted". Skip: full-document root — `@warlock.js/web/write-the-root/SKILL.md`; page route export — `@warlock.js/web/create-a-page/SKILL.md`; loader and shared lifecycle — `@warlock.js/web/load-page-data/SKILL.md`; competing layout systems `next/layout`, React Router outlets, Remix nested routes.
22
+ - [use-layouts](@warlock.js/web/use-layouts/SKILL.md): Wrap pages with positional `layout.tsx` modules, compose literal `prefix` exports, load typed layout data with `LayoutLoader` / `LayoutProps`, preserve layout state during client navigation, and know why `404.page.tsx` never receives a layout. Triggers: `layout.tsx`, `prefix`, `LayoutLoader`, `LayoutProps`, `children`, `404.page.tsx`; "add a page layout", "share navigation between pages", "prefix page routes", "keep a layout mounted", "404 page has no layout". Skip: full-document root — `@warlock.js/web/write-the-root/SKILL.md`; page route export — `@warlock.js/web/create-a-page/SKILL.md`; loader and shared lifecycle — `@warlock.js/web/load-page-data/SKILL.md`; competing layout systems `next/layout`, React Router outlets, Remix nested routes.
21
23
  - [write-the-root](@warlock.js/web/write-the-root/SKILL.md): Author `src/web/root.tsx`, the full-document application root that owns `<html>`, `<head>`, and `<body>`, places page metadata with `<Head />`, renders the hydrated subtree inside `#root`, and emits the payload with `<Scripts />`. Triggers: `root.tsx`, `AppProps`, `AppLoader`, `Head`, `Scripts`, `id="root"`; "customize the root document", "add html lang", "add an app provider", "where do Head and Scripts go"; typical import `import { Head, Scripts, type AppProps } from "@warlock.js/web"`. Skip: page component contract — `@warlock.js/web/create-a-page/SKILL.md`; layout wrappers — `@warlock.js/web/use-layouts/SKILL.md`; CSS delivery — `@warlock.js/web/serve-styles/SKILL.md`; competing roots `next/layout`, Remix `root`, React `createRoot`.
package/package.json CHANGED
@@ -11,8 +11,8 @@
11
11
  },
12
12
  "peerDependencies": {
13
13
  "@vitejs/plugin-react": "^5.2.0",
14
- "@warlock.js/core": "5.0.2",
15
- "@warlock.js/seal": "5.0.2",
14
+ "@warlock.js/core": "5.1.0",
15
+ "@warlock.js/seal": "5.1.0",
16
16
  "react": "*",
17
17
  "react-dom": "*",
18
18
  "vite": ">=7.3.5 <8"
@@ -37,7 +37,7 @@
37
37
  ],
38
38
  "author": "hassanzohdy",
39
39
  "license": "MIT",
40
- "version": "5.0.2",
40
+ "version": "5.1.0",
41
41
  "type": "module",
42
42
  "main": "./esm/index.mjs",
43
43
  "module": "./esm/index.mjs",
@@ -62,7 +62,9 @@ export default function ContactPage() {
62
62
  }
63
63
  ```
64
64
 
65
- The `route` export is required. Development skips a page without it; the production discovery pass refuses to build it.
65
+ The `route` export is required, and a page without one is **refused, not skipped**. `warlock dev` and the production discovery pass throw the same `MissingRouteExportError`, naming the file, because a page with no route is a page nothing can ever reach.
66
+
67
+ Changed in 5.1: through 5.0.2 the dev server silently skipped a route-less page, so the file you had just written 404'd with nothing said. Dev and build now reach the same verdict from the same condition.
66
68
 
67
69
  ## Route declarations
68
70
 
@@ -81,6 +83,8 @@ export const route = {
81
83
 
82
84
  Prefer an explicit stable `name` for links. Without one, Warlock derives a name from the module and declared path: a module page gets `<module>.<path-as-dots>`, a global root page gets `index`, and another global page gets its dotted path.
83
85
 
86
+ **Only the route NAME is ever derived — never the route PATH.** There is no filename-to-URL convention in Warlock and there never has been. Omitting `route` does not fall back to the file's location; it fails (see above). Every segment of a page's URL is written down somewhere: the page's own `route.path`, prefixed by the literal `prefix` exports of the positional layouts above it ([use-layouts](../use-layouts/SKILL.md)). Where the file sits decides which layouts are above it — never what the path spells.
87
+
84
88
  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.
85
89
 
86
90
  ## Page-route grammar
@@ -123,9 +127,21 @@ The browser boundary is decided by the import graph, not by the file's location.
123
127
 
124
128
  Keep server-only repository and service reads inside `loader`. Do not read them from module-scope initializers or the default component.
125
129
 
130
+ ## Editing a page in development
131
+
132
+ `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.
133
+
134
+ - **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.
135
+ - **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.
136
+ - **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.
137
+ - **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.
138
+ - Creating or deleting a page file is page-graph churn, not an in-place edit; Vite handles it on its own.
139
+
140
+ 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.
141
+
126
142
  ## Gotchas
127
143
 
128
- - **Do not derive URLs from filenames.** The file chooses discovery; `route` chooses the public URL.
144
+ - **Do not expect URLs to be derived from filenames.** No such convention exists. The file's location chooses discovery and which layout prefixes apply; `route` chooses the rest of the public URL. A page with no `route` is refused, not mounted at its path.
129
145
  - **Keep `route` literal.** A computed route cannot be discovered without executing app code and is refused.
130
146
  - **Do not annotate the loader with `: PageLoader`.** That erases the return type `PageProps` needs.
131
147
  - **Components receive data, not HTTP objects.** `request` and `response` belong to loaders; the component also renders in the browser.
@@ -7,6 +7,8 @@ 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).
11
+
10
12
  ## The shape
11
13
 
12
14
  ```tsx title="src/web/components/product-link.tsx"
@@ -92,6 +92,12 @@ The two prefix-only layouts above are legal because neither renders. Add a defau
92
92
 
93
93
  Non-rendering layouts may carry prefixes and middleware and may nest freely. Do not delete a middleware-only authorization boundary to satisfy the rendering limit; consolidate only the default-export wrappers.
94
94
 
95
+ ## `404.page.tsx` never gets a layout
96
+
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.
98
+
99
+ 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
+
95
101
  ## Why layout state persists
96
102
 
97
103
  Client navigation rebuilds the Layout + Page element tree at the same `#root` position. When the next page uses the same layout component type in the same position, React reconciles it instead of remounting it. Layout state such as open menus, scroll containers, and media survives.
@@ -46,6 +46,8 @@ 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
+
49
51
  ## `<Head />`
50
52
 
51
53
  `<Head />` renders the resolved page metadata at that position. It takes no props and emits: