@ilha/router 0.10.4 → 0.11.1
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/README.md +233 -1010
- package/dist/als-key-CVxbuM3z.js +5 -0
- package/dist/als-key.d.ts +1 -0
- package/dist/codegen.d.ts +0 -2
- package/dist/{snapshot-CsEaY6h_.js → head-DpYV3bHH.js} +5 -57
- package/dist/index.d.ts +58 -340
- package/dist/index.js +700 -3
- package/dist/{plugin-BQil-7B_.js → plugin-C3AAI3e_.js} +67 -193
- package/dist/plugin.d.ts +0 -10
- package/dist/request-scope.d.ts +2 -1
- package/dist/rsbuild.js +1 -1
- package/dist/server-island.d.ts +0 -3
- package/dist/server-island.js +7 -8
- package/dist/server-islands.d.ts +0 -3
- package/dist/snapshot-C0E2OGwL.js +55 -0
- package/dist/snapshot.d.ts +1 -0
- package/dist/{ssr-BTmqfAp9.js → ssr-WA3bSsCo.js} +146 -215
- package/dist/ssr.d.ts +29 -64
- package/dist/ssr.js +2 -2
- package/dist/vite.js +1 -1
- package/oxlint.cjs +106 -240
- package/package.json +5 -7
- package/dist/server.d.ts +0 -8
- package/dist/server.js +0 -14
- package/dist/src-PCXNZfql.js +0 -2161
package/README.md
CHANGED
|
@@ -1,1154 +1,377 @@
|
|
|
1
1
|
# `@ilha/router`
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A tiny, isomorphic SPA router for [Ilha](https://github.com/ilhajs/ilha) islands. You register routes (or scan a `src/pages/` directory), mount the router in the browser, and render the same routes to HTML on the server. Server islands re-render through a guarded frame endpoint.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install @ilha/router
|
|
11
|
-
# or Bun
|
|
12
10
|
bun add @ilha/router
|
|
13
11
|
```
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
## Quick Start
|
|
18
|
-
|
|
19
|
-
### Client-side
|
|
20
|
-
|
|
21
|
-
```ts
|
|
22
|
-
import { router } from "@ilha/router";
|
|
23
|
-
import { homePage, aboutPage, userPage, notFound } from "./pages";
|
|
24
|
-
|
|
25
|
-
router()
|
|
26
|
-
.route("/", homePage)
|
|
27
|
-
.route("/about", aboutPage)
|
|
28
|
-
.route("/user/:id", userPage)
|
|
29
|
-
.route("/**", notFound)
|
|
30
|
-
.mount("#app");
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
### Server-side (SSR)
|
|
34
|
-
|
|
35
|
-
```ts
|
|
36
|
-
import { router } from "@ilha/router";
|
|
37
|
-
import { homePage, aboutPage, userPage, notFound } from "./pages";
|
|
38
|
-
|
|
39
|
-
export default {
|
|
40
|
-
fetch(request: Request) {
|
|
41
|
-
const html = router()
|
|
42
|
-
.route("/", homePage)
|
|
43
|
-
.route("/about", aboutPage)
|
|
44
|
-
.route("/user/:id", userPage)
|
|
45
|
-
.route("/**", notFound)
|
|
46
|
-
.render(request.url);
|
|
47
|
-
return new Response(`<!doctype html><html><body>${html}</body></html>`, {
|
|
48
|
-
headers: { "content-type": "text/html" },
|
|
49
|
-
});
|
|
50
|
-
},
|
|
51
|
-
};
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
### SSR + Client Hydration (recommended)
|
|
55
|
-
|
|
56
|
-
```ts
|
|
57
|
-
// routes/[...].ts — Oxide handler (SSR/prerender)
|
|
58
|
-
import { pageRouter, registry } from "ilha:pages/server";
|
|
59
|
-
import "ilha:loaders"; // ← wire server-only loaders
|
|
60
|
-
|
|
61
|
-
export default {
|
|
62
|
-
async fetch(request: Request) {
|
|
63
|
-
const html = await pageRouter.renderHydratable(request.url, registry);
|
|
64
|
-
return new Response(`<!doctype html><html><body>${html}</body></html>`, {
|
|
65
|
-
headers: { "content-type": "text/html" },
|
|
66
|
-
});
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
```ts
|
|
72
|
-
// src/client.ts — browser entry
|
|
73
|
-
import { pageRouter, registry } from "ilha:pages/client";
|
|
74
|
-
|
|
75
|
-
pageRouter.hydrate(registry);
|
|
76
|
-
```
|
|
13
|
+
`ilha` is a peer dependency. For server islands you also need [`oxidejs`](https://npmjs.com/package/oxidejs).
|
|
77
14
|
|
|
78
15
|
---
|
|
79
16
|
|
|
80
|
-
##
|
|
81
|
-
|
|
82
|
-
By default, the router uses the HTML5 History API and treats `location.pathname` as the route. This requires either a server that serves the SPA shell at every URL, or a static host with a SPA fallback. When neither is available — the document is loaded over `file://`, embedded in a desktop wrapper like Electron or Electrobun, opened directly from disk, or served from a host that can't be configured for SPA fallbacks — switch to **hash mode**, which stores the route in `location.hash`:
|
|
17
|
+
## Import paths
|
|
83
18
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
router
|
|
90
|
-
|
|
19
|
+
| Import path | Use it for |
|
|
20
|
+
| ---------------------------- | ------------------------------------------------- |
|
|
21
|
+
| `@ilha/router` | Runtime router, navigation, `head`, route hooks |
|
|
22
|
+
| `@ilha/router/vite` | Vite file-system routing plugin (`pages()`) |
|
|
23
|
+
| `@ilha/router/rsbuild` | Rsbuild file-system routing plugin (`pages()`) |
|
|
24
|
+
| `@ilha/router/server-island` | Client proxies for `*.server` modules (generated) |
|
|
25
|
+
| `@ilha/router/ssr` | `POST /__ilha/frame` middleware and frame guards |
|
|
91
26
|
|
|
92
|
-
|
|
27
|
+
There is no loader API. Fetch data inside an async component, or stream from a server module.
|
|
93
28
|
|
|
94
|
-
|
|
29
|
+
---
|
|
95
30
|
|
|
96
|
-
|
|
97
|
-
file:///path/to/index.html#/
|
|
98
|
-
file:///path/to/index.html#/about
|
|
99
|
-
file:///path/to/index.html#/user/42?tab=overview
|
|
100
|
-
file:///path/to/index.html#/docs/intro#section
|
|
101
|
-
```
|
|
31
|
+
## Quick start
|
|
102
32
|
|
|
103
|
-
|
|
33
|
+
### Client SPA
|
|
104
34
|
|
|
105
|
-
|
|
35
|
+
```tsx
|
|
36
|
+
import { router } from "@ilha/router";
|
|
106
37
|
|
|
107
|
-
|
|
38
|
+
const HomePage = () => <p>home</p>;
|
|
39
|
+
const AboutPage = () => <p>about</p>;
|
|
40
|
+
const NotFound = () => <p>not found</p>;
|
|
108
41
|
|
|
109
|
-
|
|
110
|
-
<a href="/about">About</a>
|
|
111
|
-
<!-- plain path — preferred for shared code -->
|
|
112
|
-
<a href="#/about">About</a>
|
|
113
|
-
<!-- explicit hash form — also intercepted -->
|
|
42
|
+
router().route("/", HomePage).route("/about", AboutPage).route("/**", NotFound).mount("#app");
|
|
114
43
|
```
|
|
115
44
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
In-page anchor links (`<a href="#section">`) are not intercepted — they behave as normal browser anchors. Only links beginning with `#/` (a slash after the hash) are treated as in-app navigations.
|
|
45
|
+
A mounted SPA router intercepts same-origin `<a>` clicks. Use ordinary links for navigation; call `navigate()` after application logic.
|
|
119
46
|
|
|
120
|
-
###
|
|
121
|
-
|
|
122
|
-
**SSR + hydration.** The hash is never sent to the server, so it cannot pre-render the active route. Calling `mount({ hydrate: true })` or `.hydrate(registry)` while in hash mode logs a warning. Use plain SPA mode for hash-mode apps:
|
|
47
|
+
### Server HTML
|
|
123
48
|
|
|
124
49
|
```ts
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
You can still register loaders, but they run on the client (via the loader endpoint or by calling `runLoader()` yourself) — there is no server-rendered initial state.
|
|
130
|
-
|
|
131
|
-
**Per-router mode.** History mode is process-global, not per-builder. This is intentional: `navigate()`, `RouterLink`, and `prefetch()` are module-level and would otherwise need explicit instance threading. If your app needs both modes simultaneously, that's not a use case this router supports.
|
|
132
|
-
|
|
133
|
-
### Switching modes
|
|
134
|
-
|
|
135
|
-
`setHistoryMode()` can be called more than once, but listeners registered before a switch keep using their original adapter until the router is unmounted and remounted. In practice, set the mode once at app entry and leave it alone.
|
|
136
|
-
|
|
137
|
-
---
|
|
138
|
-
|
|
139
|
-
## Core API
|
|
140
|
-
|
|
141
|
-
### `router(options?)`
|
|
142
|
-
|
|
143
|
-
Creates a new router instance and **resets the route registry**. Always call `router()` fresh — never share instances across server requests.
|
|
144
|
-
|
|
145
|
-
| Option | Type | Default | Description |
|
|
146
|
-
| ------------------------ | ------------------- | ------- | ----------------------------------------------------------------------------------------- |
|
|
147
|
-
| `mode` | `"spa" \| "static"` | `"spa"` | `"static"` disables client navigation — hydrate with `hydrateStatic()` |
|
|
148
|
-
| `interceptLinks` | `boolean` | `true` | Intercept internal `<a>` clicks for SPA navigation |
|
|
149
|
-
| `notFound` | `Island` | — | Custom 404 island (SSR status 404; mounted with a full lifecycle in the browser) |
|
|
150
|
-
| `allowExternalRedirects` | `boolean` | `false` | Allow loader `redirect()` to cross-origin URLs; blocked targets become a 500 error |
|
|
151
|
-
| `loaderTimeout` | `number` | — | Abort + fail a loader after this many ms (enforced even if the loader ignores its signal) |
|
|
152
|
-
| `viewTransitions` | `boolean` | `false` | Wrap client view swaps in `document.startViewTransition()` when supported |
|
|
153
|
-
|
|
154
|
-
Returns a `RouterBuilder`.
|
|
155
|
-
|
|
156
|
-
---
|
|
157
|
-
|
|
158
|
-
#### `.route(pattern, island, loader?)`
|
|
50
|
+
import { router } from "@ilha/router";
|
|
51
|
+
import { httpResponse } from "@ilha/router";
|
|
159
52
|
|
|
160
|
-
|
|
53
|
+
const app = router().route("/", HomePage).route("/**", NotFound);
|
|
161
54
|
|
|
162
|
-
|
|
55
|
+
const html = await app.render(new Request("https://app.test/"));
|
|
56
|
+
return httpResponse(html);
|
|
57
|
+
```
|
|
163
58
|
|
|
164
|
-
|
|
59
|
+
### SSR + hydration (recommended)
|
|
165
60
|
|
|
166
61
|
```ts
|
|
167
|
-
|
|
62
|
+
// server
|
|
63
|
+
const app = router().route("/", HomePage).route("/**", NotFound);
|
|
168
64
|
|
|
169
|
-
const
|
|
170
|
-
|
|
65
|
+
const res = await app.respond(new Request(request.url), {
|
|
66
|
+
shell: (head, html) =>
|
|
67
|
+
`<!doctype html><html${head.htmlAttrs}><head>${head.headTags}</head><body${head.bodyAttrs}>${html}</body></html>`,
|
|
171
68
|
});
|
|
172
69
|
|
|
173
|
-
|
|
70
|
+
// client
|
|
71
|
+
router().route("/", HomePage).route("/**", NotFound).mount("#app", { hydrate: true });
|
|
174
72
|
```
|
|
175
73
|
|
|
176
|
-
|
|
177
|
-
| --------------- | ------------------- | --------------------------------- |
|
|
178
|
-
| `/` | `/` | `{}` |
|
|
179
|
-
| `/about` | `/about` | `{}` |
|
|
180
|
-
| `/user/:id` | `/user/42` | `{ id: "42" }` |
|
|
181
|
-
| `/:org/:repo` | `/ilha/router` | `{ org: "ilha", repo: "router" }` |
|
|
182
|
-
| `/docs/**:slug` | `/docs/guide/intro` | `{ slug: "guide/intro" }` |
|
|
183
|
-
| `/**` | anything | `{}` |
|
|
184
|
-
|
|
185
|
-
> Static segments take priority over `:param` segments — `/user/me` will match before `/user/:id`.
|
|
186
|
-
|
|
187
|
-
Returns the same `RouterBuilder` for chaining.
|
|
74
|
+
`respond()` renders the route, injects the serialized `<head>` into your shell, and emits security headers. On the client, `{ hydrate: true }` preserves the SSR DOM, seeds state from snapshots, and re-renders with hydration on later navigations.
|
|
188
75
|
|
|
189
76
|
---
|
|
190
77
|
|
|
191
|
-
|
|
78
|
+
## Hash mode
|
|
192
79
|
|
|
193
|
-
|
|
80
|
+
The router uses the HTML5 History API by default. When you serve from `file://` (Electron, Electrobun, static disk) or a host without SPA fallbacks, switch to hash mode:
|
|
194
81
|
|
|
195
82
|
```ts
|
|
196
|
-
|
|
83
|
+
import { setHistoryMode } from "@ilha/router";
|
|
197
84
|
|
|
198
|
-
//
|
|
199
|
-
unmount();
|
|
85
|
+
setHistoryMode("hash"); // call once, before .mount() or .hydrate()
|
|
200
86
|
```
|
|
201
87
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
| Option | Type | Default | Description |
|
|
205
|
-
| ---------- | ------------------------ | ----------- | ---------------------------------------------------------- |
|
|
206
|
-
| `hydrate` | `boolean` | `false` | Preserve SSR DOM on first mount (no destructive re-render) |
|
|
207
|
-
| `registry` | `Record<string, Island>` | `undefined` | Island registry for interactive hydration on navigation |
|
|
208
|
-
|
|
209
|
-
When `hydrate: true`, `.mount()` does **not** wipe existing SSR HTML. It instead mounts a hidden navigation handler that re-renders routes with hydration on subsequent navigations.
|
|
88
|
+
Routes live in `location.hash` (`/#/user/42`). `navigate()`, link interception, and `isActive()` all operate against the hash. Links render the hash form automatically, so right-click → copy link works.
|
|
210
89
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
No-op with a console warning when called outside a browser environment.
|
|
90
|
+
SSR + hydration is not supported in hash mode — the server cannot see hash routes.
|
|
214
91
|
|
|
215
92
|
---
|
|
216
93
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
Resolves the given URL against the route registry and returns a synchronous HTML string. Accepts a path string, full URL string, or `URL` object. Populates all route signals identically to the browser.
|
|
220
|
-
|
|
221
|
-
```ts
|
|
222
|
-
const html = router().route("/", HomePage).route("/**", notFound).render("/");
|
|
223
|
-
// → '<div data-router-view><p>home</p></div>'
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
Renders `<div data-router-empty></div>` when no route matches.
|
|
227
|
-
|
|
228
|
-
---
|
|
229
|
-
|
|
230
|
-
#### `.renderHydratable(urlOrRequest, registry, options?, request?)` — server / SSR
|
|
94
|
+
## Core API
|
|
231
95
|
|
|
232
|
-
|
|
96
|
+
### `router(options?)`
|
|
233
97
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
98
|
+
| Option | Meaning |
|
|
99
|
+
| ------------------------ | --------------------------------------------------- |
|
|
100
|
+
| `mode` | `"spa"` (default) or `"static"` (registry only) |
|
|
101
|
+
| `notFound` | Component for unmatched paths |
|
|
102
|
+
| `interceptLinks` | Intercept same-origin `<a>` clicks (default `true`) |
|
|
103
|
+
| `viewTransitions` | Wrap navigations in the View Transition API |
|
|
104
|
+
| `allowExternalRedirects` | Allow cross-origin redirects (default `false`) |
|
|
105
|
+
|
|
106
|
+
### Builder
|
|
107
|
+
|
|
108
|
+
| Method | Purpose |
|
|
109
|
+
| ---------------------------------------------- | --------------------------------------- |
|
|
110
|
+
| `route(pattern, page)` | Register a URL pattern |
|
|
111
|
+
| `errorBoundary(pattern, handler)` | Catch failures for a pattern |
|
|
112
|
+
| `routes()` | The route records |
|
|
113
|
+
| `prime()` | Prime route signals (browser) |
|
|
114
|
+
| `mount(target, { hydrate?, interceptLinks? })` | Activate in the browser |
|
|
115
|
+
| `render(url)` | HTML string (server) |
|
|
116
|
+
| `renderResponse(url)` | `RenderResponse` discriminated union |
|
|
117
|
+
| `respond(url, options?)` | `Response` with head + security headers |
|
|
118
|
+
| `hydrate({ root?, interceptLinks? })` | Hydrate SSR markup, then navigate |
|
|
119
|
+
|
|
120
|
+
`renderResponse()` resolves to `{ kind: "html", html, status?, head? }`, `{ kind: "redirect", to, status }`, or `{ kind: "error", status, message, html, head? }`.
|
|
121
|
+
|
|
122
|
+
`respond()` options: `status`, `headers`, `cspNonce`, `contentSecurityPolicy`, `timeout`, `snapshot`, `markers`, and `shell(head, html)` to inject the serialized head into your document shell.
|
|
123
|
+
|
|
124
|
+
### Route patterns
|
|
125
|
+
|
|
126
|
+
| Pattern | Example URL | Params |
|
|
127
|
+
| --------------- | ------------------- | ------------------------- |
|
|
128
|
+
| `/` | `/` | `{}` |
|
|
129
|
+
| `/user/:id` | `/user/42` | `{ id: "42" }` |
|
|
130
|
+
| `/:org/:repo` | `/ilha/router` | `{ org, repo }` |
|
|
131
|
+
| `/docs/**:slug` | `/docs/guide/intro` | `{ slug: "guide/intro" }` |
|
|
132
|
+
| `/**` | any unmatched path | `{}` |
|
|
133
|
+
|
|
134
|
+
Static segments win over parameters, then catch-alls.
|
|
135
|
+
|
|
136
|
+
### Route context
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
import { useRoute, navigate, isActive } from "@ilha/router";
|
|
140
|
+
|
|
141
|
+
const Breadcrumb = () => {
|
|
142
|
+
const { path, params, search } = useRoute();
|
|
143
|
+
if (!isActive("/user/*")) return <span>{path()}</span>;
|
|
144
|
+
return (
|
|
145
|
+
<span>
|
|
146
|
+
{path()} · {params().id}
|
|
147
|
+
</span>
|
|
148
|
+
);
|
|
149
|
+
};
|
|
237
150
|
```
|
|
238
151
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
|
248
|
-
|
|
|
249
|
-
| `snapshot` | `boolean` | `false` | Embed island state as `data-ilha-state` for hydration |
|
|
250
|
-
|
|
251
|
-
---
|
|
152
|
+
| Export | Meaning |
|
|
153
|
+
| ----------------------------------------------------------------- | ------------------------------------------------------ |
|
|
154
|
+
| `useRoute()` | `{ path, params, search, hash, navigating }` accessors |
|
|
155
|
+
| `routePath()` / `routeParams()` / `routeSearch()` / `routeHash()` | Standalone accessors |
|
|
156
|
+
| `navigate(to, { replace?, scroll? })` | Programmatic navigation |
|
|
157
|
+
| `navigating()` | True while a navigation is in flight |
|
|
158
|
+
| `isActive(pattern, { end? })` | True when the current path matches |
|
|
159
|
+
| `beforeNavigate(fn)` / `afterNavigate(fn)` | Navigation hooks (can cancel) |
|
|
160
|
+
| `useContext()` | `{ request }` during SSR |
|
|
161
|
+
| `enableLinkInterception(root?)` | Manual link interception |
|
|
252
162
|
|
|
253
|
-
|
|
163
|
+
### Head
|
|
254
164
|
|
|
255
|
-
|
|
165
|
+
```tsx
|
|
166
|
+
import { head } from "@ilha/router";
|
|
256
167
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
.renderResponse("/protected", registry);
|
|
261
|
-
|
|
262
|
-
if (res.kind === "redirect") {
|
|
263
|
-
return Response.redirect(res.to, res.status);
|
|
168
|
+
export default function About() {
|
|
169
|
+
head({ title: "About" });
|
|
170
|
+
return <h1>About</h1>;
|
|
264
171
|
}
|
|
265
|
-
if (res.kind === "error") {
|
|
266
|
-
return new Response(res.html, { status: res.status });
|
|
267
|
-
}
|
|
268
|
-
return new Response(res.html, { headers: { "content-type": "text/html" } });
|
|
269
172
|
```
|
|
270
173
|
|
|
271
|
-
|
|
272
|
-
| ------------ | --------------------------------------------------- | ------------------------------------------ |
|
|
273
|
-
| `"html"` | `html: string`, `status?: number` | Normal render; `status` is 404 if no match |
|
|
274
|
-
| `"redirect"` | `to: string`, `status: number` | Loader called `redirect()` |
|
|
275
|
-
| `"error"` | `status: number`, `message: string`, `html: string` | Loader called `error()` or threw |
|
|
174
|
+
`HeadInput` fields: `title`, `titleTemplate`, `meta`, `link`, `script`, `htmlAttrs`, `bodyAttrs`. Call `head()` inside a page or layout; during SSR entries collect into the render window and `serializeHead()` turns them into shell fragments. On the client, entries apply to `document` on navigation.
|
|
276
175
|
|
|
277
|
-
|
|
176
|
+
### Pages, layouts, and errors
|
|
278
177
|
|
|
279
|
-
|
|
178
|
+
```tsx
|
|
179
|
+
import { defineLayout, wrapError, error, redirect } from "@ilha/router";
|
|
280
180
|
|
|
281
|
-
|
|
282
|
-
const response = await router()
|
|
283
|
-
.route("/", HomePage)
|
|
284
|
-
.respond(new Request(request.url), registry, {
|
|
285
|
-
cspNonce,
|
|
286
|
-
shell: (head, html) =>
|
|
287
|
-
`<!doctype html><html lang="en"><head>${head.headTags}</head><body>${html}</body></html>`,
|
|
288
|
-
});
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
---
|
|
292
|
-
|
|
293
|
-
#### `.runLoader(urlOrRequest, request?)` — server / SSR
|
|
294
|
-
|
|
295
|
-
Runs the loader chain for the matched route without rendering any HTML. Returns a discriminated union result. Used by the `/__ilha/loader` endpoint the Vite plugin exposes for client-side navigation — the originating `Request` (cookies, identity, abort signal) is forwarded to the loader through both the endpoint and this method.
|
|
296
|
-
|
|
297
|
-
```ts
|
|
298
|
-
const result = await router().route("/user/:id", userPage, userLoader).runLoader("/user/42");
|
|
299
|
-
|
|
300
|
-
if (result.kind === "data") {
|
|
301
|
-
console.log(result.data); // → { user: { id: "42" } }
|
|
302
|
-
}
|
|
181
|
+
export default defineLayout(({ children }) => <main>{children}</main>);
|
|
303
182
|
```
|
|
304
183
|
|
|
305
|
-
|
|
|
306
|
-
|
|
|
307
|
-
| `
|
|
308
|
-
| `
|
|
309
|
-
| `
|
|
310
|
-
| `
|
|
184
|
+
| Export | Purpose |
|
|
185
|
+
| ------------------------------------------------------------------------------ | ----------------------------------------------- |
|
|
186
|
+
| `wrapLayout(layout, page)` | Wrap a page in a layout (`children` carries it) |
|
|
187
|
+
| `wrapError(handler, page)` | Catch page throws, render a fallback view |
|
|
188
|
+
| `defineLayout(layout)` | Type helper for layout components |
|
|
189
|
+
| `redirect(to, status?)` | Throw `Redirect` — the router navigates |
|
|
190
|
+
| `error(status, message)` | Throw `RouteError` — a boundary catches it |
|
|
191
|
+
| `httpResponse(html, { status?, headers?, cspNonce?, contentSecurityPolicy? })` | Headered `Response` |
|
|
311
192
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
#### `.prime()` — browser only
|
|
315
|
-
|
|
316
|
-
Primes route context signals from the current `window.location` **before** `ilha.mount()` runs. This prevents a signal mismatch that would destroy hydrated bindings.
|
|
317
|
-
|
|
318
|
-
Call this after all routes are registered and before mounting islands for interactivity:
|
|
319
|
-
|
|
320
|
-
```ts
|
|
321
|
-
import { mount } from "ilha";
|
|
322
|
-
import { pageRouter } from "ilha:pages";
|
|
323
|
-
import { registry } from "ilha:registry";
|
|
324
|
-
|
|
325
|
-
pageRouter.prime(); // ← sync signals first
|
|
326
|
-
mount(registry, { root: … }); // ← then hydrate islands
|
|
327
|
-
pageRouter.mount("#app", { hydrate: true, registry });
|
|
328
|
-
```
|
|
193
|
+
An error handler receives `AppError` (`message`, `status?`) and a route snapshot, and returns a view or a component.
|
|
329
194
|
|
|
330
195
|
---
|
|
331
196
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
Convenience method that combines `.prime()`, `ilha.mount()`, and `.mount()` into a single call. **This is the recommended client entry point for SPA apps.**
|
|
197
|
+
## File-system routing
|
|
335
198
|
|
|
336
199
|
```ts
|
|
337
|
-
|
|
200
|
+
// vite.config.ts
|
|
201
|
+
import pages from "@ilha/router/vite";
|
|
202
|
+
import { defineConfig } from "vite";
|
|
338
203
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
root: document.getElementById("root"), // defaults to document.body
|
|
342
|
-
target: "#app", // defaults to root
|
|
204
|
+
export default defineConfig({
|
|
205
|
+
plugins: [pages()],
|
|
343
206
|
});
|
|
344
207
|
```
|
|
345
208
|
|
|
346
|
-
|
|
209
|
+
| File | URL |
|
|
210
|
+
| --------------------- | -------------- |
|
|
211
|
+
| `pages/index.tsx` | `/` |
|
|
212
|
+
| `pages/about.tsx` | `/about` |
|
|
213
|
+
| `pages/user/[id].tsx` | `/user/:id` |
|
|
214
|
+
| `pages/[...slug].tsx` | `/**:slug` |
|
|
215
|
+
| `pages/+layout.tsx` | wraps children |
|
|
216
|
+
| `pages/+error.tsx` | error boundary |
|
|
347
217
|
|
|
348
|
-
|
|
218
|
+
Page modules export a default component, and may call `head()`. Layouts receive `children`.
|
|
349
219
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
#### `.hydrateStatic(registry, options?)` — browser only
|
|
353
|
-
|
|
354
|
-
The lightest client entry point. Calls `prime()` then `ilha.mount()` — no route view is mounted, no navigation handler is installed, and no route graph is touched. Use this in `static` mode where each page is a self-contained pre-rendered HTML file.
|
|
220
|
+
### Plugin options
|
|
355
221
|
|
|
356
222
|
```ts
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
223
|
+
pages({
|
|
224
|
+
dir: "src/pages", // pages directory (default: "src/pages")
|
|
225
|
+
outDir: ".ilha", // generated modules (default: ".ilha")
|
|
226
|
+
mode: "spa", // "spa" | "static" (default: "spa")
|
|
227
|
+
interceptLinks: true,
|
|
228
|
+
frameGuard: (request) => {
|
|
229
|
+
/* dev frame guard */
|
|
230
|
+
},
|
|
231
|
+
trustedOrigins: ["https://app.example.com"],
|
|
232
|
+
csrf: (request) => true,
|
|
233
|
+
strict: false, // fail codegen on collisions instead of warning
|
|
362
234
|
});
|
|
363
235
|
```
|
|
364
236
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
#### `.attachLoader(pattern, loader)` — runtime
|
|
370
|
-
|
|
371
|
-
Attaches or replaces a loader on an already-registered route pattern. No-op if the pattern was never registered via `.route()`. Used by the `ilha:loaders` virtual module to wire server-only loaders onto the client-safe `pageRouter` at SSR time.
|
|
372
|
-
|
|
373
|
-
```ts
|
|
374
|
-
router().route("/user/:id", userPage).attachLoader("/user/:id", serverLoader);
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
---
|
|
378
|
-
|
|
379
|
-
#### `.clientLoader(pattern, loader)` — runtime
|
|
380
|
-
|
|
381
|
-
Attaches a loader that runs **in the browser** on client navigations, instead of fetching from the `/__ilha/loader` endpoint. Used by the FS-routing codegen for `clientLoad` exports; also available for manual routers. When a route has both a server loader and a client loader, the client loader wins on client navigations and the server loader runs during SSR. No-op (with a warning) if the pattern was never registered via `.route()`.
|
|
382
|
-
|
|
383
|
-
```ts
|
|
384
|
-
router()
|
|
385
|
-
.route("/dashboard", dashboardPage)
|
|
386
|
-
.clientLoader(
|
|
387
|
-
"/dashboard",
|
|
388
|
-
loader(async ({ signal }) => ({ stats: await fetchStats({ signal }) })),
|
|
389
|
-
);
|
|
390
|
-
```
|
|
391
|
-
|
|
392
|
-
---
|
|
393
|
-
|
|
394
|
-
#### `.errorBoundary(pattern, handler)` — runtime
|
|
395
|
-
|
|
396
|
-
Attaches the route's `+error` boundary so **loader** errors render through it — on the server (`renderResponse` returns the boundary's HTML with the error status) and on client navigations. Render errors are already handled by `wrapError` inside the island; this closes the gap for errors thrown before rendering starts. The FS-routing codegen wires the nearest `+error.ts` automatically; manual routers can call it directly. A throwing boundary falls back to the minimal inline error.
|
|
397
|
-
|
|
398
|
-
```ts
|
|
399
|
-
import { router } from "@ilha/router";
|
|
400
|
-
import { ilha, html } from "ilha";
|
|
401
|
-
|
|
402
|
-
router()
|
|
403
|
-
.route("/user/:id", userPage, userLoader)
|
|
404
|
-
.errorBoundary("/user/:id", (err, route) =>
|
|
405
|
-
ilha(
|
|
406
|
-
() =>
|
|
407
|
-
html`<h1>${err.status ?? 500}</h1>
|
|
408
|
-
<p>${err.message}</p>`,
|
|
409
|
-
),
|
|
410
|
-
);
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
---
|
|
414
|
-
|
|
415
|
-
### `setHistoryMode(mode)` · `getHistoryMode()`
|
|
416
|
-
|
|
417
|
-
Selects the history strategy used by the router. Defaults to `"history"` (HTML5 History API, reads/writes `location.pathname`). Set to `"hash"` to store the route in `location.hash` instead — see the [Hash mode](#hash-mode) section above for when to use it.
|
|
237
|
+
- `mode: "spa"` — full client route graph, SSR/hydration, client navigation.
|
|
238
|
+
- `mode: "spa", interceptLinks: false` — route graph and SSR, but links do full document navigations.
|
|
239
|
+
- `mode: "static"` — island registry only; no route graph in the client bundle.
|
|
418
240
|
|
|
419
|
-
|
|
420
|
-
import { setHistoryMode, getHistoryMode } from "@ilha/router";
|
|
421
|
-
|
|
422
|
-
setHistoryMode("hash");
|
|
423
|
-
getHistoryMode(); // → "hash"
|
|
424
|
-
```
|
|
425
|
-
|
|
426
|
-
The mode is process-global. Call `setHistoryMode()` once at app entry, before any `.mount()`, `.hydrate()`, or `prime()` call.
|
|
427
|
-
|
|
428
|
-
---
|
|
429
|
-
|
|
430
|
-
### `navigate(to, options?)`
|
|
431
|
-
|
|
432
|
-
Programmatically navigate to a path. Updates the URL, history stack, and all reactive signals. Duplicate navigations (same URL) are no-ops.
|
|
433
|
-
|
|
434
|
-
```ts
|
|
435
|
-
import { navigate } from "@ilha/router";
|
|
436
|
-
|
|
437
|
-
navigate("/about");
|
|
438
|
-
navigate("/about", { replace: true }); // replaces instead of pushing
|
|
439
|
-
```
|
|
440
|
-
|
|
441
|
-
In hash mode, `navigate("/about")` writes `#/about` into `location.hash`. The argument is always the logical path — no need to prefix it with `#`.
|
|
442
|
-
|
|
443
|
-
No-op on the server.
|
|
444
|
-
|
|
445
|
-
---
|
|
446
|
-
|
|
447
|
-
### `navigating()`
|
|
241
|
+
### Virtual modules
|
|
448
242
|
|
|
449
|
-
|
|
243
|
+
| Module | Exports | Use for |
|
|
244
|
+
| ------------------- | ------------------------ | ------------------------------- |
|
|
245
|
+
| `ilha:pages/server` | `pageRouter`, `registry` | SSR, prerender, server handlers |
|
|
246
|
+
| `ilha:pages/client` | `pageRouter`, `registry` | Browser hydration entry |
|
|
450
247
|
|
|
451
248
|
```ts
|
|
452
|
-
|
|
453
|
-
import {
|
|
249
|
+
// src/client.ts — browser entry
|
|
250
|
+
import { pageRouter } from "ilha:pages/client";
|
|
454
251
|
|
|
455
|
-
|
|
252
|
+
pageRouter.mount("#app");
|
|
456
253
|
```
|
|
457
254
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
### `invalidate()`
|
|
461
|
-
|
|
462
|
-
Re-runs the current route's loader and re-renders the view with fresh data — call it after a mutation. Resolves when the view has updated. No-op on the server or when no router is mounted.
|
|
255
|
+
Hydrate when the host already has SSR markup:
|
|
463
256
|
|
|
464
257
|
```ts
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
await api.deleteItem(id);
|
|
468
|
-
await invalidate(); // current page refetches and re-renders
|
|
258
|
+
pageRouter.mount("#app", { hydrate: true });
|
|
469
259
|
```
|
|
470
260
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
### `prime()`
|
|
474
|
-
|
|
475
|
-
Standalone export of the same signal-priming function available as `.prime()` on the builder. Useful when managing the priming step separately from the router instance.
|
|
261
|
+
Static mode hydrates the island registered for the page — no route graph in the bundle:
|
|
476
262
|
|
|
477
263
|
```ts
|
|
478
|
-
import {
|
|
264
|
+
import { mount } from "ilha";
|
|
265
|
+
import { registry } from "ilha:pages/client";
|
|
479
266
|
|
|
480
|
-
|
|
267
|
+
const host = document.querySelector<HTMLDivElement>("#app")!;
|
|
268
|
+
mount(host, registry["about"]);
|
|
481
269
|
```
|
|
482
270
|
|
|
483
271
|
---
|
|
484
272
|
|
|
485
|
-
|
|
273
|
+
## Server islands
|
|
486
274
|
|
|
487
|
-
|
|
275
|
+
Put a component in a `*.server.ts(x)` module. It renders on the server only; the browser gets a generated proxy that re-renders it through `POST /__ilha/frame`.
|
|
488
276
|
|
|
489
|
-
```
|
|
490
|
-
|
|
277
|
+
```tsx
|
|
278
|
+
// src/lib/tasks.server.tsx
|
|
279
|
+
import * as Stream from "effect/Stream";
|
|
280
|
+
import { action } from "oxidejs";
|
|
491
281
|
|
|
492
|
-
export const
|
|
493
|
-
|
|
494
|
-
return { user };
|
|
282
|
+
export const getTasks = action(async function* () {
|
|
283
|
+
yield [{ id: "1", text: "One" }];
|
|
495
284
|
});
|
|
496
|
-
```
|
|
497
|
-
|
|
498
|
-
Inside a loader, call `redirect()` or `error()` to short-circuit rendering:
|
|
499
|
-
|
|
500
|
-
```ts
|
|
501
|
-
import { loader, redirect, error } from "@ilha/router";
|
|
502
|
-
|
|
503
|
-
export const load = loader(async ({ params }) => {
|
|
504
|
-
const session = await getSession();
|
|
505
|
-
if (!session) redirect("/login", 302);
|
|
506
|
-
const post = await getPost(params.id);
|
|
507
|
-
if (!post) error(404, "Post not found");
|
|
508
|
-
return { post };
|
|
509
|
-
});
|
|
510
|
-
```
|
|
511
|
-
|
|
512
|
-
Returns `fn` unchanged.
|
|
513
|
-
|
|
514
|
-
---
|
|
515
|
-
|
|
516
|
-
### `redirect(to, status?)`
|
|
517
|
-
|
|
518
|
-
Throws a `Redirect` sentinel that is caught by the loader execution pipeline. Always use inside a loader — do not catch it yourself.
|
|
519
|
-
|
|
520
|
-
```ts
|
|
521
|
-
import { redirect } from "@ilha/router";
|
|
522
|
-
|
|
523
|
-
redirect("/login"); // 302 by default
|
|
524
|
-
redirect("/moved", 301); // permanent redirect
|
|
525
|
-
```
|
|
526
285
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
286
|
+
export const TaskList = async function TaskList() {
|
|
287
|
+
return Stream.map(
|
|
288
|
+
Stream.fromAsyncIterable(getTasks(), (error) =>
|
|
289
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
290
|
+
),
|
|
291
|
+
(list) => (
|
|
292
|
+
<ul>
|
|
293
|
+
{list.map((t) => (
|
|
294
|
+
<li key={t.id}>{t.text}</li>
|
|
295
|
+
))}
|
|
296
|
+
</ul>
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
};
|
|
538
300
|
```
|
|
539
301
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
### `composeLoaders(loaders)`
|
|
543
|
-
|
|
544
|
-
Merges multiple loaders into a single loader. All loaders run **concurrently** via `Promise.all`. Later loaders win on key collision — the page loader overrides a layout loader for the same key.
|
|
302
|
+
Mark RPC functions with `action` from `oxidejs`. Event closures that call those actions serialize into the frame HTML — during hydration-manifest rendering the call is recorded, not executed. Each stream yield refetches the frame and morphs the HTML.
|
|
545
303
|
|
|
546
|
-
|
|
304
|
+
```tsx
|
|
305
|
+
// src/pages/index.tsx — plain page
|
|
306
|
+
import { TaskList } from "../lib/tasks.server";
|
|
547
307
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const layoutLoader = loader(async () => ({ user: await getCurrentUser() }));
|
|
552
|
-
const pageLoader = loader(async ({ params }) => ({ post: await getPost(params.id) }));
|
|
553
|
-
|
|
554
|
-
const combined = composeLoaders([layoutLoader, pageLoader]);
|
|
555
|
-
// → { user: …, post: … }
|
|
308
|
+
export default function Home() {
|
|
309
|
+
return <TaskList />;
|
|
310
|
+
}
|
|
556
311
|
```
|
|
557
312
|
|
|
558
|
-
|
|
313
|
+
The plugin rewrites the client-graph import of `TaskList` to a proxy (`@ilha/router/server-island`). You never import that module yourself.
|
|
559
314
|
|
|
560
315
|
---
|
|
561
316
|
|
|
562
|
-
|
|
317
|
+
## Frame security
|
|
563
318
|
|
|
564
|
-
|
|
319
|
+
The frame endpoint re-renders server islands from a client state snapshot. Island state is world-readable through frames unless you gate them.
|
|
565
320
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
321
|
+
| Concern | How |
|
|
322
|
+
| ------------------ | ---------------------------------------------------------------------------------------- |
|
|
323
|
+
| Production posture | Deny-by-default: `/__ilha/frame` returns `403` until you install a guard |
|
|
324
|
+
| Dev posture | Permissive unless a `frameGuard` is registered (plugin option) |
|
|
325
|
+
| Origin checks | `Origin` compared against `setFrameAuth({ trustedOrigins })` or the request's own `Host` |
|
|
326
|
+
| CSRF | `setFrameAuth({ csrf })` verifier for the state-changing POST |
|
|
327
|
+
| Identity | Only `cookie`, `authorization`, `user-agent` are forwarded to the scoped render |
|
|
328
|
+
| Body cap | 16 KiB; oversized bodies return `413` |
|
|
572
329
|
|
|
573
|
-
|
|
330
|
+
### `@ilha/router/ssr`
|
|
574
331
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
332
|
+
| Export | Purpose |
|
|
333
|
+
| ---------------------------------------------------------- | ------------------------------------------------ |
|
|
334
|
+
| `ssr` (default) | The production frame handler |
|
|
335
|
+
| `setFrameAuth({ defaultAction?, trustedOrigins?, csrf? })` | Install the frame-auth policy |
|
|
336
|
+
| `setFrameGuard(guard)` | Per-request allow/deny |
|
|
337
|
+
| `renderServerIsland(id, request, runWithScope, props?)` | Render one island — `Effect<string, FrameError>` |
|
|
338
|
+
| `renderServerIslandResult(...)` | Promise/`Result` variant for non-Effect callers |
|
|
582
339
|
|
|
583
340
|
```ts
|
|
584
|
-
import {
|
|
585
|
-
import { ilha, html } from "ilha";
|
|
341
|
+
import { setFrameAuth } from "@ilha/router/ssr";
|
|
586
342
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
return html`<p>user id: ${params().id}</p>`;
|
|
343
|
+
setFrameAuth({
|
|
344
|
+
defaultAction: "open", // public demo; deny is the production default
|
|
590
345
|
});
|
|
591
346
|
```
|
|
592
347
|
|
|
593
|
-
---
|
|
594
|
-
|
|
595
|
-
### `routePath` · `routeParams` · `routeSearch` · `routeHash`
|
|
596
|
-
|
|
597
|
-
The underlying context signals — use these outside of islands when you need direct signal access.
|
|
598
|
-
|
|
599
|
-
```ts
|
|
600
|
-
import { routePath, routeParams, routeSearch, routeHash } from "@ilha/router";
|
|
601
|
-
|
|
602
|
-
routePath(); // → "/user/42"
|
|
603
|
-
routeParams(); // → { id: "42" }
|
|
604
|
-
routeSearch(); // → "?tab=docs"
|
|
605
|
-
routeHash(); // → "#section"
|
|
606
|
-
```
|
|
607
|
-
|
|
608
|
-
---
|
|
609
|
-
|
|
610
|
-
### `isActive(pattern)`
|
|
611
|
-
|
|
612
|
-
Returns `true` if the current path matches the given registered pattern. Uses O(1) reverse island lookup internally.
|
|
613
|
-
|
|
614
|
-
```ts
|
|
615
|
-
import { isActive } from "@ilha/router";
|
|
616
|
-
|
|
617
|
-
isActive("/about"); // → true / false
|
|
618
|
-
isActive("/user/:id"); // → true when on any /user/* path
|
|
619
|
-
```
|
|
620
|
-
|
|
621
|
-
---
|
|
622
|
-
|
|
623
|
-
### `enableLinkInterception(root?, options?)`
|
|
624
|
-
|
|
625
|
-
Attaches a delegated click listener to `root` (defaults to `document`) that intercepts `<a>` clicks and routes them client-side. Called automatically by `.mount()`.
|
|
626
|
-
|
|
627
|
-
Skips links that are external, `target="_blank"`, anchor-only (`#hash`), modified (`Ctrl`/`Meta`/`Shift`), or marked with `data-no-intercept`. Also skips events already handled (`e.defaultPrevented`).
|
|
628
|
-
|
|
629
|
-
Returns a cleanup function.
|
|
630
|
-
|
|
631
|
-
```ts
|
|
632
|
-
const stop = enableLinkInterception(myContainer, { prefetch: true });
|
|
633
|
-
stop(); // remove listener
|
|
634
|
-
```
|
|
635
|
-
|
|
636
|
-
**Options:**
|
|
637
|
-
|
|
638
|
-
| Option | Type | Default | Description |
|
|
639
|
-
| ---------- | --------- | ------- | ------------------------------------- |
|
|
640
|
-
| `prefetch` | `boolean` | `true` | Enable prefetch on `mouseenter` hover |
|
|
641
|
-
|
|
642
|
-
No-op on the server.
|
|
643
|
-
|
|
644
|
-
---
|
|
645
|
-
|
|
646
|
-
### `RouterView`
|
|
647
|
-
|
|
648
|
-
The outlet island rendered by `.mount()` and `.render()`. Wraps the active island in `<div data-router-view>`, or renders `<div data-router-empty></div>` when no route matches.
|
|
649
|
-
|
|
650
|
-
```ts
|
|
651
|
-
import { RouterView } from "@ilha/router";
|
|
652
|
-
|
|
653
|
-
RouterView.toString(); // SSR
|
|
654
|
-
RouterView.mount(el); // client
|
|
655
|
-
```
|
|
656
|
-
|
|
657
|
-
---
|
|
658
|
-
|
|
659
|
-
### `RouterLink`
|
|
660
|
-
|
|
661
|
-
A declarative link island that calls `navigate()` on click. Automatically prefetches loader data for the target path on `mouseenter` (opt out per-link with `data-prefetch="false"`).
|
|
662
|
-
|
|
663
348
|
```ts
|
|
664
|
-
import {
|
|
665
|
-
|
|
666
|
-
RouterLink.toString({ href: "/about", label: "About" });
|
|
667
|
-
// → '<a data-link data-prefetch href="/about">About</a>'
|
|
668
|
-
```
|
|
669
|
-
|
|
670
|
-
---
|
|
671
|
-
|
|
672
|
-
### `wrapLayout(layout, page)`
|
|
673
|
-
|
|
674
|
-
Wraps a page island with a layout handler. Used internally by the Vite plugin codegen — also available for manual composition.
|
|
675
|
-
|
|
676
|
-
On client hydration, `wrapLayout` mounts the full layout island (layout child slots `p:*` and the keyed page slot `k:page`) from existing SSR DOM — it does not re-render layout markup from serialized props. Interactive components in `+layout.tsx` (state, event handlers, nested islands) hydrate the same way as the page.
|
|
677
|
-
|
|
678
|
-
**Nested islands under layouts are fully supported.** JSX children and callback props (`setPage`, etc.) are passed through the live slot map on every parent render — not recovered from `data-ilha-props` JSON. Compound children (e.g. Areia `<Pagination>` with `Pagination.Info` / `Controls`) mount into the slot host as real DOM; you do not need `.Static` or to lift controls into the layout.
|
|
679
|
-
|
|
680
|
-
With **nested** layouts (codegen: `wrapLayout(outer, wrapLayout(inner, page))`), `renderHydratable()` composes layout markup by awaiting the **leaf** page’s `hydratable()` envelope and injecting that HTML into each layout’s `k:page` slot (outer slot receives the inner layout tree; the innermost slot receives the page). Page state snapshots always come from the leaf page island.
|
|
681
|
-
|
|
682
|
-
```ts
|
|
683
|
-
import { wrapLayout } from "@ilha/router";
|
|
684
|
-
|
|
685
|
-
const wrapped = wrapLayout(myLayout, myPage);
|
|
686
|
-
```
|
|
687
|
-
|
|
688
|
-
---
|
|
689
|
-
|
|
690
|
-
### `defineLayout(fn)`
|
|
349
|
+
import { setFrameGuard } from "@ilha/router/ssr";
|
|
691
350
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
```ts
|
|
695
|
-
// src/pages/+layout.ts
|
|
696
|
-
import { defineLayout } from "@ilha/router";
|
|
697
|
-
import { ilha, html } from "ilha";
|
|
698
|
-
|
|
699
|
-
export default defineLayout((children) =>
|
|
700
|
-
ilha(
|
|
701
|
-
() => html`
|
|
702
|
-
<nav>
|
|
703
|
-
<a href="/">Home</a>
|
|
704
|
-
<a href="/about">About</a>
|
|
705
|
-
</nav>
|
|
706
|
-
<main>${children}</main>
|
|
707
|
-
`,
|
|
708
|
-
),
|
|
351
|
+
setFrameGuard((request) =>
|
|
352
|
+
isSignedIn(request) ? undefined : new Response("Unauthorized", { status: 401 }),
|
|
709
353
|
);
|
|
710
354
|
```
|
|
711
355
|
|
|
712
|
-
|
|
356
|
+
Frame render failures surface as `"frame failed"` — error details are never leaked to clients in production.
|
|
713
357
|
|
|
714
358
|
---
|
|
715
359
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
Wraps a page island with an error boundary. If the page throws during SSR (`.toString()`) or on the client during `.mount()`, the `handler` receives the error and current route snapshot and returns a fallback island. The nearest (innermost) `wrapError` boundary catches first. If the inner handler re-throws, the next outer boundary takes over.
|
|
719
|
-
|
|
720
|
-
```ts
|
|
721
|
-
import { wrapError } from "@ilha/router";
|
|
722
|
-
|
|
723
|
-
const safe = wrapError(myErrorHandler, myPage);
|
|
724
|
-
```
|
|
725
|
-
|
|
726
|
-
> **Note:** Error boundaries wrap the _page island's render_, not the loader. Loader errors (thrown via `error()`) route through the nearest `+error.ts` / `.errorBoundary()` boundary — on the server `renderResponse` returns the boundary's HTML with the error status, and on client navigations the boundary renders in place. `wrapError` covers render/mount throws inside the island only.
|
|
727
|
-
|
|
728
|
-
---
|
|
360
|
+
## Deployment
|
|
729
361
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
```ts
|
|
733
|
-
interface RouteSnapshot {
|
|
734
|
-
path: string;
|
|
735
|
-
params: Record<string, string>;
|
|
736
|
-
search: string;
|
|
737
|
-
hash: string;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
interface AppError {
|
|
741
|
-
message: string;
|
|
742
|
-
status?: number;
|
|
743
|
-
stack?: string;
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
interface LoaderContext {
|
|
747
|
-
params: Record<string, string>;
|
|
748
|
-
request: Request;
|
|
749
|
-
url: URL;
|
|
750
|
-
signal: AbortSignal;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
type Loader<T> = (ctx: LoaderContext) => Promise<T> | T;
|
|
754
|
-
|
|
755
|
-
// Infer the complete page props produced by a loader
|
|
756
|
-
type InferLoader<L> = L extends Loader<infer T>
|
|
757
|
-
? {
|
|
758
|
-
load: {
|
|
759
|
-
loading: boolean;
|
|
760
|
-
value: Awaited<T>;
|
|
761
|
-
error: Error | undefined;
|
|
762
|
-
};
|
|
763
|
-
}
|
|
764
|
-
: never;
|
|
765
|
-
|
|
766
|
-
// Merge multiple loader return types — later loaders win on key collision
|
|
767
|
-
type MergeLoaders<Ls extends readonly Loader<any>[]> = /* … */;
|
|
768
|
-
|
|
769
|
-
type LayoutHandler = (children: Island) => Island;
|
|
770
|
-
type ErrorHandler = (error: AppError, route: RouteSnapshot) => Island;
|
|
771
|
-
|
|
772
|
-
type RenderResponse =
|
|
773
|
-
| { kind: "html"; html: string; status?: number }
|
|
774
|
-
| { kind: "redirect"; to: string; status: number }
|
|
775
|
-
| { kind: "error"; status: number; message: string; html: string };
|
|
776
|
-
|
|
777
|
-
interface NavigateOptions {
|
|
778
|
-
replace?: boolean;
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
interface MountOptions {
|
|
782
|
-
hydrate?: boolean;
|
|
783
|
-
registry?: Record<string, Island>;
|
|
784
|
-
interceptLinks?: boolean; // default: true
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
interface HydrateOptions {
|
|
788
|
-
root?: Element;
|
|
789
|
-
target?: string | Element;
|
|
790
|
-
interceptLinks?: boolean; // default: true
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
type HistoryMode = "history" | "hash";
|
|
794
|
-
type RouterMode = "spa" | "static";
|
|
795
|
-
|
|
796
|
-
interface RouterOptions {
|
|
797
|
-
mode?: RouterMode; // "spa" | "static", default: "spa"
|
|
798
|
-
interceptLinks?: boolean; // default: true — only meaningful in spa mode
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// Helper — returns fn as-is with LayoutHandler type enforced
|
|
802
|
-
function defineLayout(fn: LayoutHandler): LayoutHandler;
|
|
803
|
-
|
|
804
|
-
// Identity — type anchor and Vite plugin marker
|
|
805
|
-
function loader<T>(fn: Loader<T>): Loader<T>;
|
|
806
|
-
|
|
807
|
-
// Throws a Redirect sentinel — use inside loaders only
|
|
808
|
-
function redirect(to: string, status?: number): never;
|
|
809
|
-
|
|
810
|
-
// Throws a LoaderError sentinel — use inside loaders only
|
|
811
|
-
function error(status: number, message: string): never;
|
|
812
|
-
|
|
813
|
-
// Merges loaders — later loaders win on key collision
|
|
814
|
-
function composeLoaders<Ls extends readonly Loader<any>[]>(loaders: Ls): Loader<MergeLoaders<Ls>>;
|
|
815
|
-
|
|
816
|
-
// Selects the history strategy. Default: "history". Call before .mount() / .hydrate().
|
|
817
|
-
function setHistoryMode(mode: HistoryMode): void;
|
|
818
|
-
function getHistoryMode(): HistoryMode;
|
|
819
|
-
```
|
|
820
|
-
|
|
821
|
-
---
|
|
822
|
-
|
|
823
|
-
## File-system Routing
|
|
824
|
-
|
|
825
|
-
`@ilha/router` includes a Vite plugin that scans `src/pages/`, resolves layout and error boundary chains, and generates a ready-to-use router — no manual route registration needed.
|
|
826
|
-
|
|
827
|
-
### Setup
|
|
362
|
+
With [oxidejs](https://npmjs.com/package/oxidejs), the SSR middleware serves the frame endpoint in production:
|
|
828
363
|
|
|
829
364
|
```ts
|
|
830
365
|
// vite.config.ts
|
|
831
|
-
import
|
|
366
|
+
import pages from "@ilha/router/vite";
|
|
367
|
+
import oxide from "oxidejs/vite";
|
|
832
368
|
|
|
833
369
|
export default defineConfig({
|
|
834
|
-
plugins: [pages()],
|
|
835
|
-
});
|
|
836
|
-
```
|
|
837
|
-
|
|
838
|
-
Add `.ilha/` (or your custom `generated` path) to `.gitignore`.
|
|
839
|
-
|
|
840
|
-
### Directory structure
|
|
841
|
-
|
|
842
|
-
```
|
|
843
|
-
src/pages/
|
|
844
|
-
+layout.ts ← root layout (wraps all pages)
|
|
845
|
-
+error.ts ← root error boundary
|
|
846
|
-
index.ts → /
|
|
847
|
-
about.ts → /about
|
|
848
|
-
(auth)/ ← route group — transparent to the URL
|
|
849
|
-
+layout.ts ← layout scoped to (auth) pages only
|
|
850
|
-
sign-in.ts → /sign-in
|
|
851
|
-
sign-up.ts → /sign-up
|
|
852
|
-
(marketing)/ ← another route group
|
|
853
|
-
index.ts → /
|
|
854
|
-
user/
|
|
855
|
-
+layout.ts ← nested layout (wraps user/* only)
|
|
856
|
-
+error.ts ← nested error boundary
|
|
857
|
-
[id].ts → /user/:id
|
|
858
|
-
[id]/
|
|
859
|
-
settings.ts → /user/:id/settings
|
|
860
|
-
[...slug].ts → /**:slug
|
|
861
|
-
```
|
|
862
|
-
|
|
863
|
-
### Filename → pattern mapping
|
|
864
|
-
|
|
865
|
-
| File | Pattern |
|
|
866
|
-
| ------------------------- | --------------- |
|
|
867
|
-
| `index.ts` | `/` |
|
|
868
|
-
| `about.ts` | `/about` |
|
|
869
|
-
| `[id].ts` | `/:id` |
|
|
870
|
-
| `user/[id].ts` | `/user/:id` |
|
|
871
|
-
| `[org]/[repo].ts` | `/:org/:repo` |
|
|
872
|
-
| `[...slug].ts` | `/**:slug` |
|
|
873
|
-
| `(auth)/sign-in.ts` | `/sign-in` |
|
|
874
|
-
| `(auth)/[token].ts` | `/:token` |
|
|
875
|
-
| `(shop)/products/[id].ts` | `/products/:id` |
|
|
876
|
-
|
|
877
|
-
`.test.ts`, `.spec.ts`, and `.d.ts` files are automatically excluded.
|
|
878
|
-
|
|
879
|
-
### Route groups
|
|
880
|
-
|
|
881
|
-
Folders wrapped in parentheses — `(name)` — are **route groups**. They organise files without contributing a segment to the URL. The group name is completely invisible to the router.
|
|
882
|
-
|
|
883
|
-
```
|
|
884
|
-
src/pages/
|
|
885
|
-
(auth)/
|
|
886
|
-
sign-in.ts → /sign-in ✓ (not /auth/sign-in)
|
|
887
|
-
sign-up.ts → /sign-up ✓
|
|
888
|
-
(marketing)/
|
|
889
|
-
index.ts → / ✓
|
|
890
|
-
pricing.ts → /pricing ✓
|
|
891
|
-
```
|
|
892
|
-
|
|
893
|
-
Route groups are useful for:
|
|
894
|
-
|
|
895
|
-
- **Shared layouts without a shared URL prefix** — place a `+layout.ts` inside `(auth)/` and it wraps only those pages, with no `/auth` prefix in the URL.
|
|
896
|
-
- **Organising large page trees** — split pages into logical sections (`(admin)`, `(public)`, `(shop)`) while keeping flat URLs.
|
|
897
|
-
- **Co-locating related pages** — keep sign-in, sign-up, and password reset together in `(auth)/` for clarity.
|
|
898
|
-
|
|
899
|
-
> Groups can be nested: `(a)/(b)/page.ts` → `/page`. Both group folders are transparent.
|
|
900
|
-
|
|
901
|
-
> If two files in different groups resolve to the **same pattern** (e.g. `(auth)/sign-in.ts` and `sign-in.ts` both produce `/sign-in`), the plugin warns about a duplicate pattern and the first match wins deterministically.
|
|
902
|
-
|
|
903
|
-
### Route sorting
|
|
904
|
-
|
|
905
|
-
Routes are sorted automatically by specificity — no need to order files manually:
|
|
906
|
-
|
|
907
|
-
1. **Static** paths (`/about`) — highest priority
|
|
908
|
-
2. **Parameterised** paths (`/user/:id`)
|
|
909
|
-
3. **Wildcard** paths (`/**:slug`) — lowest priority
|
|
910
|
-
|
|
911
|
-
Within the same tier, longer segment counts and alphabetical order act as tiebreakers for determinism. Route group pages sort alongside regular pages by their resolved pattern — the group folder is transparent.
|
|
912
|
-
|
|
913
|
-
### Layouts
|
|
914
|
-
|
|
915
|
-
A `+layout.ts` wraps every page in its directory and all subdirectories. Layouts compose **inside-out** — the nearest layout is innermost, the root layout is outermost.
|
|
916
|
-
|
|
917
|
-
```ts
|
|
918
|
-
// src/pages/+layout.ts
|
|
919
|
-
import { defineLayout } from "@ilha/router";
|
|
920
|
-
import { ilha, html } from "ilha";
|
|
921
|
-
|
|
922
|
-
export default defineLayout((children) =>
|
|
923
|
-
ilha(
|
|
924
|
-
() => html`
|
|
925
|
-
<nav>
|
|
926
|
-
<a href="/">Home</a>
|
|
927
|
-
<a href="/about">About</a>
|
|
928
|
-
</nav>
|
|
929
|
-
<main>${children}</main>
|
|
930
|
-
`,
|
|
931
|
-
),
|
|
932
|
-
);
|
|
933
|
-
```
|
|
934
|
-
|
|
935
|
-
Alternatively, using the explicit type annotation:
|
|
936
|
-
|
|
937
|
-
```ts
|
|
938
|
-
// src/pages/+layout.ts — using satisfies (equivalent)
|
|
939
|
-
import type { LayoutHandler } from "@ilha/router/vite";
|
|
940
|
-
import { ilha, html } from "ilha";
|
|
941
|
-
|
|
942
|
-
export default ((children) =>
|
|
943
|
-
ilha(
|
|
944
|
-
() => html`
|
|
945
|
-
<nav>
|
|
946
|
-
<a href="/">Home</a>
|
|
947
|
-
<a href="/about">About</a>
|
|
948
|
-
</nav>
|
|
949
|
-
<main>${children}</main>
|
|
950
|
-
`,
|
|
951
|
-
)) satisfies LayoutHandler;
|
|
952
|
-
```
|
|
953
|
-
|
|
954
|
-
A `+layout.ts` inside a route group folder works exactly like a regular nested layout — it wraps only the pages inside that group, without affecting pages elsewhere.
|
|
955
|
-
|
|
956
|
-
```
|
|
957
|
-
src/pages/
|
|
958
|
-
+layout.ts ← wraps ALL pages (including those in groups)
|
|
959
|
-
(auth)/
|
|
960
|
-
+layout.ts ← wraps (auth) pages only: /sign-in, /sign-up
|
|
961
|
-
sign-in.ts
|
|
962
|
-
sign-up.ts
|
|
963
|
-
about.ts ← wrapped by root layout only
|
|
964
|
-
```
|
|
965
|
-
|
|
966
|
-
### Page loaders
|
|
967
|
-
|
|
968
|
-
A page file can export a `load` function declared with the `loader()` helper. The Vite plugin automatically detects the named `load` export, composes it with any layout loaders in the chain (outermost first, then page), and wires them into the router via `.attachLoader()` at SSR time.
|
|
969
|
-
|
|
970
|
-
```ts
|
|
971
|
-
// src/pages/user/[id].ts
|
|
972
|
-
import { loader } from "@ilha/router";
|
|
973
|
-
import { ilha, html } from "ilha";
|
|
974
|
-
|
|
975
|
-
export const load = loader(async ({ params }) => {
|
|
976
|
-
const user = await fetchUser(params.id);
|
|
977
|
-
return { user };
|
|
370
|
+
plugins: [oxide({ middleware: ["@ilha/router/ssr"] }), pages()],
|
|
978
371
|
});
|
|
979
|
-
|
|
980
|
-
export default ilha<{ user: User }>(({ user }) => html`<h1>${user.name}</h1>`);
|
|
981
372
|
```
|
|
982
373
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
### Layout loaders
|
|
986
|
-
|
|
987
|
-
A `+layout.ts` can also export a loader. Layout loaders run concurrently with the page loader. The page loader wins on key collision.
|
|
988
|
-
|
|
989
|
-
```ts
|
|
990
|
-
// src/pages/+layout.ts
|
|
991
|
-
import { defineLayout, loader } from "@ilha/router";
|
|
992
|
-
|
|
993
|
-
export const load = loader(async () => {
|
|
994
|
-
return { currentUser: await getCurrentUser() };
|
|
995
|
-
});
|
|
996
|
-
|
|
997
|
-
export default defineLayout((children) => /* … */);
|
|
998
|
-
```
|
|
999
|
-
|
|
1000
|
-
Layout loaders are composed automatically — you do not need to call `composeLoaders()` manually.
|
|
1001
|
-
|
|
1002
|
-
### Client loaders (`clientLoad`)
|
|
1003
|
-
|
|
1004
|
-
A page or layout can export a `clientLoad` function that runs **in the browser** on client navigations, instead of fetching from the loader endpoint. Use it for data that is fetchable from the client anyway (public APIs, the app's own REST endpoints) — it saves a server round-trip per navigation, and it works on static hosts with no loader endpoint at all.
|
|
1005
|
-
|
|
1006
|
-
```ts
|
|
1007
|
-
// src/pages/dashboard.ts
|
|
1008
|
-
import { loader } from "@ilha/router";
|
|
1009
|
-
import { ilha } from "ilha";
|
|
1010
|
-
|
|
1011
|
-
export const clientLoad = loader(async ({ signal }) => {
|
|
1012
|
-
const stats = await fetch("/api/stats", { signal }).then((r) => r.json());
|
|
1013
|
-
return { stats };
|
|
1014
|
-
});
|
|
1015
|
-
|
|
1016
|
-
export default ilha<{ stats: Stats }>(({ stats }) => html`<pre>${JSON.stringify(stats)}</pre>`);
|
|
1017
|
-
```
|
|
1018
|
-
|
|
1019
|
-
Rules and caveats:
|
|
1020
|
-
|
|
1021
|
-
- `clientLoad` is bundled into the client — never put secrets, database clients, or server-only imports in it. Keep those in `load`, which stays server-only.
|
|
1022
|
-
- A page can export **both**: `load` runs during SSR (first paint), `clientLoad` runs on client navigations instead of the endpoint fetch. Make them return the same shape.
|
|
1023
|
-
- Layout `clientLoad`s compose with the page's, layouts first — the page wins on key collision, mirroring server loaders.
|
|
1024
|
-
- With SSR + hydration, a `clientLoad`-only page is server-rendered **without** its data; the router runs `clientLoad` on the client right after hydration and re-renders the route with the loaded props.
|
|
1025
|
-
- `clientLoad` receives a synthetic `Request` — rely on `url`, `params`, and `signal`, not cookies or headers.
|
|
1026
|
-
|
|
1027
|
-
### Error boundaries
|
|
1028
|
-
|
|
1029
|
-
A `+error.ts` catches any error thrown during rendering of pages in its directory and all subdirectories. The nearest boundary wins. If an inner boundary re-throws, the next outer boundary takes over.
|
|
1030
|
-
|
|
1031
|
-
```ts
|
|
1032
|
-
// src/pages/+error.ts
|
|
1033
|
-
import type { ErrorHandler } from "@ilha/router/vite";
|
|
1034
|
-
import { ilha, html } from "ilha";
|
|
1035
|
-
|
|
1036
|
-
export default ((error, route) =>
|
|
1037
|
-
ilha(
|
|
1038
|
-
() => html`
|
|
1039
|
-
<div class="error">
|
|
1040
|
-
<h1>${error.status ?? 500}</h1>
|
|
1041
|
-
<p>${error.message}</p>
|
|
1042
|
-
<p>Path: ${route.path}</p>
|
|
1043
|
-
</div>
|
|
1044
|
-
`,
|
|
1045
|
-
)) satisfies ErrorHandler;
|
|
1046
|
-
```
|
|
1047
|
-
|
|
1048
|
-
### Virtual modules
|
|
1049
|
-
|
|
1050
|
-
The plugin exposes separate server and client virtual modules. **Always use the explicit `/server` or `/client` path** — they resolve to different generated files with different import strategies.
|
|
1051
|
-
|
|
1052
|
-
| Module | Exports | Use for |
|
|
1053
|
-
| ------------------- | ------------------------ | -------------------------------------- |
|
|
1054
|
-
| `ilha:pages/server` | `pageRouter`, `registry` | SSR, prerender, server handlers |
|
|
1055
|
-
| `ilha:pages/client` | `pageRouter`, `registry` | Browser hydration entry |
|
|
1056
|
-
| `ilha:loaders` | — | Server-only side-effect: wires loaders |
|
|
1057
|
-
|
|
1058
|
-
The server module imports page/layout/error modules **without** `?client` — raw imports so SSR sees full JSX including compound component render parts. The client module imports with `?client` which strips server-only `load` exports from the browser bundle.
|
|
1059
|
-
|
|
1060
|
-
```ts
|
|
1061
|
-
// routes/[...].ts — SSR/prerender
|
|
1062
|
-
import { pageRouter, registry } from "ilha:pages/server";
|
|
1063
|
-
import "ilha:loaders"; // ← wire server loaders
|
|
1064
|
-
|
|
1065
|
-
export default defineEventHandler(async (event) => {
|
|
1066
|
-
const html = await pageRouter.renderHydratable(event.node.req.url ?? "/", registry);
|
|
1067
|
-
return new Response(`<!doctype html><html><body>${html}</body></html>`, {
|
|
1068
|
-
headers: { "content-type": "text/html" },
|
|
1069
|
-
});
|
|
1070
|
-
});
|
|
1071
|
-
```
|
|
1072
|
-
|
|
1073
|
-
```ts
|
|
1074
|
-
// src/client.ts — browser entry
|
|
1075
|
-
import { pageRouter, registry } from "ilha:pages/client";
|
|
1076
|
-
|
|
1077
|
-
pageRouter.hydrate(registry);
|
|
1078
|
-
```
|
|
1079
|
-
|
|
1080
|
-
For `static` MPA mode:
|
|
1081
|
-
|
|
1082
|
-
```ts
|
|
1083
|
-
// src/entry-client.ts — static/SSG browser entry
|
|
1084
|
-
import { pageRouter, registry } from "ilha:pages/client";
|
|
1085
|
-
|
|
1086
|
-
pageRouter.hydrateStatic(registry);
|
|
1087
|
-
```
|
|
1088
|
-
|
|
1089
|
-
### Plugin options
|
|
1090
|
-
|
|
1091
|
-
```ts
|
|
1092
|
-
pages({
|
|
1093
|
-
dir: "src/pages", // pages directory (default: "src/pages")
|
|
1094
|
-
outDir: ".ilha", // output directory for generated files (default: ".ilha")
|
|
1095
|
-
mode: "spa", // "spa" | "static" (default: "spa")
|
|
1096
|
-
interceptLinks: true, // only meaningful in spa mode (default: true)
|
|
1097
|
-
});
|
|
1098
|
-
```
|
|
1099
|
-
|
|
1100
|
-
- **`mode: "spa"`** — full client route graph, SSR/hydration, and client-side navigation.
|
|
1101
|
-
- **`mode: "spa", interceptLinks: false`** — full route graph and SSR/hydration, but internal links perform full document navigations.
|
|
1102
|
-
- **`mode: "static"`** — island registry only; no route graph bundled. Each pre-rendered page hydrates its own islands via `pageRouter.hydrateStatic(registry)`.
|
|
1103
|
-
|
|
1104
|
-
The plugin regenerates the routes file only when content actually changes — avoiding unnecessary HMR invalidations. Structural changes (file add/remove, `+layout.ts`/`+error.ts` edits, or changes to loader exports) trigger full HMR reloads.
|
|
1105
|
-
|
|
1106
|
-
---
|
|
1107
|
-
|
|
1108
|
-
## SSR + Hydration
|
|
1109
|
-
|
|
1110
|
-
The same route config runs on both sides. Signals (`routePath`, `routeParams`, etc.) are populated identically by `.render()`/`.renderHydratable()` on the server and `.mount()`/`.hydrate()` on the client:
|
|
1111
|
-
|
|
1112
|
-
```ts
|
|
1113
|
-
// server: resolves URL → hydratable HTML string
|
|
1114
|
-
await pageRouter.renderHydratable("/user/42", registry);
|
|
1115
|
-
routeParams(); // → { id: "42" }
|
|
1116
|
-
|
|
1117
|
-
// client: hydrates SSR DOM, sets up navigation
|
|
1118
|
-
pageRouter.hydrate(registry);
|
|
1119
|
-
navigate("/user/99");
|
|
1120
|
-
routeParams(); // → { id: "99" }
|
|
1121
|
-
```
|
|
1122
|
-
|
|
1123
|
-
### Full SSR → hydration flow
|
|
1124
|
-
|
|
1125
|
-
```
|
|
1126
|
-
server client
|
|
1127
|
-
────────────────────────────── ───────────────────────────────────
|
|
1128
|
-
renderHydratable(url, registry) pageRouter.prime() ← sync signals first
|
|
1129
|
-
→ data-ilha="…" markers mount(registry, { root }) ← hydrate islands
|
|
1130
|
-
→ data-ilha-state snapshot pageRouter.mount(target, ← setup navigation
|
|
1131
|
-
{ hydrate: true, registry })
|
|
1132
|
-
```
|
|
1133
|
-
|
|
1134
|
-
Or use the one-liner: `pageRouter.hydrate(registry)`.
|
|
1135
|
-
|
|
1136
|
-
### Loader data flow
|
|
1137
|
-
|
|
1138
|
-
On the **server**, loaders run inside `.renderHydratable()` / `.renderResponse()`. Their return value is serialized into `data-ilha-props` on the island element so the client can rehydrate without re-fetching.
|
|
1139
|
-
|
|
1140
|
-
On the **client**, navigations resolve loader data before mounting the next island. Routes with a loader registered in the browser — a manual `.route(path, island, loader)` or an FS-routing `clientLoad` export — run that loader locally, with no network round-trip. Routes with only a server loader (`markLoader()` / a `load` export) fetch from the `/__ilha/loader` endpoint, served automatically by the Vite plugin (dev) and the server adapter (production). The originating `Request` (cookies, identity, abort signal) is forwarded to the loader and the island-request scope, so `ctx.request` and `useContext().request` behave in client navigations exactly as they do during SSR.
|
|
1141
|
-
|
|
1142
|
-
Like `/__ilha/frame`, the loader endpoint is **denied by default** in production when no guard is registered — gate it with `setLoaderGuard()` (or the shared frame guard / `defaultAction: "open"` policy) or client navigations to server-loader routes return 403.
|
|
1143
|
-
|
|
1144
|
-
```
|
|
1145
|
-
server client (navigation)
|
|
1146
|
-
──────────────────────────── ─────────────────────────────────────────
|
|
1147
|
-
renderHydratable local loader (clientLoad / .route loader)?
|
|
1148
|
-
→ executeLoader(…) yes → runLocalLoader(…) in-browser
|
|
1149
|
-
→ island.hydratable(props) no → fetchLoaderData(…) GET /__ilha/loader?path=/user/42
|
|
1150
|
-
→ data-ilha-props="{…}" → mountRouteWithHydration(island, host, …)
|
|
1151
|
-
```
|
|
374
|
+
Without oxidejs, host the router in your own fetch handler and use `render()`, `renderResponse()`, or `respond()`. In static (`mode: "static"`) builds, prerender each route at build time and hydrate the island from the client `registry`.
|
|
1152
375
|
|
|
1153
376
|
---
|
|
1154
377
|
|