@warlock.js/web 5.0.1 → 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.
- package/CHANGELOG.md +76 -0
- package/esm/build/contribution.d.mts +10 -0
- package/esm/build/contribution.mjs +36 -0
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +226 -12
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-client-registry.mjs.map +1 -1
- package/esm/client/navigation/navigation-root.mjs +43 -6
- package/esm/client/navigation/navigation-root.mjs.map +1 -1
- package/esm/client/navigation/scroll-to-fragment.mjs +26 -0
- package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -0
- package/esm/metadata.d.mts +14 -0
- package/esm/metadata.mjs +45 -0
- package/esm/metadata.mjs.map +1 -0
- package/esm/routing/url-fragment.mjs +120 -0
- package/esm/routing/url-fragment.mjs.map +1 -0
- package/esm/server/create-page-route-handler.d.mts +27 -0
- package/esm/server/create-page-route-handler.mjs +12 -10
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/index.d.mts +2 -1
- package/esm/server/index.mjs +2 -1
- package/esm/server/install-page-routes-from-manifest.mjs +23 -1
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +4 -2
- package/esm/server/install-page-routes.mjs +33 -5
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/install-production-page-routes.mjs +6 -1
- package/esm/server/install-production-page-routes.mjs.map +1 -1
- package/esm/server/not-found-page.d.mts +126 -0
- package/esm/server/not-found-page.mjs +157 -0
- package/esm/server/not-found-page.mjs.map +1 -0
- package/esm/server/web-connector-factory.mjs +2 -1
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.mjs +123 -5
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/vite/hydration-entries.mjs +8 -4
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.mjs +211 -0
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/llms-full.txt +33 -5
- package/llms.txt +4 -2
- package/package.json +3 -3
- package/skills/create-a-page/SKILL.md +18 -2
- package/skills/navigate-on-the-client/SKILL.md +2 -0
- package/skills/use-layouts/SKILL.md +6 -0
- package/skills/write-the-root/SKILL.md +2 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { discoverPages, toPosix } from "../build/discover-pages.mjs";
|
|
2
|
+
import { SERVER_EXPORT_NAMES } from "./projection.mjs";
|
|
2
3
|
import { generateClientRegistry } from "../build/generate-client-registry.mjs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { parse } from "@babel/parser";
|
|
@@ -95,6 +96,173 @@ function eraseTypes(source) {
|
|
|
95
96
|
return magic.toString();
|
|
96
97
|
}
|
|
97
98
|
/**
|
|
99
|
+
* The four file shapes that carry SERVER data (`metadata` chief among them)
|
|
100
|
+
* and are therefore projected before the client graph forms — the exact set
|
|
101
|
+
* `projection.ts`'s `isProjectableFile` matches, spelled here by BASENAME so
|
|
102
|
+
* the two agree by construction on what "a server-side page module" is. A
|
|
103
|
+
* change to one of these is the only kind of change whose SERVER half
|
|
104
|
+
* (`metadata`, `loader`, …) can move without the client half moving at all.
|
|
105
|
+
*/
|
|
106
|
+
function isServerPageModule(file) {
|
|
107
|
+
const base = path.basename(file.split("?")[0]);
|
|
108
|
+
if (/\.page\.tsx?$/.test(base)) return true;
|
|
109
|
+
if (base === "layout.tsx" || base === "layout.ts") return true;
|
|
110
|
+
if (/\.layout\.tsx?$/.test(base)) return true;
|
|
111
|
+
if (base === "root.tsx") return true;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* What replaces a component body in the skeleton. Its content is irrelevant —
|
|
116
|
+
* only that it is CONSTANT, so two sources that differ solely inside a masked
|
|
117
|
+
* body serialise identically.
|
|
118
|
+
*/
|
|
119
|
+
const MASKED_COMPONENT_BODY = "/*warlock:component-body*/";
|
|
120
|
+
/** React's own convention, and the one `react-refresh` itself uses: components are PascalCase. */
|
|
121
|
+
function isComponentName(name) {
|
|
122
|
+
return typeof name === "string" && /^[A-Z]/.test(name);
|
|
123
|
+
}
|
|
124
|
+
/** The `body` node of a function-shaped expression/declaration, or `undefined` for anything else. */
|
|
125
|
+
function functionBody(node) {
|
|
126
|
+
if (!node) return void 0;
|
|
127
|
+
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") return node.body;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Generic duck-typed identifier walk, the same shape `projection.ts`'s
|
|
131
|
+
* `collectIdentifierNames` uses (it is not exported, and re-deriving one
|
|
132
|
+
* OVER-collecting walk is safe here for the same reason it is safe there).
|
|
133
|
+
*
|
|
134
|
+
* Over-collecting — counting an object property key or a shadowing parameter
|
|
135
|
+
* as a "read" — can only make the reachable set BIGGER, which can only UNMASK
|
|
136
|
+
* more component bodies, which can only produce more reloads. The safe
|
|
137
|
+
* direction.
|
|
138
|
+
*/
|
|
139
|
+
function collectIdentifierNames(node, names) {
|
|
140
|
+
if (!node || typeof node !== "object") return;
|
|
141
|
+
if (Array.isArray(node)) {
|
|
142
|
+
for (const item of node) collectIdentifierNames(item, names);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const record = node;
|
|
146
|
+
if (typeof record.type !== "string") return;
|
|
147
|
+
if (record.type === "Identifier" || record.type === "JSXIdentifier") names.add(record.name);
|
|
148
|
+
for (const key of Object.keys(record)) {
|
|
149
|
+
if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") continue;
|
|
150
|
+
if (key === "leadingComments" || key === "trailingComments" || key === "innerComments" || key === "extra") continue;
|
|
151
|
+
collectIdentifierNames(record[key], names);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** The module-scope names a top-level statement binds (the `export` wrapper looked through). */
|
|
155
|
+
function topLevelBoundNames(stmt) {
|
|
156
|
+
const names = /* @__PURE__ */ new Set();
|
|
157
|
+
const declaration = stmt.type === "ExportNamedDeclaration" ? stmt.declaration : stmt;
|
|
158
|
+
if (!declaration) return names;
|
|
159
|
+
if (declaration.type === "VariableDeclaration") {
|
|
160
|
+
for (const declarator of declaration.declarations) if (declarator.id?.type === "Identifier") names.add(declarator.id.name);
|
|
161
|
+
} else if (declaration.id?.type === "Identifier") names.add(declaration.id.name);
|
|
162
|
+
return names;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Every module-scope name reachable from one of the five server exports.
|
|
166
|
+
*
|
|
167
|
+
* Used ONLY to UNMASK: a PascalCase function that `metadata` or `loader` can
|
|
168
|
+
* reach is not a component for this purpose, it is a server-side helper that
|
|
169
|
+
* merely looks like one, and a change inside its body must reload. Seeded from
|
|
170
|
+
* any top-level statement binding a `SERVER_EXPORT_NAMES` name — deliberately
|
|
171
|
+
* looser than `projection.ts`'s own `isServerExportDeclaration` (no export
|
|
172
|
+
* requirement, no single-declarator requirement), because seeding from MORE
|
|
173
|
+
* statements can only unmask more, i.e. reload more.
|
|
174
|
+
*
|
|
175
|
+
* Fixpoint, not one pass, for the same reason projection's is: a server-only
|
|
176
|
+
* helper can be reached only through another server-only helper.
|
|
177
|
+
*/
|
|
178
|
+
function serverReachableNames(body) {
|
|
179
|
+
const reached = /* @__PURE__ */ new Set();
|
|
180
|
+
const declarations = body.filter((stmt) => stmt.type !== "ImportDeclaration").map((stmt) => ({
|
|
181
|
+
stmt,
|
|
182
|
+
names: topLevelBoundNames(stmt)
|
|
183
|
+
}));
|
|
184
|
+
for (const { stmt, names } of declarations) {
|
|
185
|
+
let isServerExport = false;
|
|
186
|
+
for (const name of names) if (SERVER_EXPORT_NAMES.has(name)) isServerExport = true;
|
|
187
|
+
if (isServerExport) collectIdentifierNames(stmt, reached);
|
|
188
|
+
}
|
|
189
|
+
for (let changed = true; changed;) {
|
|
190
|
+
changed = false;
|
|
191
|
+
for (const { stmt, names } of declarations) {
|
|
192
|
+
let isReached = false;
|
|
193
|
+
for (const name of names) if (reached.has(name)) isReached = true;
|
|
194
|
+
if (!isReached) continue;
|
|
195
|
+
const before = reached.size;
|
|
196
|
+
collectIdentifierNames(stmt, reached);
|
|
197
|
+
if (reached.size !== before) changed = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return reached;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The body node to mask for a top-level statement, or `undefined` if this
|
|
204
|
+
* statement is not a component declaration.
|
|
205
|
+
*
|
|
206
|
+
* Recognised shapes, and only these:
|
|
207
|
+
* - `export default function () {…}` / `export default () => …` — the page
|
|
208
|
+
* component, whatever it is called.
|
|
209
|
+
* - `function Name() {…}` / `const Name = () => …` (PascalCase, optionally
|
|
210
|
+
* `export`ed) — a component declared alongside it.
|
|
211
|
+
*
|
|
212
|
+
* Everything else — `memo(...)`/`forwardRef(...)` wrappers, classes,
|
|
213
|
+
* lowercase helpers, every server export — is left UNMASKED and therefore
|
|
214
|
+
* compared byte-for-byte. That costs Fast Refresh on those shapes and buys the
|
|
215
|
+
* guarantee; see this seam's header.
|
|
216
|
+
*/
|
|
217
|
+
function componentBodyToMask(stmt, serverReachable) {
|
|
218
|
+
if (stmt.type === "ExportDefaultDeclaration") return functionBody(stmt.declaration);
|
|
219
|
+
const declaration = stmt.type === "ExportNamedDeclaration" ? stmt.declaration : stmt;
|
|
220
|
+
if (!declaration) return void 0;
|
|
221
|
+
const named = (name, node) => isComponentName(name) && !serverReachable.has(name) ? functionBody(node) : void 0;
|
|
222
|
+
if (declaration.type === "FunctionDeclaration") return named(declaration.id?.name, declaration);
|
|
223
|
+
if (declaration.type === "VariableDeclaration" && declaration.declarations.length === 1) {
|
|
224
|
+
const declarator = declaration.declarations[0];
|
|
225
|
+
if (declarator.id?.type !== "Identifier") return void 0;
|
|
226
|
+
return named(declarator.id.name, declarator.init);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The module source with every component body replaced by a constant — the
|
|
231
|
+
* ONE value the reload decision compares across an edit.
|
|
232
|
+
*
|
|
233
|
+
* Everything outside a component body survives verbatim: imports, module-level
|
|
234
|
+
* declarations, all five server exports, and the comments and whitespace
|
|
235
|
+
* between them. So the skeleton is unchanged iff the save touched nothing but
|
|
236
|
+
* component bodies, which is exactly the ruling.
|
|
237
|
+
*
|
|
238
|
+
* Returns `undefined` when the source does not parse — a half-typed file whose
|
|
239
|
+
* error Vite is already reporting from projection's real `transform`. The
|
|
240
|
+
* caller leaves the cache holding the last GOOD skeleton, so the next
|
|
241
|
+
* successful save is still compared against the right baseline.
|
|
242
|
+
*/
|
|
243
|
+
function captureSkeleton(code) {
|
|
244
|
+
let ast;
|
|
245
|
+
try {
|
|
246
|
+
ast = parse(code, {
|
|
247
|
+
sourceType: "module",
|
|
248
|
+
plugins: ["typescript", "jsx"]
|
|
249
|
+
});
|
|
250
|
+
} catch {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const body = ast.program.body;
|
|
254
|
+
const serverReachable = serverReachableNames(body);
|
|
255
|
+
const magic = new MagicString(code);
|
|
256
|
+
for (const stmt of body) {
|
|
257
|
+
const bodyNode = componentBodyToMask(stmt, serverReachable);
|
|
258
|
+
if (!bodyNode) continue;
|
|
259
|
+
const start = bodyNode.start;
|
|
260
|
+
const end = bodyNode.end;
|
|
261
|
+
if (end > start) magic.overwrite(start, end, MASKED_COMPONENT_BODY);
|
|
262
|
+
}
|
|
263
|
+
return magic.toString();
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
98
266
|
* Serves the client page registry at {@link CLIENT_PAGE_REGISTRY_ID}.
|
|
99
267
|
*
|
|
100
268
|
* Discovery runs INSIDE `load`, once per `load` call, and its result is NOT
|
|
@@ -114,6 +282,7 @@ function eraseTypes(source) {
|
|
|
114
282
|
*/
|
|
115
283
|
function clientPageRegistry(options = {}) {
|
|
116
284
|
const appRoot = path.resolve(options.appRoot ?? process.cwd());
|
|
285
|
+
const skeletonCache = /* @__PURE__ */ new Map();
|
|
117
286
|
return {
|
|
118
287
|
name: "warlock:client-page-registry",
|
|
119
288
|
enforce: "pre",
|
|
@@ -129,6 +298,48 @@ function clientPageRegistry(options = {}) {
|
|
|
129
298
|
}),
|
|
130
299
|
toImportSpecifier
|
|
131
300
|
}));
|
|
301
|
+
},
|
|
302
|
+
/**
|
|
303
|
+
* Capture-only spy. Records the SKELETON of every server page module the
|
|
304
|
+
* CLIENT environment transforms, and returns nothing so projection's own
|
|
305
|
+
* `transform` still does the real work. SERVE-ONLY:
|
|
306
|
+
* `this.environment.mode !== "dev"` skips it during `vite build`, where
|
|
307
|
+
* there is no `hotUpdate` to feed and the extra parse would be pure cost.
|
|
308
|
+
*/
|
|
309
|
+
transform(code, id) {
|
|
310
|
+
if (this.environment?.mode !== "dev") return void 0;
|
|
311
|
+
if (!isServerPageModule(id)) return void 0;
|
|
312
|
+
const skeleton = captureSkeleton(code);
|
|
313
|
+
if (skeleton !== void 0) skeletonCache.set(id, skeleton);
|
|
314
|
+
},
|
|
315
|
+
/**
|
|
316
|
+
* Applies the ruling (canon `6b240682`): Fast Refresh ONLY when the only
|
|
317
|
+
* changes are inside component bodies.
|
|
318
|
+
*
|
|
319
|
+
* - Skeleton moved (an import, a module-level declaration, ANY server
|
|
320
|
+
* export — with or without a simultaneous JSX change) → full reload.
|
|
321
|
+
* - Skeleton unchanged → defer to Fast Refresh, zero reloads.
|
|
322
|
+
*
|
|
323
|
+
* Note what is NOT here: no attempt to name which half a shared import or
|
|
324
|
+
* local belongs to. That question is what produced the two previous stale
|
|
325
|
+
* `<head>` bugs; this seam refuses to answer it and reloads instead.
|
|
326
|
+
* `hotUpdate` exists only on the dev server, so this is serve-only by
|
|
327
|
+
* construction.
|
|
328
|
+
*/
|
|
329
|
+
async hotUpdate(context) {
|
|
330
|
+
if (context.type !== "update") return void 0;
|
|
331
|
+
if (!isServerPageModule(context.file)) return void 0;
|
|
332
|
+
const next = captureSkeleton(await context.read());
|
|
333
|
+
const prev = skeletonCache.get(context.file);
|
|
334
|
+
if (next !== void 0) skeletonCache.set(context.file, next);
|
|
335
|
+
if (next === void 0 || prev === void 0) return void 0;
|
|
336
|
+
if (prev !== next) {
|
|
337
|
+
this.environment.hot.send({
|
|
338
|
+
type: "full-reload",
|
|
339
|
+
path: "*"
|
|
340
|
+
});
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
132
343
|
}
|
|
133
344
|
};
|
|
134
345
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"page-registry-plugin.mjs","names":[],"sources":["../../../../../../../web/src/vite/page-registry-plugin.ts"],"sourcesContent":["/**\n * The wire between the two halves that already existed and never met:\n * `discoverPages` (the page graph, read off disk) and `generateClientRegistry`\n * (the module SOURCE that carries that graph into the browser). Neither one\n * touches Vite; this plugin is the only place they are joined, and it joins\n * them as a VIRTUAL module so nothing is ever written to the user's tree.\n *\n * Identical in dev and build — no `apply`/`command` gating, matching the rest\n * of `warlockClientBoundary`'s composition (`index.ts`), which is also\n * mode-agnostic. A registry that differed between `vite dev` and `vite build`\n * would make every dev-only or prod-only page bug unreproducible in the other\n * mode.\n */\nimport { parse } from \"@babel/parser\";\nimport MagicString from \"magic-string\";\nimport path from \"node:path\";\nimport type { Plugin } from \"vite\";\nimport { discoverPages, toPosix } from \"../build/discover-pages\";\nimport { generateClientRegistry } from \"../build/generate-client-registry\";\n\n/**\n * The specifier application code writes.\n *\n * Exported so the client runtime imports this constant instead of retyping the\n * string: a constant two sides must agree on is a guard, and a guard duplicated\n * at a second site fails open at the third — a typo'd re-spelling doesn't fail\n * loudly, it resolves to \"no such module\" or, worse, to a stale real file.\n */\nexport const CLIENT_PAGE_REGISTRY_ID = \"virtual:warlock/pages\";\n\n/**\n * The resolved id, `\\0`-prefixed per Vite/Rollup convention so no other plugin\n * (and no filesystem watcher) mistakes it for a real path.\n */\nexport const RESOLVED_CLIENT_PAGE_REGISTRY_ID = `\\0${CLIENT_PAGE_REGISTRY_ID}`;\n\nexport type ClientPageRegistryPluginOptions = {\n /** Absolute path to the application root. Defaults to `process.cwd()`, matching Vite's own default `root` and Gate A's `appRoot` default. */\n appRoot?: string;\n /** Source directory name under `appRoot`; forwarded verbatim to `discoverPages`, which defaults it to `\"src\"`. */\n srcDir?: string;\n};\n\n/**\n * The import specifiers the emitted registry names must be ABSOLUTE POSIX file\n * paths, never relative ones.\n *\n * A relative specifier resolves against its IMPORTER, and the importer here is\n * `\\0virtual:warlock/pages` — a synthetic id whose `dirname` is not a real\n * directory. `./blog.page.tsx` from that importer resolves to nonsense that\n * fails at bundle time with a path no user authored and no user can act on.\n *\n * Separator normalization is `hydration-entries.ts`'s\n * (`hydration-entries.ts:12-14`) and `discover-pages.ts`'s single\n * `.replace(/\\\\/g, \"/\")` rule, reused via the already-exported `toPosix` rather\n * than spelled a third time — keeping the drive colon (`D:/...`) is exactly\n * what Vite's resolver wants on Windows.\n */\nfunction toImportSpecifier(absoluteFilePath: string): string {\n return toPosix(path.resolve(absoluteFilePath));\n}\n\n/**\n * Erases the generated module's TypeScript down to plain JavaScript.\n *\n * NOT optional, and not a style choice. Vite's `vite:esbuild` transform is\n * gated behind `createFilter`, which refuses ANY id containing a NUL byte\n * (`node_modules/vite/dist/node/chunks/config.js:1512` — `if\n * (id.includes(\"\\0\")) return false`). So the one module in this build that is\n * `\\0`-prefixed by convention is precisely the one module esbuild will never\n * transform, while `generateClientRegistry` always emits TypeScript (a\n * type-only `ClientPageEntry` import plus the array's type annotation). Handed\n * to Rollup verbatim, `import type { ClientPageEntry } from ...` is a\n * JavaScript syntax error.\n *\n * Done with the AST rather than a regex, using the same `@babel/parser` +\n * `MagicString` pair `projection.ts` already uses in this directory — a regex\n * over generated source is a second grammar that drifts from the generator's\n * silently. If the generator ever emits a TS construct outside these two\n * shapes, the result is a Rollup parse error naming the virtual module: loud,\n * not silent. `page-registry-plugin.spec.ts` pins that the erased output\n * re-parses as plain JavaScript with the TypeScript plugin switched OFF.\n */\nfunction eraseTypes(source: string): string {\n const ast = parse(source, { sourceType: \"module\", plugins: [\"typescript\"] });\n const magic = new MagicString(source);\n\n for (const statement of ast.program.body as any[]) {\n if (statement.type === \"ImportDeclaration\" && statement.importKind === \"type\") {\n let end = statement.end as number;\n if (source[end] === \"\\r\" && source[end + 1] === \"\\n\") end += 2;\n else if (source[end] === \"\\n\") end += 1;\n magic.remove(statement.start as number, end);\n continue;\n }\n\n const declaration =\n statement.type === \"ExportNamedDeclaration\" ? statement.declaration : statement;\n\n if (declaration?.type !== \"VariableDeclaration\") continue;\n\n for (const declarator of declaration.declarations as any[]) {\n const annotation = declarator.id?.typeAnnotation;\n if (annotation) magic.remove(annotation.start as number, annotation.end as number);\n }\n }\n\n return magic.toString();\n}\n\n/**\n * Serves the client page registry at {@link CLIENT_PAGE_REGISTRY_ID}.\n *\n * Discovery runs INSIDE `load`, once per `load` call, and its result is NOT\n * cached across builds — the plugin holds no state at all. A registry cached\n * past the moment a page file appears is a page that silently 404s until\n * someone restarts the dev server, which is a far more expensive bug than\n * re-walking a source tree. Rollup calls `load` once per module per build, and\n * in dev Vite's module graph caches the transformed result until the module is\n * invalidated, so the walk is not per-request either way. (Invalidating that\n * dev-server cache when a page file is ADDED needs a `handleHotUpdate`/watcher\n * hook that belongs with the dev provider slice — see the followup.)\n *\n * `enforce: \"pre\"` and placed FIRST in `warlockClientBoundary`'s array — see\n * that function's comment in `index.ts` for why position is what it is, and\n * `page-registry-plugin.spec.ts` for the real-build proof that the pages this\n * module names still reach `projection()`.\n */\nexport function clientPageRegistry(options: ClientPageRegistryPluginOptions = {}): Plugin {\n const appRoot = path.resolve(options.appRoot ?? process.cwd());\n\n return {\n name: \"warlock:client-page-registry\",\n enforce: \"pre\",\n resolveId(source) {\n if (source === CLIENT_PAGE_REGISTRY_ID) return RESOLVED_CLIENT_PAGE_REGISTRY_ID;\n return undefined;\n },\n load(id) {\n if (id !== RESOLVED_CLIENT_PAGE_REGISTRY_ID) return undefined;\n\n const pages = discoverPages({ appRoot, srcDir: options.srcDir });\n\n return eraseTypes(generateClientRegistry({ pages, toImportSpecifier }));\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,MAAa,0BAA0B;;;;;AAMvC,MAAa,mCAAmC,KAAK;;;;;;;;;;;;;;;;AAwBrD,SAAS,kBAAkB,kBAAkC;CAC3D,OAAO,QAAQ,KAAK,QAAQ,gBAAgB,CAAC;AAC/C;;;;;;;;;;;;;;;;;;;;;;AAuBA,SAAS,WAAW,QAAwB;CAC1C,MAAM,MAAM,MAAM,QAAQ;EAAE,YAAY;EAAU,SAAS,CAAC,YAAY;CAAE,CAAC;CAC3E,MAAM,QAAQ,IAAI,YAAY,MAAM;CAEpC,KAAK,MAAM,aAAa,IAAI,QAAQ,MAAe;EACjD,IAAI,UAAU,SAAS,uBAAuB,UAAU,eAAe,QAAQ;GAC7E,IAAI,MAAM,UAAU;GACpB,IAAI,OAAO,SAAS,QAAQ,OAAO,MAAM,OAAO,MAAM,OAAO;QACxD,IAAI,OAAO,SAAS,MAAM,OAAO;GACtC,MAAM,OAAO,UAAU,OAAiB,GAAG;GAC3C;EACF;EAEA,MAAM,cACJ,UAAU,SAAS,2BAA2B,UAAU,cAAc;EAExE,IAAI,aAAa,SAAS,uBAAuB;EAEjD,KAAK,MAAM,cAAc,YAAY,cAAuB;GAC1D,MAAM,aAAa,WAAW,IAAI;GAClC,IAAI,YAAY,MAAM,OAAO,WAAW,OAAiB,WAAW,GAAa;EACnF;CACF;CAEA,OAAO,MAAM,SAAS;AACxB;;;;;;;;;;;;;;;;;;;AAoBA,SAAgB,mBAAmB,UAA2C,CAAC,GAAW;CACxF,MAAM,UAAU,KAAK,QAAQ,QAAQ,WAAW,QAAQ,IAAI,CAAC;CAE7D,OAAO;EACL,MAAM;EACN,SAAS;EACT,UAAU,QAAQ;GAChB,IAAI,oCAAoC,OAAO;EAEjD;EACA,KAAK,IAAI;GACP,IAAI,OAAO,kCAAkC,OAAO;GAIpD,OAAO,WAAW,uBAAuB;IAAE,OAF7B,cAAc;KAAE;KAAS,QAAQ,QAAQ;IAAO,CAEf;IAAG;GAAkB,CAAC,CAAC;EACxE;CACF;AACF"}
|
|
1
|
+
{"version":3,"file":"page-registry-plugin.mjs","names":[],"sources":["../../../../../../../web/src/vite/page-registry-plugin.ts"],"sourcesContent":["/**\n * The wire between the two halves that already existed and never met:\n * `discoverPages` (the page graph, read off disk) and `generateClientRegistry`\n * (the module SOURCE that carries that graph into the browser). Neither one\n * touches Vite; this plugin is the only place they are joined, and it joins\n * them as a VIRTUAL module so nothing is ever written to the user's tree.\n *\n * Identical in dev and build — no `apply`/`command` gating, matching the rest\n * of `warlockClientBoundary`'s composition (`index.ts`), which is also\n * mode-agnostic. A registry that differed between `vite dev` and `vite build`\n * would make every dev-only or prod-only page bug unreproducible in the other\n * mode.\n */\nimport { parse } from \"@babel/parser\";\nimport MagicString from \"magic-string\";\nimport path from \"node:path\";\nimport type { Plugin } from \"vite\";\nimport { discoverPages, toPosix } from \"../build/discover-pages\";\nimport { generateClientRegistry } from \"../build/generate-client-registry\";\nimport { SERVER_EXPORT_NAMES } from \"./projection\";\n\n/**\n * The specifier application code writes.\n *\n * Exported so the client runtime imports this constant instead of retyping the\n * string: a constant two sides must agree on is a guard, and a guard duplicated\n * at a second site fails open at the third — a typo'd re-spelling doesn't fail\n * loudly, it resolves to \"no such module\" or, worse, to a stale real file.\n */\nexport const CLIENT_PAGE_REGISTRY_ID = \"virtual:warlock/pages\";\n\n/**\n * The resolved id, `\\0`-prefixed per Vite/Rollup convention so no other plugin\n * (and no filesystem watcher) mistakes it for a real path.\n */\nexport const RESOLVED_CLIENT_PAGE_REGISTRY_ID = `\\0${CLIENT_PAGE_REGISTRY_ID}`;\n\nexport type ClientPageRegistryPluginOptions = {\n /** Absolute path to the application root. Defaults to `process.cwd()`, matching Vite's own default `root` and Gate A's `appRoot` default. */\n appRoot?: string;\n /** Source directory name under `appRoot`; forwarded verbatim to `discoverPages`, which defaults it to `\"src\"`. */\n srcDir?: string;\n};\n\n/**\n * The import specifiers the emitted registry names must be ABSOLUTE POSIX file\n * paths, never relative ones.\n *\n * A relative specifier resolves against its IMPORTER, and the importer here is\n * `\\0virtual:warlock/pages` — a synthetic id whose `dirname` is not a real\n * directory. `./blog.page.tsx` from that importer resolves to nonsense that\n * fails at bundle time with a path no user authored and no user can act on.\n *\n * Separator normalization is `hydration-entries.ts`'s\n * (`hydration-entries.ts:12-14`) and `discover-pages.ts`'s single\n * `.replace(/\\\\/g, \"/\")` rule, reused via the already-exported `toPosix` rather\n * than spelled a third time — keeping the drive colon (`D:/...`) is exactly\n * what Vite's resolver wants on Windows.\n */\nfunction toImportSpecifier(absoluteFilePath: string): string {\n return toPosix(path.resolve(absoluteFilePath));\n}\n\n/**\n * Erases the generated module's TypeScript down to plain JavaScript.\n *\n * NOT optional, and not a style choice. Vite's `vite:esbuild` transform is\n * gated behind `createFilter`, which refuses ANY id containing a NUL byte\n * (`node_modules/vite/dist/node/chunks/config.js:1512` — `if\n * (id.includes(\"\\0\")) return false`). So the one module in this build that is\n * `\\0`-prefixed by convention is precisely the one module esbuild will never\n * transform, while `generateClientRegistry` always emits TypeScript (a\n * type-only `ClientPageEntry` import plus the array's type annotation). Handed\n * to Rollup verbatim, `import type { ClientPageEntry } from ...` is a\n * JavaScript syntax error.\n *\n * Done with the AST rather than a regex, using the same `@babel/parser` +\n * `MagicString` pair `projection.ts` already uses in this directory — a regex\n * over generated source is a second grammar that drifts from the generator's\n * silently. If the generator ever emits a TS construct outside these two\n * shapes, the result is a Rollup parse error naming the virtual module: loud,\n * not silent. `page-registry-plugin.spec.ts` pins that the erased output\n * re-parses as plain JavaScript with the TypeScript plugin switched OFF.\n */\nfunction eraseTypes(source: string): string {\n const ast = parse(source, { sourceType: \"module\", plugins: [\"typescript\"] });\n const magic = new MagicString(source);\n\n for (const statement of ast.program.body as any[]) {\n if (statement.type === \"ImportDeclaration\" && statement.importKind === \"type\") {\n let end = statement.end as number;\n if (source[end] === \"\\r\" && source[end + 1] === \"\\n\") end += 2;\n else if (source[end] === \"\\n\") end += 1;\n magic.remove(statement.start as number, end);\n continue;\n }\n\n const declaration =\n statement.type === \"ExportNamedDeclaration\" ? statement.declaration : statement;\n\n if (declaration?.type !== \"VariableDeclaration\") continue;\n\n for (const declarator of declaration.declarations as any[]) {\n const annotation = declarator.id?.typeAnnotation;\n if (annotation) magic.remove(annotation.start as number, annotation.end as number);\n }\n }\n\n return magic.toString();\n}\n\n/**\n * The four file shapes that carry SERVER data (`metadata` chief among them)\n * and are therefore projected before the client graph forms — the exact set\n * `projection.ts`'s `isProjectableFile` matches, spelled here by BASENAME so\n * the two agree by construction on what \"a server-side page module\" is. A\n * change to one of these is the only kind of change whose SERVER half\n * (`metadata`, `loader`, …) can move without the client half moving at all.\n */\nfunction isServerPageModule(file: string): boolean {\n const base = path.basename(file.split(\"?\")[0]);\n if (/\\.page\\.tsx?$/.test(base)) return true;\n if (base === \"layout.tsx\" || base === \"layout.ts\") return true;\n if (/\\.layout\\.tsx?$/.test(base)) return true;\n if (base === \"root.tsx\") return true;\n return false;\n}\n\n/**\n * The server-vs-client reload seam.\n *\n * A page module carries TWO halves. The CLIENT half is the projected code the\n * browser actually runs; Fast Refresh can hot-swap it with zero reloads. The\n * SERVER half — `metadata`, `loader`, `route`, `middleware`, `validation`, plus\n * the imports/locals orphaned with them — is stripped by projection\n * (`projection.ts:49`) and set to `undefined` on hydration\n * (`client/hydrate-page.tsx`), so the browser never holds it and there is\n * nothing on the client to hot-swap. Its effect is felt only when SSR re-runs\n * and re-renders `<head>`; the honest way to apply a change to it is a full\n * document reload.\n *\n * THE RULING (canon `6b240682`), stated as the invariant it is:\n *\n * FAST REFRESH ONLY WHEN THE ONLY CHANGES ARE INSIDE COMPONENT BODIES.\n * EVERYTHING ELSE RELOADS.\n *\n * Concretely: any change to an import statement, to a module-level\n * declaration, or to a server export forces a full document reload — whether\n * or not the JSX moved in the same save.\n *\n * WHY AN OVER-APPROXIMATION, AND WHY NOBODY SHOULD \"IMPROVE\" IT BACK\n *\n * Two earlier cuts tried to name the server half EXACTLY and both shipped a\n * stale `<head>`:\n *\n * 1. Comparing only the projected CLIENT code. A save that changed the JSX\n * *and* `metadata` moved the client half, which was read as proof that\n * only the JSX moved. Mixed saves took the Fast Refresh branch.\n * 2. Adding the complement — the stripped server half, recovered by\n * subsequence diff. `projection.ts:447-448` KEEPS an import when the\n * client reads it, so an import read by BOTH `metadata` and the JSX\n * lives in the projection and appears in NEITHER half exclusively.\n * Change its specifier and the complement is byte-identical → Fast\n * Refresh, stale `<title>`. Shared module-level LOCALS have the same\n * shape, so extending the complement a third time is a third bug.\n *\n * Both failures were UNDER-approximations, and under-approximating is the\n * unsafe direction. A precise reachability analysis over shared imports and\n * locals is the correct answer and is a later refinement; getting it subtly\n * wrong reproduces this bug again. Over-approximating can only err toward\n * RELOADING. A needless reload costs component state; a missed one ships a\n * stale `<head>` and calls it a hot update.\n *\n * THE ACCEPTED COST, which is not a bug to be optimised away: editing a\n * module-level helper read only by the JSX now reloads.\n *\n * The skeleton has to be captured BEFORE the edit, because by the time\n * `hotUpdate` runs Vite has already hard-invalidated the module and cleared its\n * `transformResult` (`onFileChange` → `invalidateModule`, which runs before any\n * `hotUpdate` hook). The `transform` spy below is that capture: it runs first in\n * the client environment, records the skeleton, and returns nothing so\n * projection still performs the real transform.\n */\ntype SkeletonCache = Map<string, string>;\n\n/**\n * What replaces a component body in the skeleton. Its content is irrelevant —\n * only that it is CONSTANT, so two sources that differ solely inside a masked\n * body serialise identically.\n */\nconst MASKED_COMPONENT_BODY = \"/*warlock:component-body*/\";\n\n/** React's own convention, and the one `react-refresh` itself uses: components are PascalCase. */\nfunction isComponentName(name: string | undefined): boolean {\n return typeof name === \"string\" && /^[A-Z]/.test(name);\n}\n\n/** The `body` node of a function-shaped expression/declaration, or `undefined` for anything else. */\nfunction functionBody(node: any): any | undefined {\n if (!node) return undefined;\n if (\n node.type === \"FunctionDeclaration\" ||\n node.type === \"FunctionExpression\" ||\n node.type === \"ArrowFunctionExpression\"\n ) {\n return node.body;\n }\n return undefined;\n}\n\n/**\n * Generic duck-typed identifier walk, the same shape `projection.ts`'s\n * `collectIdentifierNames` uses (it is not exported, and re-deriving one\n * OVER-collecting walk is safe here for the same reason it is safe there).\n *\n * Over-collecting — counting an object property key or a shadowing parameter\n * as a \"read\" — can only make the reachable set BIGGER, which can only UNMASK\n * more component bodies, which can only produce more reloads. The safe\n * direction.\n */\nfunction collectIdentifierNames(node: unknown, names: Set<string>): void {\n if (!node || typeof node !== \"object\") return;\n if (Array.isArray(node)) {\n for (const item of node) collectIdentifierNames(item, names);\n return;\n }\n const record = node as Record<string, unknown>;\n if (typeof record.type !== \"string\") return;\n if (record.type === \"Identifier\" || record.type === \"JSXIdentifier\") {\n names.add((record as any).name);\n }\n for (const key of Object.keys(record)) {\n if (key === \"type\" || key === \"start\" || key === \"end\" || key === \"loc\" || key === \"range\") continue;\n if (key === \"leadingComments\" || key === \"trailingComments\" || key === \"innerComments\" || key === \"extra\") {\n continue;\n }\n collectIdentifierNames(record[key], names);\n }\n}\n\n/** The module-scope names a top-level statement binds (the `export` wrapper looked through). */\nfunction topLevelBoundNames(stmt: any): Set<string> {\n const names = new Set<string>();\n const declaration = stmt.type === \"ExportNamedDeclaration\" ? stmt.declaration : stmt;\n if (!declaration) return names;\n if (declaration.type === \"VariableDeclaration\") {\n for (const declarator of declaration.declarations) {\n if (declarator.id?.type === \"Identifier\") names.add(declarator.id.name);\n }\n } else if (declaration.id?.type === \"Identifier\") {\n names.add(declaration.id.name);\n }\n return names;\n}\n\n/**\n * Every module-scope name reachable from one of the five server exports.\n *\n * Used ONLY to UNMASK: a PascalCase function that `metadata` or `loader` can\n * reach is not a component for this purpose, it is a server-side helper that\n * merely looks like one, and a change inside its body must reload. Seeded from\n * any top-level statement binding a `SERVER_EXPORT_NAMES` name — deliberately\n * looser than `projection.ts`'s own `isServerExportDeclaration` (no export\n * requirement, no single-declarator requirement), because seeding from MORE\n * statements can only unmask more, i.e. reload more.\n *\n * Fixpoint, not one pass, for the same reason projection's is: a server-only\n * helper can be reached only through another server-only helper.\n */\nfunction serverReachableNames(body: any[]): Set<string> {\n const reached = new Set<string>();\n const declarations = body\n .filter((stmt) => stmt.type !== \"ImportDeclaration\")\n .map((stmt) => ({ stmt, names: topLevelBoundNames(stmt) }));\n\n for (const { stmt, names } of declarations) {\n let isServerExport = false;\n for (const name of names) {\n if (SERVER_EXPORT_NAMES.has(name)) isServerExport = true;\n }\n if (isServerExport) collectIdentifierNames(stmt, reached);\n }\n\n for (let changed = true; changed; ) {\n changed = false;\n for (const { stmt, names } of declarations) {\n let isReached = false;\n for (const name of names) {\n if (reached.has(name)) isReached = true;\n }\n if (!isReached) continue;\n const before = reached.size;\n collectIdentifierNames(stmt, reached);\n if (reached.size !== before) changed = true;\n }\n }\n\n return reached;\n}\n\n/**\n * The body node to mask for a top-level statement, or `undefined` if this\n * statement is not a component declaration.\n *\n * Recognised shapes, and only these:\n * - `export default function () {…}` / `export default () => …` — the page\n * component, whatever it is called.\n * - `function Name() {…}` / `const Name = () => …` (PascalCase, optionally\n * `export`ed) — a component declared alongside it.\n *\n * Everything else — `memo(...)`/`forwardRef(...)` wrappers, classes,\n * lowercase helpers, every server export — is left UNMASKED and therefore\n * compared byte-for-byte. That costs Fast Refresh on those shapes and buys the\n * guarantee; see this seam's header.\n */\nfunction componentBodyToMask(stmt: any, serverReachable: Set<string>): any | undefined {\n if (stmt.type === \"ExportDefaultDeclaration\") return functionBody(stmt.declaration);\n\n const declaration = stmt.type === \"ExportNamedDeclaration\" ? stmt.declaration : stmt;\n if (!declaration) return undefined;\n\n const named = (name: string | undefined, node: any) =>\n isComponentName(name) && !serverReachable.has(name as string) ? functionBody(node) : undefined;\n\n if (declaration.type === \"FunctionDeclaration\") {\n return named(declaration.id?.name, declaration);\n }\n if (declaration.type === \"VariableDeclaration\" && declaration.declarations.length === 1) {\n const declarator = declaration.declarations[0];\n if (declarator.id?.type !== \"Identifier\") return undefined;\n return named(declarator.id.name, declarator.init);\n }\n return undefined;\n}\n\n/**\n * The module source with every component body replaced by a constant — the\n * ONE value the reload decision compares across an edit.\n *\n * Everything outside a component body survives verbatim: imports, module-level\n * declarations, all five server exports, and the comments and whitespace\n * between them. So the skeleton is unchanged iff the save touched nothing but\n * component bodies, which is exactly the ruling.\n *\n * Returns `undefined` when the source does not parse — a half-typed file whose\n * error Vite is already reporting from projection's real `transform`. The\n * caller leaves the cache holding the last GOOD skeleton, so the next\n * successful save is still compared against the right baseline.\n */\nfunction captureSkeleton(code: string): string | undefined {\n let ast: ReturnType<typeof parse>;\n try {\n ast = parse(code, { sourceType: \"module\", plugins: [\"typescript\", \"jsx\"] });\n } catch {\n return undefined;\n }\n\n const body = ast.program.body as any[];\n const serverReachable = serverReachableNames(body);\n const magic = new MagicString(code);\n\n for (const stmt of body) {\n const bodyNode = componentBodyToMask(stmt, serverReachable);\n if (!bodyNode) continue;\n const start = bodyNode.start as number;\n const end = bodyNode.end as number;\n if (end > start) magic.overwrite(start, end, MASKED_COMPONENT_BODY);\n }\n\n return magic.toString();\n}\n\n/**\n * Serves the client page registry at {@link CLIENT_PAGE_REGISTRY_ID}.\n *\n * Discovery runs INSIDE `load`, once per `load` call, and its result is NOT\n * cached across builds — the plugin holds no state at all. A registry cached\n * past the moment a page file appears is a page that silently 404s until\n * someone restarts the dev server, which is a far more expensive bug than\n * re-walking a source tree. Rollup calls `load` once per module per build, and\n * in dev Vite's module graph caches the transformed result until the module is\n * invalidated, so the walk is not per-request either way. (Invalidating that\n * dev-server cache when a page file is ADDED needs a `handleHotUpdate`/watcher\n * hook that belongs with the dev provider slice — see the followup.)\n *\n * `enforce: \"pre\"` and placed FIRST in `warlockClientBoundary`'s array — see\n * that function's comment in `index.ts` for why position is what it is, and\n * `page-registry-plugin.spec.ts` for the real-build proof that the pages this\n * module names still reach `projection()`.\n */\nexport function clientPageRegistry(options: ClientPageRegistryPluginOptions = {}): Plugin {\n const appRoot = path.resolve(options.appRoot ?? process.cwd());\n\n // Per-plugin-instance, so two composed pipelines never cross-contaminate.\n // Holds the last captured SKELETON (source with component bodies masked) of\n // each server page module the client environment transformed — the \"before\"\n // side of the comparison in `hotUpdate`. See `captureSkeleton` above.\n const skeletonCache: SkeletonCache = new Map();\n\n return {\n name: \"warlock:client-page-registry\",\n enforce: \"pre\",\n resolveId(source) {\n if (source === CLIENT_PAGE_REGISTRY_ID) return RESOLVED_CLIENT_PAGE_REGISTRY_ID;\n return undefined;\n },\n load(id) {\n if (id !== RESOLVED_CLIENT_PAGE_REGISTRY_ID) return undefined;\n\n const pages = discoverPages({ appRoot, srcDir: options.srcDir });\n\n return eraseTypes(generateClientRegistry({ pages, toImportSpecifier }));\n },\n /**\n * Capture-only spy. Records the SKELETON of every server page module the\n * CLIENT environment transforms, and returns nothing so projection's own\n * `transform` still does the real work. SERVE-ONLY:\n * `this.environment.mode !== \"dev\"` skips it during `vite build`, where\n * there is no `hotUpdate` to feed and the extra parse would be pure cost.\n */\n transform(code, id) {\n if (this.environment?.mode !== \"dev\") return undefined;\n if (!isServerPageModule(id)) return undefined;\n\n const skeleton = captureSkeleton(code);\n if (skeleton !== undefined) skeletonCache.set(id, skeleton);\n\n return undefined;\n },\n /**\n * Applies the ruling (canon `6b240682`): Fast Refresh ONLY when the only\n * changes are inside component bodies.\n *\n * - Skeleton moved (an import, a module-level declaration, ANY server\n * export — with or without a simultaneous JSX change) → full reload.\n * - Skeleton unchanged → defer to Fast Refresh, zero reloads.\n *\n * Note what is NOT here: no attempt to name which half a shared import or\n * local belongs to. That question is what produced the two previous stale\n * `<head>` bugs; this seam refuses to answer it and reloads instead.\n * `hotUpdate` exists only on the dev server, so this is serve-only by\n * construction.\n */\n async hotUpdate(context) {\n // `create`/`delete` are page graph churn, not in-place edits — leave them\n // to Vite's normal handling (a new/removed module reloads on its own).\n if (context.type !== \"update\") return undefined;\n if (!isServerPageModule(context.file)) return undefined;\n\n const nextSource = await context.read();\n const next = captureSkeleton(nextSource);\n const prev = skeletonCache.get(context.file);\n\n // Refresh the cache for the next edit regardless of the decision below.\n if (next !== undefined) skeletonCache.set(context.file, next);\n\n // Could not parse the new source (Vite is already reporting that error),\n // or the client environment never transformed this module — which means\n // the browser is not holding this page, so there is no stale `<head>` to\n // ship and nothing a reload of some OTHER page would fix.\n if (next === undefined || prev === undefined) return undefined;\n\n // Anything outside a component body moved: the browser cannot hot-swap\n // it, so reload the document to re-run SSR and re-render `<head>`.\n // `path: \"*\"` matches Vite's own middleware-mode reload.\n if (prev !== next) {\n this.environment.hot.send({ type: \"full-reload\", path: \"*\" });\n\n // Empty module list: we've issued the update ourselves, so Vite should\n // not additionally push a Fast Refresh for the client module.\n return [];\n }\n\n // Only component bodies moved: defer to Vite's Fast Refresh with zero\n // reloads. A no-op re-save falls through the same harmless path.\n return undefined;\n },\n };\n}\n"],"mappings":"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"}
|
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
|
-
>
|
|
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
|
|
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
|
|
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
|
|
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`,
|
|
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
|
|
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`,
|
|
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
|
|
15
|
-
"@warlock.js/seal": "5.0
|
|
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
|
|
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
|
|
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
|
|
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:
|