@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/render.tsx
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tree -> React (+ optionally, the island hydration manifest).
|
|
3
|
+
*
|
|
4
|
+
* The renderer turns a validated JSON tree into a React element against the
|
|
5
|
+
* registry. It degrades safely: an unknown or malformed node renders nothing
|
|
6
|
+
* (and is reported), and a component whose own `render()` throws is contained
|
|
7
|
+
* here — at build time — rather than crashing the whole tree. The renderer
|
|
8
|
+
* itself NEVER throws.
|
|
9
|
+
*
|
|
10
|
+
* Why contain the throw eagerly instead of leaning on a React error boundary?
|
|
11
|
+
* Because boundaries only fire during client reconciliation — server rendering
|
|
12
|
+
* (`renderToStaticMarkup`) lets a throw propagate straight out. A try/catch
|
|
13
|
+
* around each component's render is the one mechanism that holds on both sides.
|
|
14
|
+
*
|
|
15
|
+
* Islands ride the SAME walk. Where a node's `type` names a *client* component,
|
|
16
|
+
* the server cannot run it — it emits a marked wrapper element carrying an
|
|
17
|
+
* optional server `fallback`, and (when building a page) records the mount in a
|
|
18
|
+
* manifest the browser later hydrates. `renderTree` keeps its exact shape and
|
|
19
|
+
* behavior: an island renders its static fallback and is invisible to callers
|
|
20
|
+
* that never asked for a manifest. `renderPage` is the additive door to the
|
|
21
|
+
* manifest.
|
|
22
|
+
*
|
|
23
|
+
* SCOPE (ADR 0011 Increment 2 — the path convergence): this whole `UiNode`/
|
|
24
|
+
* Registry walk is the DEMOTED niche — the AI-/DB-driven content tree, where a
|
|
25
|
+
* `type` is a JSON string a model emitted and an island is collected into one
|
|
26
|
+
* page-wide `#lesto-islands` manifest. The CANONICAL island path is `defineIsland`
|
|
27
|
+
* in a hand-authored `.page` (co-located mount scripts, `hydrateDocumentIslands`,
|
|
28
|
+
* the synthesized `@lesto/assets` client) — that path does NOT come through here.
|
|
29
|
+
* Both emit the byte-identical {@link IslandMount} (the shared `islandMount`
|
|
30
|
+
* author), so the one wire contract and the one set of island invariants serve
|
|
31
|
+
* both; only the EMISSION (page-wide array vs. co-located per island) differs.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { createElement, Fragment } from "react";
|
|
35
|
+
import type { ComponentType, ReactElement, ReactNode } from "react";
|
|
36
|
+
import { renderToStaticMarkup, renderToString } from "react-dom/server";
|
|
37
|
+
|
|
38
|
+
import { ISLAND_ATTR } from "./island";
|
|
39
|
+
import type { ClientComponentDef, IslandMount } from "./island";
|
|
40
|
+
import { islandMount } from "./mount";
|
|
41
|
+
import { isNodeObject } from "./node";
|
|
42
|
+
import { validateProps } from "./props";
|
|
43
|
+
import type { Registry } from "./registry";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One thing the renderer couldn't render, located by `path`.
|
|
47
|
+
*
|
|
48
|
+
* `type` is the stable diagnostic kind (`invalid_node`, `unknown_component`,
|
|
49
|
+
* `invalid_props`, `render_threw`) — branch on it, never on the prose. `detail`
|
|
50
|
+
* is a human-readable note (the offending component name, the prop-validation
|
|
51
|
+
* message); `cause` carries the actual thrown value for a `render_threw` (the
|
|
52
|
+
* component's own `Error`, or the `UI_ISLAND_PROPS_NOT_SERIALIZABLE` an island's
|
|
53
|
+
* bad prop raised), so an operator gets the real stack rather than a bare
|
|
54
|
+
* "something threw". Both are OPTIONAL — a plain `invalid_node` needs neither —
|
|
55
|
+
* and mirror `validate.ts`'s `TreeError.detail`, so the render walk and the pure
|
|
56
|
+
* tree walk report the same diagnostic shape.
|
|
57
|
+
*/
|
|
58
|
+
export interface RenderError {
|
|
59
|
+
path: string;
|
|
60
|
+
type: string;
|
|
61
|
+
detail?: string;
|
|
62
|
+
cause?: unknown;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The injectable server-render dialect: the two functions {@link renderPageMarkup}
|
|
67
|
+
* needs to serialize a built {@link Page} to body HTML.
|
|
68
|
+
*
|
|
69
|
+
* It is the SAME two-function surface `react-dom/server` exposes (`renderToString`
|
|
70
|
+
* + `renderToStaticMarkup`), narrowed to what this module calls — so the default
|
|
71
|
+
* is a thin pass-through and every existing caller is byte-for-byte unchanged.
|
|
72
|
+
*
|
|
73
|
+
* Why a seam at all? An `ssr: true` island ships its real server render into the
|
|
74
|
+
* shell for the client to `hydrateRoot`, and hydration only succeeds when the
|
|
75
|
+
* server- and client-emitted markup agree. React and Preact emit DIFFERENT
|
|
76
|
+
* hydration markup (notably how they delimit adjacent text segments), so a page
|
|
77
|
+
* whose client bundle is Preact (the opt-in `react`→`preact/compat` alias, ADR
|
|
78
|
+
* 0007) MUST be server-rendered by Preact too, or every `ssr: true` island
|
|
79
|
+
* mismatches on hydration. This seam lets the caller pick the dialect that matches
|
|
80
|
+
* its client; the default keeps the React dialect this engine has always emitted.
|
|
81
|
+
*
|
|
82
|
+
* It mirrors `hydrate.tsx`'s injectable `mount` seam: the thing that varies by
|
|
83
|
+
* runtime is injected, never reached for as a global, so both halves of the
|
|
84
|
+
* hydration contract are chosen by the same explicit decision.
|
|
85
|
+
*
|
|
86
|
+
* The `dialect` tag names which client this renderer's markup is meant to hydrate
|
|
87
|
+
* against (`"react"` / `"preact"`). It is the load-bearing half of ADR 0008's
|
|
88
|
+
* matched pair: the wiring that turns a single `ui.dialect` key into a client
|
|
89
|
+
* alias AND a server renderer compares this tag to the client dialect and refuses
|
|
90
|
+
* a mismatch (client Preact + server React) with a coded error, so the
|
|
91
|
+
* silently-mismatching hydration this seam exists to prevent can never be wired.
|
|
92
|
+
*/
|
|
93
|
+
export interface ServerRenderer {
|
|
94
|
+
/** Which client this renderer's markup hydrates against — the matched-pair tag. */
|
|
95
|
+
dialect: "react" | "preact";
|
|
96
|
+
renderToString(node: ReactElement): string;
|
|
97
|
+
renderToStaticMarkup(node: ReactElement): string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The default dialect: React's own `react-dom/server`. Statically imported (not
|
|
102
|
+
* lazy) because it is this engine's baseline renderer — the one the tests, the
|
|
103
|
+
* stream path, and the deploy all already use — so there is nothing to defer.
|
|
104
|
+
* Selecting a different dialect (e.g. {@link ./server-preact}) is the caller's
|
|
105
|
+
* explicit opt-in via {@link renderPageMarkup}'s `renderer` argument.
|
|
106
|
+
*/
|
|
107
|
+
export const reactServerRenderer: ServerRenderer = {
|
|
108
|
+
dialect: "react",
|
|
109
|
+
renderToStaticMarkup,
|
|
110
|
+
renderToString,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The mutable scratch a single render walk threads through itself: the errors it
|
|
115
|
+
* accumulates and, only when a page is being built, the island manifest it fills.
|
|
116
|
+
*/
|
|
117
|
+
interface Walk {
|
|
118
|
+
registry: Registry;
|
|
119
|
+
errors: RenderError[];
|
|
120
|
+
islands: IslandMount[] | undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Build the React element for a tree, plus the list of nodes that degraded. */
|
|
124
|
+
export function renderTree(
|
|
125
|
+
registry: Registry,
|
|
126
|
+
tree: unknown,
|
|
127
|
+
): { element: ReactElement | null; errors: RenderError[] } {
|
|
128
|
+
const walk: Walk = { registry, errors: [], islands: undefined };
|
|
129
|
+
|
|
130
|
+
const element = build(walk, tree, "$");
|
|
131
|
+
|
|
132
|
+
return { element, errors: walk.errors };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A fully built page: the HTML element tree plus the islands to hydrate. */
|
|
136
|
+
export interface Page {
|
|
137
|
+
element: ReactElement | null;
|
|
138
|
+
errors: RenderError[];
|
|
139
|
+
islands: IslandMount[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Render a tree AND collect its island hydration manifest in one walk.
|
|
144
|
+
*
|
|
145
|
+
* This is `renderTree` plus the wire payload: every island in the tree yields an
|
|
146
|
+
* `IslandMount { id, component, props }` whose `id` matches the `data-lesto-island`
|
|
147
|
+
* attribute on its wrapper, so the client can pair DOM to data. Server-only and
|
|
148
|
+
* additive — existing `renderTree` callers are untouched.
|
|
149
|
+
*/
|
|
150
|
+
export function renderPage(registry: Registry, tree: unknown): Page {
|
|
151
|
+
const islands: IslandMount[] = [];
|
|
152
|
+
|
|
153
|
+
const walk: Walk = { registry, errors: [], islands };
|
|
154
|
+
|
|
155
|
+
const element = build(walk, tree, "$");
|
|
156
|
+
|
|
157
|
+
return { element, errors: walk.errors, islands };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Serialize a built {@link Page} to its body HTML, choosing the React server
|
|
162
|
+
* renderer that the page's own hydration contract requires.
|
|
163
|
+
*
|
|
164
|
+
* This is where the framework's headline feature lives or dies. An `ssr: true`
|
|
165
|
+
* island ships its REAL server render into the shell so the client can
|
|
166
|
+
* `hydrateRoot` it — reuse the DOM, no re-render. But `hydrateRoot` matches the
|
|
167
|
+
* server tree to the client tree by walking *text-segment comment markers*
|
|
168
|
+
* (`<!-- -->`) that React emits between adjacent text children. `renderToString`
|
|
169
|
+
* emits those markers; `renderToStaticMarkup` deliberately STRIPS them (its
|
|
170
|
+
* markup is for documents that will never hydrate). Hydrating
|
|
171
|
+
* `renderToStaticMarkup` output therefore mismatches the instant a component
|
|
172
|
+
* renders two or more adjacent text segments under one parent (`'Hi, ', name`),
|
|
173
|
+
* firing `onRecoverableError` and forcing React to re-render the whole island —
|
|
174
|
+
* defeating `ssr: true` entirely. Single-text-child trees happen to survive,
|
|
175
|
+
* which is exactly the trap: the common shape (interpolated text) is the broken
|
|
176
|
+
* one.
|
|
177
|
+
*
|
|
178
|
+
* So the rule is mechanical, never a guess: **if any island in the manifest is
|
|
179
|
+
* `ssr: true`, the body MUST be rendered with `renderToString`** to keep the
|
|
180
|
+
* hydration markers the client needs. A page with no SSR islands (pure static,
|
|
181
|
+
* or only deferred `createRoot` islands whose shells are mounted fresh and never
|
|
182
|
+
* hydrated) uses `renderToStaticMarkup` — smaller, marker-free output, and the
|
|
183
|
+
* deferred shells are replaced wholesale so their markers are irrelevant.
|
|
184
|
+
*
|
|
185
|
+
* A page whose element degraded to `null` has no body to render.
|
|
186
|
+
*
|
|
187
|
+
* The `renderer` is the dialect seam. It DEFAULTS to {@link reactServerRenderer}
|
|
188
|
+
* (real `react-dom/server`), so every existing caller — estate's `document.ts`,
|
|
189
|
+
* the render/hydrate/stream tests — is byte-for-byte unchanged. A page whose
|
|
190
|
+
* client bundle is Preact passes the Preact adapter (`@lesto/ui/server`) so
|
|
191
|
+
* the server emits the same markup the Preact client will hydrate against; only
|
|
192
|
+
* then is an `ssr: true` island safe under the `preact/compat` alias.
|
|
193
|
+
*/
|
|
194
|
+
export function renderPageMarkup(
|
|
195
|
+
page: Page,
|
|
196
|
+
renderer: ServerRenderer = reactServerRenderer,
|
|
197
|
+
): string {
|
|
198
|
+
if (page.element === null) return "";
|
|
199
|
+
|
|
200
|
+
// The manifest is the single source of truth for which renderer is safe: it is
|
|
201
|
+
// the same data the client reads to decide hydrate-vs-mount, so server and
|
|
202
|
+
// client can never disagree about whether markers were emitted. WHICH dialect
|
|
203
|
+
// emits those markers is the `renderer`'s business; this rule (markers iff any
|
|
204
|
+
// ssr island) is identical across dialects.
|
|
205
|
+
const needsHydrationMarkers = page.islands.some((island) => island.ssr);
|
|
206
|
+
|
|
207
|
+
return needsHydrationMarkers
|
|
208
|
+
? renderer.renderToString(page.element)
|
|
209
|
+
: renderer.renderToStaticMarkup(page.element);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Recursively build one node into a React element, collecting render errors. */
|
|
213
|
+
function build(walk: Walk, node: unknown, path: string): ReactElement | null {
|
|
214
|
+
// A bare string leaf becomes a text node, wrapped so the return type stays a
|
|
215
|
+
// uniform ReactElement (callers get ReactElement | null, never raw strings).
|
|
216
|
+
if (typeof node === "string") {
|
|
217
|
+
return createElement(Fragment, { key: path }, node);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Malformed: not a string, not a node object. Render nothing, report it.
|
|
221
|
+
if (!isNodeObject(node)) {
|
|
222
|
+
walk.errors.push({ path, type: "invalid_node", detail: "node must be a string or an object" });
|
|
223
|
+
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// An island short-circuits the server component path: the client owns it.
|
|
228
|
+
const client = walk.registry.getClient(node.type);
|
|
229
|
+
|
|
230
|
+
if (client !== undefined) {
|
|
231
|
+
return buildIsland(walk, client, node.props ?? {}, path);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const def = walk.registry.get(node.type);
|
|
235
|
+
|
|
236
|
+
// Unknown component: nothing vetted to render. Degrade to nothing.
|
|
237
|
+
if (def === undefined) {
|
|
238
|
+
walk.errors.push({ path, type: "unknown_component", detail: node.type });
|
|
239
|
+
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Validate props as the pure `validateTree` walk does, but here in the render
|
|
244
|
+
// path too: a missing-required or out-of-enum prop was previously DROPPED on
|
|
245
|
+
// the floor (only the parallel `validateTree` reported it), so a renderer-only
|
|
246
|
+
// caller got a silently-degraded component with no diagnostic. Surface each as
|
|
247
|
+
// an `invalid_props` (the same kind `validate.ts` emits), then render with the
|
|
248
|
+
// reconciled bag — the render still proceeds (defaults applied, the bad value
|
|
249
|
+
// dropped), it is just no longer silent.
|
|
250
|
+
const { props, errors: propErrors } = validateProps(def.props, node.props ?? {});
|
|
251
|
+
|
|
252
|
+
for (const detail of propErrors) {
|
|
253
|
+
walk.errors.push({ path, type: "invalid_props", detail });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const childNodes: ReactNode = (node.children ?? []).map((child, index) =>
|
|
257
|
+
build(walk, child, `${path}.children[${index}]`),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
// Contain the component's own render: a throw becomes a reported diagnostic,
|
|
261
|
+
// never an exception that escapes the walk.
|
|
262
|
+
return safeRender(def.render, props, childNodes, path, walk.errors);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Build an island's server footprint: a marked wrapper element holding either the
|
|
267
|
+
* fallback (deferred island) or the component's real output (an `ssr` island),
|
|
268
|
+
* plus (when a page is being built) its manifest entry.
|
|
269
|
+
*
|
|
270
|
+
* Props are validated against the client schema and asserted serializable; a
|
|
271
|
+
* non-serializable prop is contained as a `render_threw` diagnostic carrying the
|
|
272
|
+
* raised `UiError` as its `cause`, exactly like a server component that throws,
|
|
273
|
+
* so the island degrades to nothing rather than crashing the surrounding page.
|
|
274
|
+
*
|
|
275
|
+
* The shell contents are the crux of the hydration contract. A deferred island
|
|
276
|
+
* (`ssr` falsy) holds only the fallback — the client mounts the live component
|
|
277
|
+
* fresh, so the two need not match. An `ssr` island holds the component's actual
|
|
278
|
+
* server render, which the client then `hydrateRoot`s — the markup MUST match, so
|
|
279
|
+
* the same `component` runs on both sides with the same wire props. The `ssr`
|
|
280
|
+
* flag rides into the manifest so the client picks the right mount without
|
|
281
|
+
* guessing.
|
|
282
|
+
*/
|
|
283
|
+
function buildIsland(
|
|
284
|
+
walk: Walk,
|
|
285
|
+
client: ClientComponentDef,
|
|
286
|
+
rawProps: Record<string, unknown>,
|
|
287
|
+
path: string,
|
|
288
|
+
): ReactElement | null {
|
|
289
|
+
try {
|
|
290
|
+
// The mount shape (validated + serializable props, ssr, the optional
|
|
291
|
+
// strategy/bind) is authored once in `islandMount`, shared with the `.page`
|
|
292
|
+
// path's `defineIsland` so the two emit byte-identical wire entries. Building
|
|
293
|
+
// it is the serialize guard `buildIsland` contains — a non-serializable prop
|
|
294
|
+
// throws here and the island is reported, never crashing the page render.
|
|
295
|
+
const { mount, props } = islandMount(client, rawProps, path);
|
|
296
|
+
|
|
297
|
+
// Only a page build cares about the manifest; `renderTree` leaves it absent.
|
|
298
|
+
walk.islands?.push(mount);
|
|
299
|
+
|
|
300
|
+
// An `ssr` island ships its REAL output (the markup the client will hydrate
|
|
301
|
+
// and find unchanged); a deferred island ships only its fallback placeholder.
|
|
302
|
+
//
|
|
303
|
+
// The ssr component is placed LAZILY (`createElement`, not an eager call):
|
|
304
|
+
// an island's `component` is a full React component that may use hooks, so it
|
|
305
|
+
// can only be run by React's renderer (during the caller's
|
|
306
|
+
// `renderToStaticMarkup`), never invoked as a plain function here. This keeps
|
|
307
|
+
// the renderer's invariant honest — *building* the element tree never throws;
|
|
308
|
+
// the island's own render runs when React renders the tree.
|
|
309
|
+
const contents: ReactNode = mount.ssr
|
|
310
|
+
? createElement(client.component as ComponentType<Record<string, unknown>>, mount.props)
|
|
311
|
+
: (client.fallback?.(props) as ReactNode);
|
|
312
|
+
|
|
313
|
+
return createElement("div", { key: path, [ISLAND_ATTR]: path }, contents);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
// Carry the actual throw (an island's `UI_ISLAND_PROPS_NOT_SERIALIZABLE`, or
|
|
316
|
+
// a coded `UI_ISLAND_SSR_DATA_UNRESOLVED`) as `cause` so an operator sees the
|
|
317
|
+
// real error, plus its message as `detail` — instead of a bare "threw".
|
|
318
|
+
walk.errors.push({ path, type: "render_threw", detail: errorDetail(error), cause: error });
|
|
319
|
+
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** A human-readable note for a thrown value: its `.message` if it has one, else its `String`. */
|
|
325
|
+
function errorDetail(error: unknown): string {
|
|
326
|
+
return error instanceof Error ? error.message : String(error);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Invoke a component's render, containing any throw as a reported error. */
|
|
330
|
+
function safeRender(
|
|
331
|
+
render: (props: Record<string, unknown>, children: ReactNode) => ReactElement,
|
|
332
|
+
props: Record<string, unknown>,
|
|
333
|
+
children: ReactNode,
|
|
334
|
+
path: string,
|
|
335
|
+
errors: RenderError[],
|
|
336
|
+
): ReactElement | null {
|
|
337
|
+
try {
|
|
338
|
+
const element = render(props, children);
|
|
339
|
+
|
|
340
|
+
// Re-key so React can place sibling elements without a missing-key warning.
|
|
341
|
+
return createElement(Fragment, { key: path }, element);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
// The component's own throw, carried (not swallowed): `cause` is the real
|
|
344
|
+
// error for an operator's stack, `detail` its message for a quick read.
|
|
345
|
+
errors.push({ path, type: "render_threw", detail: errorDetail(error), cause: error });
|
|
346
|
+
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
package/src/resources.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource hints over React 19's native resource APIs.
|
|
3
|
+
*
|
|
4
|
+
* React 19 turns `preload`/`preinit`/`preconnect`/`prefetchDNS` (from `react-dom`)
|
|
5
|
+
* and document-metadata tags into real `<link>`/`<script>` elements during SSR —
|
|
6
|
+
* and it does so even under the framework's buffered `renderToStaticMarkup`
|
|
7
|
+
* render, hoisting them to the FRONT of the emitted markup. That is the whole
|
|
8
|
+
* Tier-0 win: the browser learns about the LCP image, fonts, and island bundles
|
|
9
|
+
* before it has finished parsing the body, with no streaming and no new transport.
|
|
10
|
+
*
|
|
11
|
+
* This module is a thin, Lesto-flavored seam over those primitives. Two reasons it
|
|
12
|
+
* exists rather than telling callers to import `react-dom` directly:
|
|
13
|
+
* - the `react-dom` resource functions are imperative side effects that only
|
|
14
|
+
* register a hint while a render is in flight; wrapping them keeps the import
|
|
15
|
+
* surface small and lets us inject a fake registrar in tests (no real render
|
|
16
|
+
* needed to prove we called the right thing with the right options);
|
|
17
|
+
* - the LCP-image and island-bundle conventions (`fetchpriority="high"`,
|
|
18
|
+
* `<link rel="modulepreload">`) are Lesto decisions, not React ones, and belong
|
|
19
|
+
* in one named place.
|
|
20
|
+
*
|
|
21
|
+
* The element-returning helpers (`lcpImage`, `modulePreload`) emit React elements
|
|
22
|
+
* the caller drops into the tree; React hoists the `<link>`s itself. The
|
|
23
|
+
* imperative helpers (`preload`/`preinit`/…) register a hint as a side effect and
|
|
24
|
+
* return nothing — call them from inside a component's render.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createElement } from "react";
|
|
28
|
+
import type { ReactElement } from "react";
|
|
29
|
+
import {
|
|
30
|
+
preconnect as reactPreconnect,
|
|
31
|
+
prefetchDNS as reactPrefetchDNS,
|
|
32
|
+
preinit as reactPreinit,
|
|
33
|
+
preinitModule as reactPreinitModule,
|
|
34
|
+
preload as reactPreload,
|
|
35
|
+
} from "react-dom";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The subset of React's resource API we lean on, named as one seam so a test can
|
|
39
|
+
* substitute a recorder and assert the exact calls without rendering. The shapes
|
|
40
|
+
* mirror `react-dom`'s own (intentionally loose option bags — React validates).
|
|
41
|
+
*/
|
|
42
|
+
export interface ResourceRegistrar {
|
|
43
|
+
preload: (href: string, options: PreloadOptions) => void;
|
|
44
|
+
preinit: (href: string, options: PreinitOptions) => void;
|
|
45
|
+
preinitModule: (href: string, options?: PreinitModuleOptions) => void;
|
|
46
|
+
preconnect: (href: string, options?: PreconnectOptions) => void;
|
|
47
|
+
prefetchDNS: (href: string) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Options for a `preload` hint — `as` is required; the rest mirror react-dom. */
|
|
51
|
+
export interface PreloadOptions {
|
|
52
|
+
as:
|
|
53
|
+
| "audio"
|
|
54
|
+
| "document"
|
|
55
|
+
| "embed"
|
|
56
|
+
| "fetch"
|
|
57
|
+
| "font"
|
|
58
|
+
| "image"
|
|
59
|
+
| "object"
|
|
60
|
+
| "script"
|
|
61
|
+
| "style"
|
|
62
|
+
| "track"
|
|
63
|
+
| "video"
|
|
64
|
+
| "worker";
|
|
65
|
+
crossOrigin?: "anonymous" | "use-credentials";
|
|
66
|
+
integrity?: string;
|
|
67
|
+
type?: string;
|
|
68
|
+
fetchPriority?: "high" | "low" | "auto";
|
|
69
|
+
imageSrcSet?: string;
|
|
70
|
+
imageSizes?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Options for a `preinit` hint — load AND execute/apply a script or stylesheet. */
|
|
74
|
+
export interface PreinitOptions {
|
|
75
|
+
as: "script" | "style";
|
|
76
|
+
precedence?: string;
|
|
77
|
+
crossOrigin?: "anonymous" | "use-credentials";
|
|
78
|
+
integrity?: string;
|
|
79
|
+
fetchPriority?: "high" | "low" | "auto";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Options for a `preinitModule` hint — load AND execute an ES module. */
|
|
83
|
+
export interface PreinitModuleOptions {
|
|
84
|
+
as?: "script";
|
|
85
|
+
crossOrigin?: "anonymous" | "use-credentials";
|
|
86
|
+
integrity?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Options for a `preconnect` hint — open the connection early. */
|
|
90
|
+
export interface PreconnectOptions {
|
|
91
|
+
crossOrigin?: "anonymous" | "use-credentials";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The real react-dom functions, the default registrar everywhere but tests. */
|
|
95
|
+
const reactRegistrar: ResourceRegistrar = {
|
|
96
|
+
preload: reactPreload,
|
|
97
|
+
preinit: reactPreinit,
|
|
98
|
+
preinitModule: reactPreinitModule,
|
|
99
|
+
preconnect: reactPreconnect,
|
|
100
|
+
prefetchDNS: reactPrefetchDNS,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Register a `preload` hint: fetch `href` early at the given priority, but do not
|
|
105
|
+
* execute it. The browser caches it for the moment the parser reaches the real
|
|
106
|
+
* reference. React hoists the resulting `<link rel="preload">` into the document.
|
|
107
|
+
*/
|
|
108
|
+
export function preload(
|
|
109
|
+
href: string,
|
|
110
|
+
options: PreloadOptions,
|
|
111
|
+
registrar: ResourceRegistrar = reactRegistrar,
|
|
112
|
+
): void {
|
|
113
|
+
registrar.preload(href, options);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Register a `preinit` hint: fetch AND execute/apply `href` early. Use it for a
|
|
118
|
+
* critical script or stylesheet whose effect the page needs as soon as possible.
|
|
119
|
+
*/
|
|
120
|
+
export function preinit(
|
|
121
|
+
href: string,
|
|
122
|
+
options: PreinitOptions,
|
|
123
|
+
registrar: ResourceRegistrar = reactRegistrar,
|
|
124
|
+
): void {
|
|
125
|
+
registrar.preinit(href, options);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Register a `preinitModule` hint: fetch AND execute an ES module early. This
|
|
130
|
+
* EXECUTES the module — for execute-free hint use {@link modulePreload} instead.
|
|
131
|
+
*/
|
|
132
|
+
export function preinitModule(
|
|
133
|
+
href: string,
|
|
134
|
+
options?: PreinitModuleOptions,
|
|
135
|
+
registrar: ResourceRegistrar = reactRegistrar,
|
|
136
|
+
): void {
|
|
137
|
+
// Forward `options` only when given: react-dom's signature makes it optional,
|
|
138
|
+
// and passing an explicit `undefined` under exactOptionalPropertyTypes is noise.
|
|
139
|
+
if (options === undefined) {
|
|
140
|
+
registrar.preinitModule(href);
|
|
141
|
+
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
registrar.preinitModule(href, options);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Register a `preconnect` hint: open the TCP+TLS connection to `href`'s origin
|
|
150
|
+
* before the first request to it, shaving the handshake off the critical path.
|
|
151
|
+
*/
|
|
152
|
+
export function preconnect(
|
|
153
|
+
href: string,
|
|
154
|
+
options?: PreconnectOptions,
|
|
155
|
+
registrar: ResourceRegistrar = reactRegistrar,
|
|
156
|
+
): void {
|
|
157
|
+
if (options === undefined) {
|
|
158
|
+
registrar.preconnect(href);
|
|
159
|
+
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
registrar.preconnect(href, options);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Register a `prefetchDNS` hint: resolve `href`'s DNS early. Cheaper than
|
|
168
|
+
* `preconnect` (no socket), for an origin you are likely — but not certain — to hit.
|
|
169
|
+
*/
|
|
170
|
+
export function prefetchDNS(href: string, registrar: ResourceRegistrar = reactRegistrar): void {
|
|
171
|
+
registrar.prefetchDNS(href);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Optional attributes for {@link lcpImage} beyond the load-bearing ones. */
|
|
175
|
+
export interface LcpImageProps {
|
|
176
|
+
src: string;
|
|
177
|
+
alt: string;
|
|
178
|
+
width?: number;
|
|
179
|
+
height?: number;
|
|
180
|
+
className?: string;
|
|
181
|
+
sizes?: string;
|
|
182
|
+
srcSet?: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The hero image, marked as the Largest Contentful Paint candidate.
|
|
187
|
+
*
|
|
188
|
+
* `fetchpriority="high"` tells the browser to prioritize this fetch over the
|
|
189
|
+
* default-`low` images; React 19 *also* auto-emits a `<link rel="preload"
|
|
190
|
+
* as="image" fetchpriority="high">` from an `<img fetchPriority>` during SSR, so
|
|
191
|
+
* the hint reaches the browser in the document head before the body is parsed —
|
|
192
|
+
* the single highest-ROI LCP lever. We never lazy-load the LCP image (`loading`
|
|
193
|
+
* stays eager) for the same reason: deferring the most important paint is a
|
|
194
|
+
* regression dressed as an optimization.
|
|
195
|
+
*/
|
|
196
|
+
export function lcpImage(props: LcpImageProps): ReactElement {
|
|
197
|
+
return createElement("img", {
|
|
198
|
+
src: props.src,
|
|
199
|
+
alt: props.alt,
|
|
200
|
+
// React 19 emits this in its DOM-property casing (`fetchPriority`), which the
|
|
201
|
+
// browser reads case-insensitively; it also auto-emits a `<link rel="preload"
|
|
202
|
+
// as="image">` from an `<img fetchPriority>`. Marking the LCP image is the one
|
|
203
|
+
// place this attribute earns its keep.
|
|
204
|
+
fetchPriority: "high",
|
|
205
|
+
// Spell the load behavior out: an eager LCP image is the point.
|
|
206
|
+
loading: "eager",
|
|
207
|
+
decoding: "async",
|
|
208
|
+
...(props.width === undefined ? {} : { width: props.width }),
|
|
209
|
+
...(props.height === undefined ? {} : { height: props.height }),
|
|
210
|
+
...(props.className === undefined ? {} : { className: props.className }),
|
|
211
|
+
...(props.sizes === undefined ? {} : { sizes: props.sizes }),
|
|
212
|
+
...(props.srcSet === undefined ? {} : { srcSet: props.srcSet }),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A `<link rel="modulepreload">` for an island's client bundle.
|
|
218
|
+
*
|
|
219
|
+
* `modulepreload` fetches AND compiles a module graph WITHOUT executing it — the
|
|
220
|
+
* right hint for code that runs only after hydration. (Contrast `preinitModule`,
|
|
221
|
+
* which executes.) React hoists this `<link>` into the head, so the island's JS
|
|
222
|
+
* is parsed and ready the moment `hydrateIslands` reaches for it.
|
|
223
|
+
*/
|
|
224
|
+
export function modulePreload(
|
|
225
|
+
href: string,
|
|
226
|
+
crossOrigin?: "anonymous" | "use-credentials",
|
|
227
|
+
): ReactElement {
|
|
228
|
+
return createElement("link", {
|
|
229
|
+
rel: "modulepreload",
|
|
230
|
+
href,
|
|
231
|
+
...(crossOrigin === undefined ? {} : { crossOrigin }),
|
|
232
|
+
});
|
|
233
|
+
}
|
package/src/route.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `route(pattern, params)` — build a typed URL from a known route pattern.
|
|
3
|
+
*
|
|
4
|
+
* The compile-time-CHECKED companion to `<Link>`: where `href` only autocompletes
|
|
5
|
+
* (a typo still compiles), `route` CONSTRAINS its pattern argument to the app's
|
|
6
|
+
* registered route patterns — a typo'd or stale pattern is a `tsc` error — and types
|
|
7
|
+
* `params` from the pattern's `:segments` via `@lesto/router`'s `PathParams`. It
|
|
8
|
+
* returns a `string` assignable to a `<Link href>`, so the type-safe dynamic link is:
|
|
9
|
+
*
|
|
10
|
+
* <Link href={route("/lab/gallery/:id", { id: listing.id })}>{listing.title}</Link>
|
|
11
|
+
* route("/lab/gallery") // a param-less pattern takes no second argument
|
|
12
|
+
*
|
|
13
|
+
* This is the higher-value half of typed routing (ADR/`docs/plans/dx-parity.md`,
|
|
14
|
+
* Workstream 1 Increment 2): `<Link href>` made navigation route-AWARE; `route` makes
|
|
15
|
+
* a dynamic link route-SAFE. With no route codegen the pattern is any `string` and the
|
|
16
|
+
* params are unconstrained — the unchanged escape hatch, nothing breaks.
|
|
17
|
+
*
|
|
18
|
+
* Authored as a plain pure function with native string ops, so it stays in
|
|
19
|
+
* `@lesto/ui`'s isomorphic, react-free core alongside `<Link>` (its sibling on the
|
|
20
|
+
* link-authoring surface).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { UiError } from "./errors";
|
|
24
|
+
import type { KnownPatterns, ParamArgs } from "./routes";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The pattern argument: the app's known patterns when codegen has augmented
|
|
28
|
+
* `RegisteredRoutes`, else any `string` (unchanged). The `[KnownPatterns] extends
|
|
29
|
+
* [never]` tuple guards the empty case without distributing over the union.
|
|
30
|
+
*/
|
|
31
|
+
type PatternArg = [KnownPatterns] extends [never] ? string : KnownPatterns;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a URL from a route pattern, substituting each `:name` segment from `params`
|
|
35
|
+
* (URL-encoded). The pattern is constrained to the app's {@link KnownPatterns} and the
|
|
36
|
+
* params are typed via {@link ParamArgs} from the pattern's `:segments` — so a typo or
|
|
37
|
+
* a missing/mistyped param is a compile error.
|
|
38
|
+
*
|
|
39
|
+
* The runtime missing-param guard (a coded {@link UiError}) is unreachable through the
|
|
40
|
+
* types, but an untyped JS caller could trip it — mirrors `@lesto/client`'s
|
|
41
|
+
* `applyParams`, the same `:param`-substitution this is the navigation twin of.
|
|
42
|
+
*/
|
|
43
|
+
export function route<P extends PatternArg>(pattern: P, ...args: ParamArgs<P>): string {
|
|
44
|
+
const params = args[0] as Record<string, string | number> | undefined;
|
|
45
|
+
|
|
46
|
+
return pattern.replace(/:([A-Za-z0-9_]+)/g, (_match, name: string) => {
|
|
47
|
+
const value = params?.[name];
|
|
48
|
+
|
|
49
|
+
if (value === undefined) {
|
|
50
|
+
throw new UiError(
|
|
51
|
+
"UI_ROUTE_MISSING_PARAM",
|
|
52
|
+
`route "${pattern}" needs a value for ":${name}"`,
|
|
53
|
+
{
|
|
54
|
+
pattern,
|
|
55
|
+
param: name,
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return encodeURIComponent(String(value));
|
|
61
|
+
});
|
|
62
|
+
}
|