@lesto/ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +58 -0
- package/src/bfcache.ts +121 -0
- package/src/client.ts +54 -0
- package/src/data-client.ts +361 -0
- package/src/data-resolve.tsx +120 -0
- package/src/data.ts +183 -0
- package/src/define-island.tsx +222 -0
- package/src/errors.ts +36 -0
- package/src/hydrate.tsx +563 -0
- package/src/index.ts +165 -0
- package/src/island.ts +258 -0
- package/src/link.tsx +103 -0
- package/src/metadata.ts +195 -0
- package/src/mount.ts +99 -0
- package/src/node.ts +8 -0
- package/src/props.ts +95 -0
- package/src/registry.ts +141 -0
- package/src/render.tsx +349 -0
- package/src/resources.ts +233 -0
- package/src/route.ts +62 -0
- package/src/routes.ts +101 -0
- package/src/schema.ts +244 -0
- package/src/serialize.ts +148 -0
- package/src/server-preact.ts +59 -0
- package/src/server.ts +44 -0
- package/src/softnav-contract.ts +144 -0
- package/src/softnav.ts +934 -0
- package/src/stream.tsx +387 -0
- package/src/types.ts +46 -0
- package/src/validate.ts +107 -0
package/src/hydrate.tsx
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client runtime: turn a static page's islands live.
|
|
3
|
+
*
|
|
4
|
+
* After the server ships HTML with `<div data-lesto-island="id">…</div>` shells
|
|
5
|
+
* and a manifest of `IslandMount`s, the browser calls `hydrateIslands`. For each
|
|
6
|
+
* mount it finds the matching shell by id and brings it to life with the
|
|
7
|
+
* manifest's props — the moment a prerendered page becomes auth-aware,
|
|
8
|
+
* per-visitor, without re-rendering the rest of the page.
|
|
9
|
+
*
|
|
10
|
+
* Two mount strategies, chosen per island by the manifest's `ssr` flag — because
|
|
11
|
+
* the server emits two different shells (see `render.tsx`'s `buildIsland`):
|
|
12
|
+
*
|
|
13
|
+
* - **Deferred island (`ssr: false`) → `createRoot().render()`.** The server
|
|
14
|
+
* could not render the real component (it depends on the signed-in user the
|
|
15
|
+
* prerender never knew), so the shell holds only a *fallback*. `hydrateRoot`
|
|
16
|
+
* demands the server and client markup match; against a fallback it would
|
|
17
|
+
* throw a hydration mismatch — strictly worse than the status quo. So we mount
|
|
18
|
+
* fresh into the shell, swapping the fallback for the live render.
|
|
19
|
+
*
|
|
20
|
+
* - **SSR-able island (`ssr: true`) → `hydrateRoot()`.** Here the server
|
|
21
|
+
* rendered the component's REAL output into the shell, so the client reuses
|
|
22
|
+
* that DOM instead of re-rendering it: real hydration, React 19's hydration
|
|
23
|
+
* resilience, and the path that later unlocks selective hydration. A
|
|
24
|
+
* recoverable error (a benign mismatch React patched, or a server-thrown
|
|
25
|
+
* error it recovered from) is routed to an injectable sink rather than
|
|
26
|
+
* silently swallowed.
|
|
27
|
+
*
|
|
28
|
+
* The choice is the manifest's, never a guess: the server is the only side that
|
|
29
|
+
* knows which shell it shipped, so it tells the client. We NEVER `hydrateRoot` a
|
|
30
|
+
* fallback-only shell.
|
|
31
|
+
*
|
|
32
|
+
* An island may also defer WHEN it mounts. The manifest's `strategy` chooses:
|
|
33
|
+
* - **`"load"` (default / absent) → mount now.** Today's only path, untouched.
|
|
34
|
+
* - **`"visible"` → mount on first intersection.** We do NOT mount the island
|
|
35
|
+
* yet; we observe its container and mount it (then stop observing) the first
|
|
36
|
+
* time it scrolls into view — Astro's `client:visible` analogue. For Lesto's
|
|
37
|
+
* deferred Account island this also defers its on-mount `/mls/api/session`
|
|
38
|
+
* fetch until the region is actually seen, so an above-the-fold prerender
|
|
39
|
+
* does not fan out a request for every below-the-fold island on load.
|
|
40
|
+
*
|
|
41
|
+
* Byte deferral is the declaration's choice, not the strategy's: an island whose
|
|
42
|
+
* def carries an eager `component` already shipped its code in the main bundle
|
|
43
|
+
* (so `"visible"` defers only its mount WORK), while a lazy `load` def fetches
|
|
44
|
+
* its component as its own chunk at mount time — the per-island code-splitting
|
|
45
|
+
* that makes `"visible"` defer BYTES too. The runtime treats a lazy island like
|
|
46
|
+
* a visible one in its result: found, pending, reported under `deferred`, with
|
|
47
|
+
* the eventual mount (or failure) appended to the caller-held arrays.
|
|
48
|
+
*
|
|
49
|
+
* Everything that varies is injected, so the whole runtime is exercised under
|
|
50
|
+
* jsdom with no real browser:
|
|
51
|
+
* - `root` — where to look for shells (defaults to `document`);
|
|
52
|
+
* - `mount` — the mount function (defaults to React's create/hydrate split);
|
|
53
|
+
* - `observe` — how a `"visible"` island waits for its region (defaults to an
|
|
54
|
+
* `IntersectionObserver`); injectable so the lazy path is tested
|
|
55
|
+
* under jsdom, which has no real `IntersectionObserver`;
|
|
56
|
+
* - `onRecoverableError` — the hydration-error sink (defaults to `console.error`).
|
|
57
|
+
*
|
|
58
|
+
* It is honest about React: this registry renders to React everywhere else, so an
|
|
59
|
+
* island mounts with React. A different client framework would ship its own
|
|
60
|
+
* runtime over the SAME manifest — that is the point of keeping the manifest a
|
|
61
|
+
* plain data contract.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { createElement } from "react";
|
|
65
|
+
import type { ReactElement } from "react";
|
|
66
|
+
import { createRoot, hydrateRoot } from "react-dom/client";
|
|
67
|
+
|
|
68
|
+
import { UiError } from "./errors";
|
|
69
|
+
import { ISLAND_ATTR, ISLAND_MOUNT_ATTR } from "./island";
|
|
70
|
+
import type { ClientComponentDef, IslandMount } from "./island";
|
|
71
|
+
import type { Registry } from "./registry";
|
|
72
|
+
|
|
73
|
+
declare global {
|
|
74
|
+
interface Window {
|
|
75
|
+
/**
|
|
76
|
+
* Per-source data promises kicked by the parse-time primer
|
|
77
|
+
* (`dataPrimerScript`), keyed by source name. The hydration runtime awaits
|
|
78
|
+
* these before mounting a bound island, so the data fetch runs parallel
|
|
79
|
+
* with `client.js` rather than after it (ADR 0010).
|
|
80
|
+
*/
|
|
81
|
+
__lestoData?: Record<string, Promise<unknown>>;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The client analogue of the server's 10s `RENDER_DEADLINE_MS` (render-page.tsx):
|
|
87
|
+
* a hung `/__lesto/data/<name>` — or a primed promise that never settles — would
|
|
88
|
+
* otherwise leave its island in `deferred` forever. Bind resolution races this
|
|
89
|
+
* deadline and routes a timeout to `onMountError`/`failed`, exactly like a
|
|
90
|
+
* rejected fetch.
|
|
91
|
+
*/
|
|
92
|
+
const BIND_DEADLINE_MS = 10_000;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve an island's data binds to a `{ propName: value }` bag, in parallel,
|
|
96
|
+
* racing a `timeoutMs` deadline so a never-settling source cannot strand the
|
|
97
|
+
* island in `deferred`.
|
|
98
|
+
*
|
|
99
|
+
* Each bound source is taken from the parse-time primer promise
|
|
100
|
+
* (`window.__lestoData[source]`, started before any JS ran — see
|
|
101
|
+
* `dataPrimerScript`) when present, else fetched directly as the fallback. A
|
|
102
|
+
* rejection (a non-ok fetch, or the timeout) propagates to the caller's catch,
|
|
103
|
+
* which fails just that one island. The timer is CLEARED when the data wins — a
|
|
104
|
+
* dangling timer keeps a test process (or the browser tab) alive needlessly.
|
|
105
|
+
*/
|
|
106
|
+
async function resolveBinds(
|
|
107
|
+
entry: IslandMount,
|
|
108
|
+
timeoutMs: number,
|
|
109
|
+
): Promise<Record<string, unknown>> {
|
|
110
|
+
if (entry.bind === undefined) return {};
|
|
111
|
+
|
|
112
|
+
const primed = window.__lestoData;
|
|
113
|
+
|
|
114
|
+
const resolved: Record<string, unknown> = {};
|
|
115
|
+
|
|
116
|
+
const data = Promise.all(
|
|
117
|
+
Object.entries(entry.bind).map(async ([prop, bind]) => {
|
|
118
|
+
resolved[prop] = await (primed?.[bind.source] ??
|
|
119
|
+
fetch(bind.href, { credentials: "same-origin" }).then((response) => {
|
|
120
|
+
// Mirror the primer's `ok` guard: a 401/429 etc. JSON error body must
|
|
121
|
+
// never become the island's prop value. A coded rejection flows to the
|
|
122
|
+
// caller's catch, which fails just this island (it keeps its fallback).
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
throw new UiError(
|
|
125
|
+
"UI_ISLAND_DATA_FETCH_FAILED",
|
|
126
|
+
`data source "${bind.source}" answered ${response.status}`,
|
|
127
|
+
{ source: bind.source, status: response.status },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return response.json();
|
|
132
|
+
}));
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
137
|
+
|
|
138
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
139
|
+
timer = setTimeout(() => {
|
|
140
|
+
reject(
|
|
141
|
+
new UiError("UI_ISLAND_DATA_TIMEOUT", `island data did not arrive within ${timeoutMs}ms`, {
|
|
142
|
+
id: entry.id,
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
}, timeoutMs);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
await Promise.race([data, deadline]);
|
|
150
|
+
} finally {
|
|
151
|
+
// Whether the data won or the deadline fired, stop the timer so it cannot
|
|
152
|
+
// dangle (the win case) — the loss case has already cleared on fire.
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return resolved;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Where islands are looked up: anything that can query by selector. */
|
|
160
|
+
export interface IslandRoot {
|
|
161
|
+
querySelector(selectors: string): Element | null;
|
|
162
|
+
|
|
163
|
+
/** Find every co-located mount script (used by {@link hydrateDocumentIslands}). */
|
|
164
|
+
querySelectorAll(selectors: string): ArrayLike<Element>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* How a `"visible"` island waits for its region to enter the viewport.
|
|
169
|
+
*
|
|
170
|
+
* Given the island's container and a callback to run when it becomes visible, an
|
|
171
|
+
* observer starts watching and returns a `disconnect` function that tears the
|
|
172
|
+
* watching down. The observer MAY fire `onVisible` more than once (a real
|
|
173
|
+
* `IntersectionObserver` fires on every viewport entry); the runtime guards
|
|
174
|
+
* re-entry so the mount runs exactly once, then calls `disconnect` to stop the
|
|
175
|
+
* watching. An `ObserveFn` therefore need not be one-shot itself.
|
|
176
|
+
*
|
|
177
|
+
* It is its own injectable seam (not folded into `mount`) for the same reason
|
|
178
|
+
* `mount` is one: jsdom has no real `IntersectionObserver`, so the lazy path is
|
|
179
|
+
* untestable without substituting a fake here. The default
|
|
180
|
+
* ({@link intersectionObserve}) wraps the browser's `IntersectionObserver`.
|
|
181
|
+
*/
|
|
182
|
+
export type ObserveFn = (container: Element, onVisible: () => void) => Disconnect;
|
|
183
|
+
|
|
184
|
+
/** Stop an {@link ObserveFn} from watching — idempotent by contract. */
|
|
185
|
+
export type Disconnect = () => void;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* How to bring one island to life. `ssr` tells the mount whether the container
|
|
189
|
+
* already holds the component's server-rendered output (hydrate it) or only a
|
|
190
|
+
* fallback (mount fresh). `onRecoverableError` is the sink the hydrate path wires
|
|
191
|
+
* to React's recoverable-error callback.
|
|
192
|
+
*/
|
|
193
|
+
export type MountFn = (container: Element, element: ReactElement, context: MountContext) => void;
|
|
194
|
+
|
|
195
|
+
/** The non-element inputs a mount needs to choose and configure its strategy. */
|
|
196
|
+
export interface MountContext {
|
|
197
|
+
ssr: boolean;
|
|
198
|
+
onRecoverableError: RecoverableErrorSink;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Where hydration's recoverable errors go — wired to React's `onRecoverableError`. */
|
|
202
|
+
export type RecoverableErrorSink = (error: unknown, errorInfo: { componentStack?: string }) => void;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Where a *fatal* per-island mount error goes — the throw a single island's
|
|
206
|
+
* mount raised, which we catch so the rest of the page still hydrates.
|
|
207
|
+
*
|
|
208
|
+
* This is distinct from `RecoverableErrorSink`: that one carries React's
|
|
209
|
+
* already-recovered hydration mismatches (the mount succeeded, React patched the
|
|
210
|
+
* DOM); this one carries a mount that genuinely failed (it threw and that island
|
|
211
|
+
* is dead). The id of the island whose mount threw rides along so the caller can
|
|
212
|
+
* tell which region is broken.
|
|
213
|
+
*/
|
|
214
|
+
export type MountErrorSink = (error: unknown, info: { id: string; component: string }) => void;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* What `hydrateIslands` did: the ids it brought to life, the ones it couldn't
|
|
218
|
+
* find a shell for, and the ones whose mount threw.
|
|
219
|
+
*
|
|
220
|
+
* `failed` is the resilience seam: one island's mount throwing no longer aborts
|
|
221
|
+
* the loop, so a single broken region cannot dark out every island after it in
|
|
222
|
+
* the manifest. A page with no broken islands gets an empty `failed`, so the
|
|
223
|
+
* common case reads exactly as before plus one always-empty array.
|
|
224
|
+
*
|
|
225
|
+
* `deferred` reports the islands found in the DOM that we did NOT finish
|
|
226
|
+
* mounting synchronously — a `"visible"` island waiting on its intersection
|
|
227
|
+
* observer, or a lazy (`load`) island whose chunk is still in flight. Each will
|
|
228
|
+
* mount (and surface its own throw or load failure to `onMountError`) AFTER this
|
|
229
|
+
* call has returned. It is its own list precisely because a deferred island is
|
|
230
|
+
* neither `mounted` (no work finished) nor `missing` (its shell is present) nor
|
|
231
|
+
* `failed` (nothing failed yet): conflating it with any of those would lie about
|
|
232
|
+
* the page's state. A page of eager `"load"` islands gets an empty `deferred`,
|
|
233
|
+
* so the common case reads exactly as before plus one always-empty array.
|
|
234
|
+
*/
|
|
235
|
+
export interface HydrationResult {
|
|
236
|
+
mounted: string[];
|
|
237
|
+
missing: string[];
|
|
238
|
+
failed: string[];
|
|
239
|
+
deferred: string[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Optional injection seams; all default to the real browser + React. */
|
|
243
|
+
export interface HydrateOptions {
|
|
244
|
+
root?: IslandRoot;
|
|
245
|
+
mount?: MountFn;
|
|
246
|
+
observe?: ObserveFn;
|
|
247
|
+
onRecoverableError?: RecoverableErrorSink;
|
|
248
|
+
onMountError?: MountErrorSink;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* How long a bound island waits for its data before failing (the client
|
|
252
|
+
* analogue of the server's render deadline). Defaults to {@link BIND_DEADLINE_MS};
|
|
253
|
+
* an injectable seam like `observe`/`mount`, primarily for tests.
|
|
254
|
+
*/
|
|
255
|
+
bindTimeoutMs?: number;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Real-React default: hydrate the shell when the server rendered the real
|
|
260
|
+
* component into it, otherwise mount fresh over the fallback.
|
|
261
|
+
*
|
|
262
|
+
* The branch is the whole point — `hydrateRoot` reuses the server DOM and would
|
|
263
|
+
* throw against a fallback, `createRoot` replaces it. We only ever hydrate when
|
|
264
|
+
* the manifest says the server shipped matching markup.
|
|
265
|
+
*/
|
|
266
|
+
const reactMount: MountFn = (container, element, context) => {
|
|
267
|
+
if (context.ssr) {
|
|
268
|
+
hydrateRoot(container, element, { onRecoverableError: context.onRecoverableError });
|
|
269
|
+
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
createRoot(container).render(element);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** Default sink: surface a recoverable hydration error on the console, don't hide it. */
|
|
277
|
+
const consoleRecoverableError: RecoverableErrorSink = (error) => {
|
|
278
|
+
console.error("[lesto/ui] recoverable hydration error", error);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/** Default sink: surface a fatal per-island mount error, naming the dead island. */
|
|
282
|
+
const consoleMountError: MountErrorSink = (error, info) => {
|
|
283
|
+
console.error(`[lesto/ui] island "${info.id}" (${info.component}) failed to mount`, error);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Default observer: a real `IntersectionObserver` that fires `onVisible` the
|
|
288
|
+
* first time any part of the container intersects the viewport.
|
|
289
|
+
*
|
|
290
|
+
* Teardown is the RUNTIME's job, not this observer's: an `ObserveFn` returns a
|
|
291
|
+
* `disconnect` and the runtime calls it after the one-shot mount. Keeping the
|
|
292
|
+
* teardown in one place (the caller) means every observer — this default and any
|
|
293
|
+
* injected fake — is torn down identically, and an injected observer that does
|
|
294
|
+
* not self-disconnect is still cleaned up. So this default does not disconnect
|
|
295
|
+
* itself on intersection; it just reports the `disconnect` handle and lets the
|
|
296
|
+
* runtime drive it.
|
|
297
|
+
*/
|
|
298
|
+
const intersectionObserve: ObserveFn = (container, onVisible) => {
|
|
299
|
+
const observer = new IntersectionObserver((entries) => {
|
|
300
|
+
// `isIntersecting` is the standard "is any of it on screen" predicate; we act
|
|
301
|
+
// on the first entry that reports true and ignore the leave/partial events.
|
|
302
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
303
|
+
onVisible();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
observer.observe(container);
|
|
308
|
+
|
|
309
|
+
return () => observer.disconnect();
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Hydrate every island in an explicit `manifest` array, pairing each mount's `id`
|
|
314
|
+
* to its shell — the shared mount engine BOTH island paths run on.
|
|
315
|
+
*
|
|
316
|
+
* Public callers reach it through the page-wide-array niche (the DEMOTED Registry/
|
|
317
|
+
* `UiNode` content path: a `#lesto-islands` manifest the server emitted from
|
|
318
|
+
* `renderPage`, read and passed here, as the e2e fixture and AI-content apps do).
|
|
319
|
+
* The CANONICAL `.page` path calls {@link hydrateDocumentIslands} instead, which
|
|
320
|
+
* scans the co-located mount scripts and feeds them HERE — so the two emission
|
|
321
|
+
* shapes converge onto this one mount loop, with one set of strategies, binds,
|
|
322
|
+
* and resilience semantics. Estate and blog go through `hydrateDocumentIslands`.
|
|
323
|
+
*
|
|
324
|
+
* A mount whose shell is absent from the DOM is skipped and reported in
|
|
325
|
+
* `missing` (a page may legitimately render only some islands). A mount whose
|
|
326
|
+
* `component` is not a registered client component (`UI_ISLAND_UNKNOWN_COMPONENT`)
|
|
327
|
+
* is ROUTED, not thrown: both manifest forms reach the client inside a possibly
|
|
328
|
+
* CDN-cached document, so a rename-and-redeploy leaves stale pages naming a
|
|
329
|
+
* component this bundle no longer holds — a production deploy-skew fault, not a
|
|
330
|
+
* build-time bug. Throwing it would dark out every island after the drifted id;
|
|
331
|
+
* instead we hand the coded error to `onMountError`, record the id in `failed`,
|
|
332
|
+
* and keep going. Callers still branch on the code at the sink.
|
|
333
|
+
*
|
|
334
|
+
* A mount that *throws at runtime* (a component that blows up during its initial
|
|
335
|
+
* render) is the same animal: it dents one region, not the page. We catch it,
|
|
336
|
+
* route it to `onMountError`, record the id in `failed`, and keep going — so a
|
|
337
|
+
* single broken island can never dark out every island that follows it in the
|
|
338
|
+
* manifest. This mirrors React's own per-root hydration resilience at the
|
|
339
|
+
* island-orchestration layer above it.
|
|
340
|
+
*
|
|
341
|
+
* A `"visible"` island (the manifest's `strategy`) takes neither branch
|
|
342
|
+
* synchronously: instead of mounting it now we set up an intersection observer
|
|
343
|
+
* and record it in `deferred`. The SAME mount-and-contain logic runs when the
|
|
344
|
+
* region is first seen, so a deferred island that throws is still caught and
|
|
345
|
+
* routed to `onMountError` — just later, when its callback fires. Because that
|
|
346
|
+
* fire happens after this function has returned, its result is reflected by
|
|
347
|
+
* mutating the returned `mounted`/`failed` arrays (the caller holds the
|
|
348
|
+
* reference): the synchronous return value reports the deferred id under
|
|
349
|
+
* `deferred`, and the post-intersection mount appends to `mounted` or `failed`.
|
|
350
|
+
*/
|
|
351
|
+
export function hydrateIslands(
|
|
352
|
+
registry: Registry,
|
|
353
|
+
manifest: readonly IslandMount[],
|
|
354
|
+
options: HydrateOptions = {},
|
|
355
|
+
): HydrationResult {
|
|
356
|
+
const root: IslandRoot = options.root ?? document;
|
|
357
|
+
|
|
358
|
+
const mount: MountFn = options.mount ?? reactMount;
|
|
359
|
+
|
|
360
|
+
const observe: ObserveFn = options.observe ?? intersectionObserve;
|
|
361
|
+
|
|
362
|
+
const onRecoverableError: RecoverableErrorSink =
|
|
363
|
+
options.onRecoverableError ?? consoleRecoverableError;
|
|
364
|
+
|
|
365
|
+
const onMountError: MountErrorSink = options.onMountError ?? consoleMountError;
|
|
366
|
+
|
|
367
|
+
const bindTimeoutMs: number = options.bindTimeoutMs ?? BIND_DEADLINE_MS;
|
|
368
|
+
|
|
369
|
+
const mounted: string[] = [];
|
|
370
|
+
|
|
371
|
+
const missing: string[] = [];
|
|
372
|
+
|
|
373
|
+
const failed: string[] = [];
|
|
374
|
+
|
|
375
|
+
const deferred: string[] = [];
|
|
376
|
+
|
|
377
|
+
// Render one resolved component into its container with its (data-merged)
|
|
378
|
+
// props and contain its throw, shared by every path (eager, lazy, bound,
|
|
379
|
+
// on-intersection) so each gets identical resilience. It reads and mutates the
|
|
380
|
+
// result arrays above by closure, which is why a post-return mount still lands
|
|
381
|
+
// in `mounted`/`failed`.
|
|
382
|
+
const finish = (
|
|
383
|
+
entry: IslandMount,
|
|
384
|
+
component: NonNullable<ClientComponentDef["component"]>,
|
|
385
|
+
container: Element,
|
|
386
|
+
props: Record<string, unknown>,
|
|
387
|
+
): void => {
|
|
388
|
+
try {
|
|
389
|
+
mount(container, createElement(component, props), {
|
|
390
|
+
ssr: entry.ssr,
|
|
391
|
+
onRecoverableError,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
mounted.push(entry.id);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
// One island's mount threw. Route it to the sink, mark it failed, and keep
|
|
397
|
+
// hydrating the rest — a broken region must not take the page down with it.
|
|
398
|
+
onMountError(error, { id: entry.id, component: entry.component });
|
|
399
|
+
|
|
400
|
+
failed.push(entry.id);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// Mount one island now (eager `component`, no data binds) or, when its code
|
|
405
|
+
// and/or its data are still arriving, resolve both IN PARALLEL and mount when
|
|
406
|
+
// they land. Returns `"now"` for the synchronous case and `"later"` otherwise,
|
|
407
|
+
// so the caller records a still-pending island as deferred — its outcome
|
|
408
|
+
// (mounted or failed) is appended to the result arrays on arrival. A rejected
|
|
409
|
+
// chunk load OR a rejected data fetch is the same animal as a throwing mount —
|
|
410
|
+
// that island is dead, the page is not — so it routes to `onMountError`/`failed`.
|
|
411
|
+
const mountOne = (
|
|
412
|
+
entry: IslandMount,
|
|
413
|
+
def: ClientComponentDef,
|
|
414
|
+
container: Element,
|
|
415
|
+
): "now" | "later" => {
|
|
416
|
+
const bound = entry.bind !== undefined && Object.keys(entry.bind).length > 0;
|
|
417
|
+
|
|
418
|
+
if (def.component !== undefined && !bound) {
|
|
419
|
+
finish(entry, def.component, container, entry.props);
|
|
420
|
+
|
|
421
|
+
return "now";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// The chunk (lazy) and the data (binds) are independent — fetch them at the
|
|
425
|
+
// same time, not in sequence, so a lazy+bound island never waterfalls its
|
|
426
|
+
// own two requests. ADR 0009's lesson applied to data.
|
|
427
|
+
const componentReady: Promise<NonNullable<ClientComponentDef["component"]>> =
|
|
428
|
+
def.component === undefined ? def.load() : Promise.resolve(def.component);
|
|
429
|
+
|
|
430
|
+
Promise.all([componentReady, resolveBinds(entry, bindTimeoutMs)]).then(
|
|
431
|
+
([component, data]) => finish(entry, component, container, { ...entry.props, ...data }),
|
|
432
|
+
(error: unknown) => {
|
|
433
|
+
onMountError(error, { id: entry.id, component: entry.component });
|
|
434
|
+
|
|
435
|
+
failed.push(entry.id);
|
|
436
|
+
},
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
return "later";
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
for (const entry of manifest) {
|
|
443
|
+
const def = registry.getClient(entry.component);
|
|
444
|
+
|
|
445
|
+
if (def === undefined) {
|
|
446
|
+
// Deploy skew, not a build bug: both manifest forms reach the client inside
|
|
447
|
+
// a possibly CDN-cached document, so a renamed-and-redeployed island leaves
|
|
448
|
+
// stale pages naming a component this bundle no longer registers. Throwing
|
|
449
|
+
// would dark out every island after it in the manifest. Route it to the
|
|
450
|
+
// sink, record the id, and keep going — the page-resilience contract.
|
|
451
|
+
onMountError(
|
|
452
|
+
new UiError(
|
|
453
|
+
"UI_ISLAND_UNKNOWN_COMPONENT",
|
|
454
|
+
`island manifest names an unregistered client component "${entry.component}"`,
|
|
455
|
+
{ id: entry.id, component: entry.component },
|
|
456
|
+
),
|
|
457
|
+
{ id: entry.id, component: entry.component },
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
failed.push(entry.id);
|
|
461
|
+
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const container = root.querySelector(`[${ISLAND_ATTR}="${quoteAttrValue(entry.id)}"]`);
|
|
466
|
+
|
|
467
|
+
// No shell for this id: the page didn't render it. Skip, don't fail.
|
|
468
|
+
if (container === null) {
|
|
469
|
+
missing.push(entry.id);
|
|
470
|
+
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// A `"visible"` island defers its mount work until its region is seen. We
|
|
475
|
+
// observe the container and mount on first intersection; everything else
|
|
476
|
+
// (including an absent `strategy`) is the eager default and mounts now.
|
|
477
|
+
if (entry.strategy === "visible") {
|
|
478
|
+
deferred.push(entry.id);
|
|
479
|
+
|
|
480
|
+
// The runtime owns the one-shot: it mounts on the first `onVisible`,
|
|
481
|
+
// ignores any later ones (a real IntersectionObserver fires on every
|
|
482
|
+
// entry), and then disconnects to stop watching. Holding the guard and the
|
|
483
|
+
// teardown here keeps every observer — real or injected — behaving the same.
|
|
484
|
+
let mountedOnce = false;
|
|
485
|
+
|
|
486
|
+
const disconnect = observe(container, () => {
|
|
487
|
+
if (mountedOnce) return;
|
|
488
|
+
|
|
489
|
+
mountedOnce = true;
|
|
490
|
+
|
|
491
|
+
mountOne(entry, def, container);
|
|
492
|
+
|
|
493
|
+
disconnect();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// An eager island mounts now; a lazy one ("later") has its chunk in flight —
|
|
500
|
+
// report it deferred, exactly like a visible island whose mount is pending.
|
|
501
|
+
if (mountOne(entry, def, container) === "later") {
|
|
502
|
+
deferred.push(entry.id);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return { mounted, missing, failed, deferred };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Hydrate every island a `.page` self-described (ADR 0011): the document's
|
|
511
|
+
* default entry point.
|
|
512
|
+
*
|
|
513
|
+
* Where {@link hydrateIslands} takes a manifest array (the Registry path's single
|
|
514
|
+
* `#lesto-islands` script), this scans the document for the co-located mount
|
|
515
|
+
* scripts `defineIsland` emits — one `<script type="application/json"
|
|
516
|
+
* data-lesto-island-mount>` per island — parses each into an {@link IslandMount},
|
|
517
|
+
* and feeds the very same machinery (binds, strategies, mount resilience). The
|
|
518
|
+
* scan + parse is the only new step; everything downstream is unchanged, so a
|
|
519
|
+
* page-described island and a manifest-described island hydrate identically.
|
|
520
|
+
*
|
|
521
|
+
* A mount script that does not parse is skipped — its island keeps the
|
|
522
|
+
* server-painted fallback, graceful degradation rather than a thrown page — so a
|
|
523
|
+
* single corrupt script cannot dark out the rest, the same
|
|
524
|
+
* one-broken-region-cannot-take-the-page-down contract as the mount loop.
|
|
525
|
+
*/
|
|
526
|
+
export function hydrateDocumentIslands(
|
|
527
|
+
registry: Registry,
|
|
528
|
+
options: HydrateOptions = {},
|
|
529
|
+
): HydrationResult {
|
|
530
|
+
const root: IslandRoot = options.root ?? document;
|
|
531
|
+
|
|
532
|
+
const manifest: IslandMount[] = [];
|
|
533
|
+
|
|
534
|
+
for (const script of Array.from(root.querySelectorAll(`script[${ISLAND_MOUNT_ATTR}]`))) {
|
|
535
|
+
const text = script.textContent;
|
|
536
|
+
|
|
537
|
+
// An empty mount script has nothing to parse; skip it (the island keeps its
|
|
538
|
+
// fallback) rather than feed `JSON.parse` an empty string.
|
|
539
|
+
if (!text) continue;
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
manifest.push(JSON.parse(text) as IslandMount);
|
|
543
|
+
} catch {
|
|
544
|
+
// A corrupt mount script is skipped; its island keeps its fallback.
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return hydrateIslands(registry, manifest, options);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Escape an id for the *quoted* value of an attribute selector.
|
|
554
|
+
*
|
|
555
|
+
* Ids are tree paths like `$.children[0]`. Inside the double quotes of
|
|
556
|
+
* `[data-lesto-island="…"]`, the special grammar (`[`, `]`, `.`) is inert — only
|
|
557
|
+
* a literal `"` or `\` would break out of the string, so those alone are
|
|
558
|
+
* escaped. We do it by hand rather than reach for `CSS.escape`, which is not
|
|
559
|
+
* present in every runtime this code must run under (notably bare jsdom).
|
|
560
|
+
*/
|
|
561
|
+
function quoteAttrValue(value: string): string {
|
|
562
|
+
return value.replaceAll(/["\\]/g, "\\$&");
|
|
563
|
+
}
|