@warlock.js/web 5.5.0 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -5
- package/esm/build/contribution.d.mts +2 -2
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +127 -205
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs +5 -3
- package/esm/build/generate-pages-barrel.mjs.map +1 -1
- package/esm/build/public-files.mjs +29 -1
- 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/navigation-root.mjs +23 -103
- 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.map +1 -1
- package/esm/index.d.mts +3 -2
- package/esm/index.mjs +3 -2
- package/esm/loaders.d.mts +10 -3
- 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 +35 -2
- 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 +6 -6
- 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 +87 -23
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/execute-page-request.types.d.mts +10 -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 +2 -2
- package/esm/server/hydration-client-url.mjs.map +1 -1
- package/esm/server/index.d.mts +2 -2
- package/esm/server/index.mjs +2 -2
- package/esm/server/install-page-routes-from-manifest.d.mts +1 -1
- package/esm/server/install-page-routes-from-manifest.mjs +43 -40
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +22 -2
- package/esm/server/install-page-routes.mjs +108 -42
- 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.map +1 -1
- package/esm/server/page-file-change.mjs.map +1 -1
- 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 +25 -1
- package/esm/server/register-production-public-files.mjs.map +1 -1
- package/esm/server/render-page.d.mts +11 -48
- package/esm/server/render-page.mjs +13 -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/route-validation-error.mjs +32 -0
- package/esm/server/route-validation-error.mjs.map +1 -0
- package/esm/server/settle-page-response.mjs +5 -3
- package/esm/server/settle-page-response.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 +24 -321
- 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/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 +31 -70
- 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 +5 -7
- 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 +558 -482
- package/llms.txt +2 -2
- package/package.json +6 -6
- package/skills/create-a-page/SKILL.md +348 -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
|
@@ -1,189 +1,212 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: navigate-on-the-client
|
|
3
|
-
description: 'Navigate hydrated pages with `<Link>`, resolve named URLs with `href()`, use `navigateTo` / `navigateBack`, prefetch on interaction, inspect the server match with `currentRoute()`,
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Warlock — navigate on the client
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
Every behaviour on this page depends on hydration having mounted. See [write-the-root](../write-the-root/SKILL.md#root-is-the-hydration-boundary).
|
|
11
|
-
|
|
12
|
-
## The shape
|
|
13
|
-
|
|
14
|
-
```tsx title="src/web/components/product-link.tsx"
|
|
15
|
-
import { Link } from "@warlock.js/web";
|
|
16
|
-
|
|
17
|
-
export function ProductLink({ id }: { id: string }) {
|
|
18
|
-
return (
|
|
19
|
-
<Link to="products.details" params={{ id }} query={{ tab: "specifications" }} prefetch>
|
|
20
|
-
View product
|
|
21
|
-
</Link>
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
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.
|
|
27
|
-
|
|
28
|
-
## Link destinations
|
|
29
|
-
|
|
30
|
-
Pass exactly one destination prop:
|
|
31
|
-
|
|
32
|
-
```tsx
|
|
33
|
-
import { Link } from "@warlock.js/web";
|
|
34
|
-
|
|
35
|
-
export function NavigationLinks() {
|
|
36
|
-
return (
|
|
37
|
-
<nav>
|
|
38
|
-
<Link to="products.index">Products</Link>
|
|
39
|
-
<Link href="/pricing">Pricing</Link>
|
|
40
|
-
<Link href="https://example.com/docs" newTab>
|
|
41
|
-
External docs
|
|
42
|
-
</Link>
|
|
43
|
-
<Link email="sales@example.com">Email sales</Link>
|
|
44
|
-
<Link tel="+201000000000">Call sales</Link>
|
|
45
|
-
</nav>
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
`to` and `href` both accept a route name or literal URL:
|
|
51
|
-
|
|
52
|
-
- A value beginning with `/` or with a URI scheme is literal and is passed through.
|
|
53
|
-
- Any other value is a route name and is resolved through `href()`.
|
|
54
|
-
- `params` and `query` apply only to route names.
|
|
55
|
-
- `newTab` supplies `_blank` and `noopener noreferrer` unless you provide your own `target` or `rel`.
|
|
56
|
-
|
|
57
|
-
`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.
|
|
58
|
-
|
|
59
|
-
Modified clicks, middle clicks, downloads, another browsing context, or an earlier `preventDefault()` remain browser-owned.
|
|
60
|
-
|
|
61
|
-
## Build a URL without React
|
|
62
|
-
|
|
63
|
-
`href(name, params?, query?)` is the durable primitive for redirects, headers, email bodies, and other non-component callers:
|
|
64
|
-
|
|
65
|
-
```ts
|
|
66
|
-
import { href } from "@warlock.js/web";
|
|
67
|
-
|
|
68
|
-
const productUrl = href(
|
|
69
|
-
"products.details",
|
|
70
|
-
{ id: "42" },
|
|
71
|
-
{ tab: "reviews", tags: ["featured", "sale"] },
|
|
72
|
-
);
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
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.
|
|
76
|
-
|
|
77
|
-
## Programmatic navigation
|
|
78
|
-
|
|
79
|
-
```tsx title="src/web/components/checkout-button.tsx"
|
|
80
|
-
import { href, navigateBack, navigateTo } from "@warlock.js/web";
|
|
81
|
-
|
|
82
|
-
export function CheckoutButtons() {
|
|
83
|
-
const openCheckout = () => {
|
|
84
|
-
const url = href("checkout.index");
|
|
85
|
-
|
|
86
|
-
if (!navigateTo(url)) {
|
|
87
|
-
window.location.assign(url);
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
return (
|
|
92
|
-
<div>
|
|
93
|
-
<button type="button" onClick={navigateBack}>
|
|
94
|
-
Back
|
|
95
|
-
</button>
|
|
96
|
-
<button type="button" onClick={openCheckout}>
|
|
97
|
-
Checkout
|
|
98
|
-
</button>
|
|
99
|
-
</div>
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
`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.
|
|
105
|
-
|
|
106
|
-
## Re-fetch after a mutation
|
|
107
|
-
|
|
108
|
-
The public primitive is `refresh()`. There is no `revalidate()` export.
|
|
109
|
-
|
|
110
|
-
```tsx title="src/web/products/delete-product-button.tsx"
|
|
111
|
-
import { refresh } from "@warlock.js/web";
|
|
112
|
-
|
|
113
|
-
export function DeleteProductButton({ id }: { id: string }) {
|
|
114
|
-
const deleteProduct = async () => {
|
|
115
|
-
const response = await fetch(`/api/products/${encodeURIComponent(id)}`, {
|
|
116
|
-
method: "DELETE",
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
if (!response.ok) {
|
|
120
|
-
throw new Error(`Delete failed with status ${response.status}`);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const refreshed = await refresh();
|
|
124
|
-
|
|
125
|
-
if (!refreshed) {
|
|
126
|
-
window.location.reload();
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
return (
|
|
131
|
-
<button type="button" onClick={deleteProduct}>
|
|
132
|
-
Delete product
|
|
133
|
-
</button>
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
`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.
|
|
139
|
-
|
|
140
|
-
This is an ordinary API mutation followed by a re-fetch. Server actions are not supported.
|
|
141
|
-
|
|
142
|
-
##
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
return
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
1
|
+
---
|
|
2
|
+
name: navigate-on-the-client
|
|
3
|
+
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.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Warlock — navigate on the client
|
|
7
|
+
|
|
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
|
+
|
|
10
|
+
Every behaviour on this page depends on hydration having mounted. See [write-the-root](../write-the-root/SKILL.md#root-is-the-hydration-boundary).
|
|
11
|
+
|
|
12
|
+
## The shape
|
|
13
|
+
|
|
14
|
+
```tsx title="src/web/components/product-link.tsx"
|
|
15
|
+
import { Link } from "@warlock.js/web";
|
|
16
|
+
|
|
17
|
+
export function ProductLink({ id }: { id: string }) {
|
|
18
|
+
return (
|
|
19
|
+
<Link to="products.details" params={{ id }} query={{ tab: "specifications" }} prefetch>
|
|
20
|
+
View product
|
|
21
|
+
</Link>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
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.
|
|
27
|
+
|
|
28
|
+
## Link destinations
|
|
29
|
+
|
|
30
|
+
Pass exactly one destination prop:
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { Link } from "@warlock.js/web";
|
|
34
|
+
|
|
35
|
+
export function NavigationLinks() {
|
|
36
|
+
return (
|
|
37
|
+
<nav>
|
|
38
|
+
<Link to="products.index">Products</Link>
|
|
39
|
+
<Link href="/pricing">Pricing</Link>
|
|
40
|
+
<Link href="https://example.com/docs" newTab>
|
|
41
|
+
External docs
|
|
42
|
+
</Link>
|
|
43
|
+
<Link email="sales@example.com">Email sales</Link>
|
|
44
|
+
<Link tel="+201000000000">Call sales</Link>
|
|
45
|
+
</nav>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`to` and `href` both accept a route name or literal URL:
|
|
51
|
+
|
|
52
|
+
- A value beginning with `/` or with a URI scheme is literal and is passed through.
|
|
53
|
+
- Any other value is a route name and is resolved through `href()`.
|
|
54
|
+
- `params` and `query` apply only to route names.
|
|
55
|
+
- `newTab` supplies `_blank` and `noopener noreferrer` unless you provide your own `target` or `rel`.
|
|
56
|
+
|
|
57
|
+
`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.
|
|
58
|
+
|
|
59
|
+
Modified clicks, middle clicks, downloads, another browsing context, or an earlier `preventDefault()` remain browser-owned.
|
|
60
|
+
|
|
61
|
+
## Build a URL without React
|
|
62
|
+
|
|
63
|
+
`href(name, params?, query?)` is the durable primitive for redirects, headers, email bodies, and other non-component callers:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { href } from "@warlock.js/web";
|
|
67
|
+
|
|
68
|
+
const productUrl = href(
|
|
69
|
+
"products.details",
|
|
70
|
+
{ id: "42" },
|
|
71
|
+
{ tab: "reviews", tags: ["featured", "sale"] },
|
|
72
|
+
);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
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.
|
|
76
|
+
|
|
77
|
+
## Programmatic navigation
|
|
78
|
+
|
|
79
|
+
```tsx title="src/web/components/checkout-button.tsx"
|
|
80
|
+
import { href, navigateBack, navigateTo } from "@warlock.js/web";
|
|
81
|
+
|
|
82
|
+
export function CheckoutButtons() {
|
|
83
|
+
const openCheckout = () => {
|
|
84
|
+
const url = href("checkout.index");
|
|
85
|
+
|
|
86
|
+
if (!navigateTo(url)) {
|
|
87
|
+
window.location.assign(url);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div>
|
|
93
|
+
<button type="button" onClick={navigateBack}>
|
|
94
|
+
Back
|
|
95
|
+
</button>
|
|
96
|
+
<button type="button" onClick={openCheckout}>
|
|
97
|
+
Checkout
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`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.
|
|
105
|
+
|
|
106
|
+
## Re-fetch after a mutation
|
|
107
|
+
|
|
108
|
+
The public primitive is `refresh()`. There is no `revalidate()` export.
|
|
109
|
+
|
|
110
|
+
```tsx title="src/web/products/delete-product-button.tsx"
|
|
111
|
+
import { refresh } from "@warlock.js/web";
|
|
112
|
+
|
|
113
|
+
export function DeleteProductButton({ id }: { id: string }) {
|
|
114
|
+
const deleteProduct = async () => {
|
|
115
|
+
const response = await fetch(`/api/products/${encodeURIComponent(id)}`, {
|
|
116
|
+
method: "DELETE",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
throw new Error(`Delete failed with status ${response.status}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const refreshed = await refresh();
|
|
124
|
+
|
|
125
|
+
if (!refreshed) {
|
|
126
|
+
window.location.reload();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<button type="button" onClick={deleteProduct}>
|
|
132
|
+
Delete product
|
|
133
|
+
</button>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`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.
|
|
139
|
+
|
|
140
|
+
This is an ordinary API mutation followed by a re-fetch. Server actions are not supported.
|
|
141
|
+
|
|
142
|
+
## Read the query string live — `useQueryString`
|
|
143
|
+
|
|
144
|
+
```tsx title="src/web/products/product-list-filters.tsx"
|
|
145
|
+
import { useQueryString } from "@warlock.js/web";
|
|
146
|
+
|
|
147
|
+
export function ProductListFilters() {
|
|
148
|
+
const tab = useQueryString("tab");
|
|
149
|
+
// Narrow before rendering: anyone can type `?tab[]=a` or `?tab[x]=1`, so the
|
|
150
|
+
// hook's type says the value may not be a string.
|
|
151
|
+
const active = typeof tab === "string" ? tab : "all";
|
|
152
|
+
|
|
153
|
+
return <p>Tab: {active}</p>;
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`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.
|
|
158
|
+
|
|
159
|
+
**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.
|
|
160
|
+
|
|
161
|
+
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.
|
|
162
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
165
|
+
## Current and previous routes
|
|
166
|
+
|
|
167
|
+
`currentRoute()` and `previousRoute()` report route names and params sent by the server; they never re-match the browser URL.
|
|
168
|
+
|
|
169
|
+
```tsx title="src/web/components/current-product-id.tsx"
|
|
170
|
+
import { currentRoute, previousRoute } from "@warlock.js/web";
|
|
171
|
+
|
|
172
|
+
export function CurrentProductId() {
|
|
173
|
+
const current = currentRoute();
|
|
174
|
+
const previous = previousRoute();
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<dl>
|
|
178
|
+
<dt>Current product</dt>
|
|
179
|
+
<dd>{current?.params?.id ?? "none"}</dd>
|
|
180
|
+
<dt>Previous page</dt>
|
|
181
|
+
<dd>{previous?.name ?? "first visit"}</dd>
|
|
182
|
+
</dl>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`previousRoute()` means the previously swapped page, not the previous browser-history entry. On the first page it is `undefined`.
|
|
188
|
+
|
|
189
|
+
`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.
|
|
190
|
+
|
|
191
|
+
## Failure behavior
|
|
192
|
+
|
|
193
|
+
- A failed navigation falls back to a full browser load so the user still arrives.
|
|
194
|
+
- A failed `refresh()` keeps the current page and reports `false`; it never spends the page the user already has.
|
|
195
|
+
- Back/Forward uses the same data fetch and tree swap, replacing the existing history entry rather than pushing another.
|
|
196
|
+
- Navigating within one layout preserves layout state through React reconciliation.
|
|
197
|
+
|
|
198
|
+
## Gotchas
|
|
199
|
+
|
|
200
|
+
- **Use `refresh()`, not `revalidate()`.** `refresh` is the exported loader re-fetch primitive.
|
|
201
|
+
- **`navigateTo` accepts a URL/path.** Resolve named routes with `href()` first.
|
|
202
|
+
- **Pass exactly one Link destination.** Combining `to`, `href`, `email`, or `tel` throws.
|
|
203
|
+
- **Do not add `params` or `query` to a literal URL.** Put them in the URL itself or use a route name.
|
|
204
|
+
- **Do not build a client matcher.** The server's matched name and params travel in the payload.
|
|
205
|
+
- **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`.
|
|
206
|
+
|
|
207
|
+
## See also
|
|
208
|
+
|
|
209
|
+
- [`create-a-page/SKILL.md`](../create-a-page/SKILL.md) — declare route names and params.
|
|
210
|
+
- [`load-page-data/SKILL.md`](../load-page-data/SKILL.md) — what `refresh()` re-runs.
|
|
211
|
+
- [`use-layouts/SKILL.md`](../use-layouts/SKILL.md) — why layout state persists.
|
|
212
|
+
- [`write-the-root/SKILL.md`](../write-the-root/SKILL.md) — the `#root` swap boundary.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../../../../../../web/src/hydration/index.ts"],"sourcesContent":["/**\n * The client entry: registry in, hydration out. No page names, no app paths.\n *\n * The page graph arrives from the Vite virtual module the build plugin serves,\n * so this file works for ANY page in ANY app. The previous version imported one\n * page and one layout by relative path into the monorepo's reference app, which\n * both hydrated every URL as the home page and made `@warlock.js/web`\n * uninstallable anywhere outside this checkout. Neither a page name nor an app\n * path appears here now, and nothing about the composition lives here either -\n * that is `buildHydratedTree`, which takes the registry as an argument and is\n * therefore testable without a bundler.\n */\nimport { createElement } from \"react\";\nimport { pages } from \"virtual:warlock/pages\";\nimport { buildHydratedTree } from \"../client/build-hydrated-tree\";\nimport { hydratePage } from \"../client/hydrate-page\";\nimport { NavigationRoot } from \"../client/navigation/navigation-root\";\nimport { publishRouteTable } from \"../routing/route-table\";\n\n/*\n BEFORE the mount, not after: `<Link>` resolves its URL through the route table\n during render, and the first render is the hydration render. Publishing\n afterwards would make every anchor in the initial tree throw.\n\n The registry entries already carry `name` and `path` - the same pair the\n server registered its routes from, out of the same discovery result - so the\n browser's table cannot drift from the server's without the two being built\n from different page graphs, which hydration already refuses.\n*/\npublishRouteTable(pages, \"hydration client entry\");\n\n/*\n The hydrated tree is wrapped in `NavigationRoot` so the page can be REPLACED\n later without a document load. The first render is still exactly the tree the\n server produced - `NavigationRoot` renders `initialTree` verbatim and adds no\n markup of its own - so hydration still matches the server byte for byte, and\n the wrapper only starts to matter on the first navigation.\n\n NO MRR HISTORY BRIDGE IS INSTALLED HERE YET, and that is a pending decision\n rather than an oversight. This runtime drives `window.history` itself and is\n complete without MRR. Handing history to `@mongez/react-router` as well means\n `@warlock.js/web` importing it, which puts MRR in the bundle of EVERY app that\n uses this package - a packaging choice (dependency vs. peer vs. app-level\n opt-in) with consequences for apps that never navigate through MRR. The bridge\n itself is written and documented in\n `conversations/2026-08-24-production-ssr-session.md`; it lands the moment that\n choice is made.\n*/\nhydratePage(async (payload) => {\n const tree = await buildHydratedTree(pages, payload);\n\n return createElement(NavigationRoot, {\n pages,\n initialPayload: payload,\n initialTree: tree,\n buildTree: buildHydratedTree,\n });\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA6BA,kBAAkB,OAAO,wBAAwB;AAmBjD,YAAY,OAAO,YAAY;CAG7B,OAAO,cAAc,gBAAgB;EACnC;EACA,gBAAgB;EAChB,aAAa,MALI,kBAAkB,OAAO,OAAO;EAMjD,WAAW;CACb,CAAC;AACH,CAAC"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"register-modules.mjs","names":[],"sources":["../../../../../../../web/src/runtime/register-modules.ts"],"sourcesContent":["/**\n * The universal portion of a page or layout module namespace.\n *\n * `register` deliberately has no arguments and must finish synchronously: it\n * runs while both the server and browser evaluate the same module graph.\n */\nexport type RegisterableModuleNamespace = {\n readonly register?: () => unknown;\n};\n\nconst registeredModules = new WeakSet<RegisterableModuleNamespace>();\n\nfunction isThenable(value: unknown): value is PromiseLike<unknown> {\n return (\n value !== null &&\n (typeof value === \"object\" || typeof value === \"function\") &&\n typeof (value as { then?: unknown }).then === \"function\"\n );\n}\n\n/**\n * Run each module's optional universal registration hook at most once for\n * that module namespace instance. A replacement namespace from HMR naturally\n * has a new identity and is therefore registered again.\n */\nexport function registerModules(modules: readonly RegisterableModuleNamespace[]): void {\n for (const module of modules) {\n if (registeredModules.has(module)) continue;\n\n const result = module.register?.();\n\n if (isThenable(result)) {\n throw new Error(\n \"Warlock register() hooks must be synchronous and must not return a Promise or thenable.\",\n );\n }\n\n // A throwing hook (including one that returned a thenable above) is not\n // recorded, so a later route composition still exposes and can retry it.\n registeredModules.add(module);\n }\n}\n"],"mappings":";AAUA,MAAM,oCAAoB,IAAI,QAAqC;AAEnE,SAAS,WAAW,OAA+C;CACjE,OACE,UAAU,SACT,OAAO,UAAU,YAAY,OAAO,UAAU,eAC/C,OAAQ,MAA6B,SAAS;AAElD;;;;;;AAOA,SAAgB,gBAAgB,SAAuD;CACrF,KAAK,MAAM,UAAU,SAAS;EAC5B,IAAI,kBAAkB,IAAI,MAAM,GAAG;EAEnC,MAAM,SAAS,OAAO,WAAW;EAEjC,IAAI,WAAW,MAAM,GACnB,MAAM,IAAI,MACR,yFACF;EAKF,kBAAkB,IAAI,MAAM;CAC9B;AACF"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"dev-server.mjs","names":[],"sources":["../../../../../../../web/src/server/dev-server.ts"],"sourcesContent":["/** Development-only transport for Vite transform failures. */\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { stripVTControlCharacters } from \"node:util\";\nimport type { Connect, Plugin } from \"vite\";\n\n/**\n * Where a captured transform/resolve failure rides from Vite's connect stack to\n * the Fastify hook that mounted it.\n *\n * A `Symbol.for` key on the raw `IncomingMessage` rather than a `WeakMap`\n * because the two halves live in different modules and are wired at different\n * times; the request object is the only thing they provably share, and the\n * symbol cannot collide with a Vite/Fastify/user property.\n *\n * Exported so a test can stage a captured failure without booting a vite\n * server, and so the two halves cannot drift onto two different keys.\n */\nexport const DEV_TRANSFORM_ERROR_BODY = Symbol.for(\"warlock.web.devTransformErrorBody\");\n\n/**\n * Re-exported so a dev-only caller can reach the cookie commit seam through\n * this module without a second import path into `./buffered-response`, which\n * is where the implementation actually lives.\n */\nexport { applyBufferedCookie, type BufferedCookie } from \"./buffered-response\";\n\ntype DevTransformErrorCarrier = { [DEV_TRANSFORM_ERROR_BODY]?: string };\n\n/**\n * The status a refused module now answers with.\n *\n * NOT the 404 this replaces. That 404 was never a decision about the module —\n * it is what an unmatched URL gets once vite has declined it, and which of the\n * two framework answers you see depends only on whether the app declares a\n * catch-all page: with one (v5/app does — `path: \"*\"`) the request lands in the\n * page pipeline, matches no route, and `./render-page.ts:604` returns\n * `{ html: \"\", status: 404 }` for `./create-page-route-handler.ts:147` to write\n * as an empty `text/html` body; without one it is `core/src/router/router.ts:879`.\n * Either way \"the module does not exist\" is precisely the wrong thing to tell a\n * developer whose module exists and was refused. 500 is the status VITE ITSELF writes for\n * this exact condition when it is not in middleware mode\n * (`node_modules/vite/dist/node/chunks/config.js:9528`), so this adopts that\n * convention rather than inventing a third one.\n */\nexport const DEV_TRANSFORM_ERROR_STATUS = 500;\n\n/**\n * `buildErrorMessage` as vite exports it. Declared structurally so this module\n * needs no value import of vite — vite is an optional, dev-only peer and a\n * production install does not carry it.\n */\nexport type BuildErrorMessage = (\n error: Error,\n args?: string[],\n includeStack?: boolean,\n) => string;\n\n/**\n * The dev error transport was constructed while the process is hosting a\n * PRODUCTION build. Refused by name at construction rather than degraded,\n * because everything this transport does — file paths, source frames, plugin\n * names — is exactly what a production response must never carry.\n */\nexport class DevErrorTransportInProductionError extends Error {\n public constructor() {\n super(\n \"The dev error transport was constructed with `Application.runtimeStrategy === \" +\n '\"production\"`. It exists only to put a Vite transform failure in front of a ' +\n \"developer and its response body carries absolute file paths and source frames, \" +\n \"so it must never be mounted on a production-hosted server.\",\n );\n this.name = \"DevErrorTransportInProductionError\";\n }\n}\n\n/**\n * Render a refused module's failure as the plain-text body the browser gets.\n *\n * Formatting is DELEGATED to vite's own exported `buildErrorMessage`, not\n * reimplemented: it is the same function vite uses to print the failure to the\n * terminal, so the text a developer reads in the network panel and the text\n * they read in the terminal cannot drift. Two things are added around it —\n * `error.name`, which vite's terminal path replaces with a fixed\n * \"Internal server error:\" prefix and which is the single most useful token for\n * a named gate refusal (`ProjectionAmbiguityError`), and the `cause` chain,\n * which vite does not walk.\n *\n * `stripVTControlCharacters` is not optional: `buildErrorMessage` colours its\n * output with picocolors, which is ON whenever the dev server owns a TTY, and\n * raw ANSI escapes in an HTTP body are noise. Vite strips them the same way for\n * the overlay payload (`config.js:9490-9497`).\n *\n * The stack is deliberately omitted (`includeStack: false`). A gate refusal's\n * stack points into the gate, not into the developer's code; the fields that\n * locate the problem — plugin, file, line, source frame — are what\n * `buildErrorMessage` puts there without it.\n */\nexport function formatDevTransformError(\n error: unknown,\n buildErrorMessage: BuildErrorMessage,\n): string {\n const failure =\n error instanceof Error ? error : new Error(typeof error === \"string\" ? error : String(error));\n\n const lines = [\n stripVTControlCharacters(\n buildErrorMessage(failure, [`${failure.name}: ${failure.message}`], false),\n ),\n ];\n\n // Walk the `cause` chain. A gate may wrap a parser failure, and the wrapped\n // message is usually the one naming the actual syntax that was refused.\n let cause = (failure as { cause?: unknown }).cause;\n\n while (cause instanceof Error) {\n lines.push(` Caused by: ${cause.name}: ${stripVTControlCharacters(cause.message)}`);\n cause = (cause as { cause?: unknown }).cause;\n }\n\n return `${lines.join(\"\\n\")}\\n`;\n}\n\n/**\n * DEV-ONLY. Capture the transform/resolve failure that vite is about to throw\n * away, so the request that caused it can answer with it.\n *\n * WHY THIS IS A PLUGIN AND NOT A `middlewares.use(...)` CALL — this is the\n * whole defect, and it is an ordering fact, not a style choice:\n *\n * - Vite mounts its own error handler LAST, built as\n * `errorMiddleware(server, !!middlewareMode)`\n * (`node_modules/vite/dist/node/chunks/config.js:25705`).\n * - In middleware mode that `allowNext` flag is `true`, and the handler then\n * logs the error to the TERMINAL and calls `next()` — with no error\n * (`config.js:9525-9527`).\n * - connect only routes an error to a 4-arity handler while an error is in\n * flight (`config.js:10611-10626`), so `next()` clears it: every layer after\n * that point, INCLUDING the `done` callback `./web-connector.ts` hands the\n * stack, is called as if the request had simply gone unhandled. The\n * framework then answers the only way it can for a URL it does not know — a\n * 404, empty (see {@link DEV_TRANSFORM_ERROR_STATUS} for which of the two\n * produces it).\n * - Anything registered with `vite.middlewares.use(...)` after `createServer()`\n * resolves lands AFTER that handler and is therefore unreachable. A\n * `configureServer` POST hook does not: vite runs post hooks at\n * `config.js:25700`, five lines BEFORE it mounts its error handler.\n *\n * So this sits between the failure and vite's logger. It captures, then calls\n * `next(error)` and lets vite's own handler run exactly as before — the\n * terminal message and the `hot.send({ type: \"error\" })` overlay push\n * (`config.js:9511-9521`) are unchanged. This transport ADDS a reader; it\n * replaces nothing.\n *\n * @param isProductionRuntime the connector's own hosting-mode signal\n * (`./web-connector.ts:122`) — passed in rather than re-derived so there\n * is one definition of \"this process is Vite-hosted\", not two.\n */\nexport function devErrorTransportPlugin(options: {\n isProductionRuntime: () => boolean;\n buildErrorMessage: BuildErrorMessage;\n}): Plugin {\n const { isProductionRuntime, buildErrorMessage } = options;\n\n if (isProductionRuntime()) {\n throw new DevErrorTransportInProductionError();\n }\n\n const capture: Connect.ErrorHandleFunction = (error, request, _response, next) => {\n // Re-asserted per request, not just at construction: `runtimeStrategy` is\n // process state and a transport that leaks source frames is not something\n // to hold open on a boot-time reading alone. In production this layer is a\n // pass-through and vite's handler behaves exactly as it does today.\n if (isProductionRuntime()) return next(error);\n\n (request as DevTransformErrorCarrier)[DEV_TRANSFORM_ERROR_BODY] = formatDevTransformError(\n error,\n buildErrorMessage,\n );\n\n next(error);\n };\n\n return {\n name: \"warlock:dev-error-transport\",\n // Belt to the `isProductionRuntime` braces: this plugin has no business in\n // a `vite build` graph either.\n apply: \"serve\",\n configureServer(server) {\n // RETURNING a function is what makes this a POST hook — the ordering the\n // note above depends on. Mounting inline here would land the layer BEFORE\n // vite's transform middleware, where no error has been thrown yet.\n return () => {\n server.middlewares.use(capture);\n };\n },\n };\n}\n\n/**\n * Answer the request with the failure {@link devErrorTransportPlugin} captured,\n * if there was one. Returns `false` when there was not, which is the normal\n * case and means \"carry on down the framework's own path\".\n *\n * Called from the Fastify `onRequest` hook that mounts vite\n * (`./web-connector.ts:321`), in the `done` callback — i.e. at the one moment\n * where connect has finished, vite has declined to answer, and the framework is\n * about to 404. Writing to the raw `ServerResponse` rather than through Fastify\n * is what the mount already does for every response vite serves, so this stays\n * on the same side of the seam.\n */\nexport function sendCapturedDevError(request: IncomingMessage, response: ServerResponse): boolean {\n const body = (request as DevTransformErrorCarrier)[DEV_TRANSFORM_ERROR_BODY];\n\n if (typeof body !== \"string\") return false;\n\n // A middleware further down may have answered already (vite serves plenty of\n // requests itself). Never write twice; the captured body is then just dropped.\n if (response.headersSent || response.writableEnded) return false;\n\n response.statusCode = DEV_TRANSFORM_ERROR_STATUS;\n response.setHeader(\"content-type\", \"text/plain; charset=utf-8\");\n // A refusal is a fact about the CURRENT source. Caching it would survive the\n // edit that fixes it.\n response.setHeader(\"cache-control\", \"no-store\");\n response.end(body);\n\n return true;\n}\n"],"mappings":";;;;;;;;;;;;;;;AAiBA,MAAa,2BAA2B,OAAO,IAAI,mCAAmC;;;;;;;;;;;;;;;;;AA2BtF,MAAa,6BAA6B;;;;;;;AAmB1C,IAAa,qCAAb,cAAwD,MAAM;CAC5D,AAAO,cAAc;EACnB,MACE,uSAIF;EACA,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,wBACd,OACA,mBACQ;CACR,MAAM,UACJ,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,UAAU,WAAW,QAAQ,OAAO,KAAK,CAAC;CAE9F,MAAM,QAAQ,CACZ,yBACE,kBAAkB,SAAS,CAAC,GAAG,QAAQ,KAAK,IAAI,QAAQ,SAAS,GAAG,KAAK,CAC3E,CACF;CAIA,IAAI,QAAS,QAAgC;CAE7C,OAAO,iBAAiB,OAAO;EAC7B,MAAM,KAAK,gBAAgB,MAAM,KAAK,IAAI,yBAAyB,MAAM,OAAO,GAAG;EACnF,QAAS,MAA8B;CACzC;CAEA,OAAO,GAAG,MAAM,KAAK,IAAI,EAAE;AAC7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,SAAgB,wBAAwB,SAG7B;CACT,MAAM,EAAE,qBAAqB,sBAAsB;CAEnD,IAAI,oBAAoB,GACtB,MAAM,IAAI,mCAAmC;CAG/C,MAAM,WAAwC,OAAO,SAAS,WAAW,SAAS;EAKhF,IAAI,oBAAoB,GAAG,OAAO,KAAK,KAAK;EAE5C,AAAC,QAAqC,4BAA4B,wBAChE,OACA,iBACF;EAEA,KAAK,KAAK;CACZ;CAEA,OAAO;EACL,MAAM;EAGN,OAAO;EACP,gBAAgB,QAAQ;GAItB,aAAa;IACX,OAAO,YAAY,IAAI,OAAO;GAChC;EACF;CACF;AACF;;;;;;;;;;;;;AAcA,SAAgB,qBAAqB,SAA0B,UAAmC;CAChG,MAAM,OAAQ,QAAqC;CAEnD,IAAI,OAAO,SAAS,UAAU,OAAO;CAIrC,IAAI,SAAS,eAAe,SAAS,eAAe,OAAO;CAE3D,SAAS;CACT,SAAS,UAAU,gBAAgB,2BAA2B;CAG9D,SAAS,UAAU,iBAAiB,UAAU;CAC9C,SAAS,IAAI,IAAI;CAEjB,OAAO;AACT"}
|
|
File without changes
|