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