@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.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Render-time data resolution — the canonical island's machinery (ADR 0012).
3
+ *
4
+ * The static delivery tier (`dataPrimerScript`, see `data.ts`) hands the client
5
+ * an unresolved `bind` + a parse-time primer: correct on a shared-cacheable
6
+ * document, but it arrives too late to feed an `ssr: true` island's SERVER
7
+ * markup. This module is the DYNAMIC tier: a per-request resolver that runs each
8
+ * bound source's loader DURING the render, so `defineIsland` can read the value
9
+ * with React's `use()` and render the island's real component with the data
10
+ * inlined — server markup is the per-user truth, the client hydrates it, zero
11
+ * extra requests.
12
+ *
13
+ * It supersedes and deletes ADR 0010's `resolveIslandData`, whose post-walk
14
+ * manifest mutation could never reach an `ssr: true` island's server render.
15
+ *
16
+ * Two pieces:
17
+ * - {@link createSourceResolver} — wraps a `load(name)` in a per-name memo, so
18
+ * one loader runs per distinct source per request, its promise shared by
19
+ * every island that binds it (ADR 0010 §2's parallel-batch/no-chaining
20
+ * semantics, now compatible with streaming: one run, started on first use).
21
+ * - {@link IslandDataProvider} / {@link IslandDataContext} — carry the resolver
22
+ * down the server React tree; `defineIsland` reads it. Absent context means a
23
+ * static/prerender emission (the primer tier, or a loud refusal for
24
+ * `ssr: true` + `data`).
25
+ */
26
+
27
+ import { createContext } from "react";
28
+ import type { FC, ReactNode } from "react";
29
+
30
+ /** Resolve a bound data source by name, memoized — one loader run per source per request. */
31
+ export interface SourceResolver {
32
+ resolve(source: string): PromiseLike<unknown>;
33
+ }
34
+
35
+ /**
36
+ * A thenable React's `use()` can read SYNCHRONOUSLY when its value is already
37
+ * known. React tracks a thenable's `status`/`value`; a pre-fulfilled one lets
38
+ * `use()` return without suspending, so a synchronous loader (estate's pure-HMAC
39
+ * session) renders under the non-streaming renderers (`renderToString`,
40
+ * `renderToStaticMarkup`) too, not only under Suspense.
41
+ */
42
+ interface FulfilledThenable<T> extends PromiseLike<T> {
43
+ status: "fulfilled";
44
+ value: T;
45
+ }
46
+
47
+ /**
48
+ * Wrap an already-known value as a fulfilled tracked thenable React reads
49
+ * synchronously. The `then` is deliberate — this IS a thenable React's `use()`
50
+ * consumes (it reads `status`/`value` for the sync return, and `then` is the
51
+ * promise contract for any async consumer) — so the no-thenable rule is silenced
52
+ * here on purpose.
53
+ */
54
+ function fulfilled<T>(value: T): FulfilledThenable<T> {
55
+ return {
56
+ status: "fulfilled",
57
+ value,
58
+ // eslint-disable-next-line unicorn/no-thenable
59
+ then(onFulfilled) {
60
+ return Promise.resolve(onFulfilled ? onFulfilled(value) : (value as never));
61
+ },
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Build a memoized {@link SourceResolver} over a `load(name)` function.
67
+ *
68
+ * The first `resolve(name)` runs `load(name)` and caches the result keyed by
69
+ * name; every later `resolve(name)` returns the same thenable, so two islands
70
+ * binding one source share a single loader run. Chaining still has no API — the
71
+ * loader receives only `name`, never another source's value (whatever request
72
+ * context it needs, the caller closed over when building `load`).
73
+ *
74
+ * A loader that returns a real promise is memoized as-is and `use()` instruments
75
+ * it the way React expects. A loader that returns a plain VALUE is stored as a
76
+ * pre-fulfilled tracked thenable so `use()` reads it synchronously — keeping sync
77
+ * loaders compatible with the buffered renderers and the tests simple.
78
+ */
79
+ export function createSourceResolver(
80
+ load: (source: string) => Promise<unknown> | unknown,
81
+ ): SourceResolver {
82
+ const memo = new Map<string, PromiseLike<unknown>>();
83
+
84
+ return {
85
+ resolve(source) {
86
+ const cached = memo.get(source);
87
+
88
+ if (cached !== undefined) return cached;
89
+
90
+ const loaded = load(source);
91
+
92
+ const thenable: PromiseLike<unknown> =
93
+ typeof (loaded as { then?: unknown } | null)?.then === "function"
94
+ ? (loaded as PromiseLike<unknown>)
95
+ : fulfilled(loaded);
96
+
97
+ memo.set(source, thenable);
98
+
99
+ return thenable;
100
+ },
101
+ };
102
+ }
103
+
104
+ /**
105
+ * The server-side context carrying the per-request {@link SourceResolver} to
106
+ * every `defineIsland` in the tree. `null` by default — its absence is the
107
+ * signal that this is a static/prerender emission (no render-time resolution),
108
+ * which `defineIsland`'s resolution rule branches on.
109
+ */
110
+ export const IslandDataContext = createContext<SourceResolver | null>(null);
111
+
112
+ /**
113
+ * Provide a {@link SourceResolver} to the page subtree it wraps (dynamic render
114
+ * only). `children` is optional so the provider may be created with positional
115
+ * children via `createElement` (the `.page` renderer's form) as well as JSX.
116
+ */
117
+ export const IslandDataProvider: FC<{ resolver: SourceResolver; children?: ReactNode }> = ({
118
+ resolver,
119
+ children,
120
+ }) => <IslandDataContext.Provider value={resolver}>{children}</IslandDataContext.Provider>;
package/src/data.ts ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Island data sources — declared data, framework-owned delivery (ADR 0010).
3
+ *
4
+ * An island gets its per-request data through a typed, implementation-free
5
+ * TOKEN shared between server and client. The server binds an implementation to
6
+ * the token (`lesto().data(source, loader)`); an island binds a prop to the
7
+ * token (`defineClient({ data: { session: sessionSource } })`); the component
8
+ * receives the value as a prop and is a pure function of it. There is no
9
+ * `fetch`-in-effect for the author to write, and therefore no waterfall to
10
+ * write — the whole point.
11
+ *
12
+ * The token carries only a NAME, a SCOPE (per-user vs shared — it picks the auto
13
+ * route's cache header, ADR 0010 §3a), and a phantom TYPE. It deliberately holds
14
+ * no implementation, so importing the shared token module from the client bundle
15
+ * (as the island registry does) drags no server code across the wire.
16
+ *
17
+ * Delivery is chosen by topology, never by the author:
18
+ * - dynamically rendered → the render-time resolver in `data-resolve.tsx`
19
+ * (ADR 0012) runs loaders DURING the render and `defineIsland` inlines the
20
+ * values straight into the island's props — feeding even an `ssr: true`
21
+ * island's server markup (the canonical island, 0 extra requests). This
22
+ * module owns only the STATIC tier;
23
+ * - static / prerendered with a server in the path → the document carries the
24
+ * unresolved bind plus the {@link dataPrimerScript} primer, which kicks the
25
+ * fetch at HTML-parse time (parallel with `client.js`), and the client
26
+ * hydration runtime awaits it (1 RTT, never the serial `doc → js → fetch`).
27
+ * A `hydrate: "visible"` island is EXCLUDED from the primer — its data is
28
+ * deferred to first intersection along with its mount, or `"visible"` would
29
+ * be defeated by a parse-time fetch.
30
+ */
31
+
32
+ import { UiError } from "./errors";
33
+
34
+ import type { IslandMount } from "./island";
35
+
36
+ /**
37
+ * Whether a source's value is per-user (`"private"`) or the same for everyone
38
+ * (`"shared"`). It is a MEANING only the author can know, so it is declared, not
39
+ * inferred — and it drives the cache header on the auto-exposed route (ADR 0010
40
+ * §3a): `private` → `no-store` (a per-user JSON GET with no cache header is a
41
+ * session leak waiting for a CDN), `shared` → revalidated-but-cacheable.
42
+ */
43
+ export type DataSourceScope = "private" | "shared";
44
+
45
+ /**
46
+ * A typed handle to a named data source — no implementation, just a name, a
47
+ * scope, and a phantom value type. `defineDataSource<User | null>("session")`
48
+ * names the source and pins what its loader returns / its bound prop receives.
49
+ */
50
+ export interface DataSource<T = unknown> {
51
+ readonly name: string;
52
+
53
+ /** Per-user or shared — drives the route's cache header. Defaults to `"private"`. */
54
+ readonly scope: DataSourceScope;
55
+
56
+ /** Phantom: carries the resolved value's type to the binding. Never present at runtime. */
57
+ readonly __value?: T;
58
+ }
59
+
60
+ /**
61
+ * A source name must be a single safe URL/identifier segment: it becomes a path
62
+ * segment (`/__lesto/data/<name>`) AND is embedded in the executable primer
63
+ * script, so anything outside this charset could break out of the route or the
64
+ * string literal. Locking the charset is what keeps the primer injection-safe
65
+ * without escaping gymnastics (the primer carries names + hrefs, never data).
66
+ */
67
+ const VALID_SOURCE_NAME = /^[a-zA-Z0-9_-]+$/;
68
+
69
+ /** The route prefix the framework auto-exposes every registered source under. */
70
+ export const DATA_ROUTE_PREFIX = "/__lesto/data/";
71
+
72
+ /** The endpoint a source's client fallback fetch hits — `/__lesto/data/<name>`. */
73
+ export function dataSourceHref(name: string): string {
74
+ return `${DATA_ROUTE_PREFIX}${name}`;
75
+ }
76
+
77
+ /**
78
+ * Declare a data source: a typed token bound to an implementation elsewhere
79
+ * (server) and to a prop elsewhere (island). The name is validated here so an
80
+ * unsafe one fails loudly at declaration, not as a broken route or a malformed
81
+ * primer at request time.
82
+ *
83
+ * `scope` declares whether the value is per-user (`"private"`, the default) or
84
+ * the same for every visitor (`"shared"`) — the meaning that picks the auto
85
+ * route's cache header (ADR 0010 §3a). Private-by-default keeps the dangerous
86
+ * configuration (a per-user value heuristically cached by a CDN) unrepresentable
87
+ * without a visible declaration.
88
+ */
89
+ export function defineDataSource<T>(
90
+ name: string,
91
+ options?: { scope?: DataSourceScope },
92
+ ): DataSource<T> {
93
+ if (!VALID_SOURCE_NAME.test(name)) {
94
+ throw new UiError(
95
+ "UI_INVALID_DATA_SOURCE_NAME",
96
+ `data source name "${name}" must match ${String(VALID_SOURCE_NAME)} (it is a URL segment and a script literal)`,
97
+ { name },
98
+ );
99
+ }
100
+
101
+ return { name, scope: options?.scope ?? "private" };
102
+ }
103
+
104
+ /** One bound prop on an island: which source feeds it, and where the client fetches it. */
105
+ export interface IslandBind {
106
+ source: string;
107
+
108
+ href: string;
109
+ }
110
+
111
+ /**
112
+ * Collect the distinct sources every PRIMED island on the page binds.
113
+ *
114
+ * A `hydrate: "visible"` island is excluded: its mount work — and so its data
115
+ * fetch — is deferred to first intersection (resolved then by the client's
116
+ * fallback fetch), or the whole point of `"visible"` is defeated by priming it
117
+ * at parse time (ADR 0010 corrections #4). A source bound by both an eager and a
118
+ * visible island still primes once: the eager mount keeps it in the map.
119
+ *
120
+ * Shared by the primer (which kicks one fetch per distinct source) and the
121
+ * server resolver (which runs one loader per distinct source) — a source bound
122
+ * by three islands is fetched/run once, not three times.
123
+ */
124
+ function distinctSources(manifest: readonly IslandMount[]): Map<string, string> {
125
+ const sources = new Map<string, string>();
126
+
127
+ for (const mount of manifest) {
128
+ if (mount.bind === undefined) continue;
129
+
130
+ if (mount.strategy === "visible") continue;
131
+
132
+ for (const bind of Object.values(mount.bind)) {
133
+ sources.set(bind.source, bind.href);
134
+ }
135
+ }
136
+
137
+ return sources;
138
+ }
139
+
140
+ /**
141
+ * Build the inline primer that starts every unresolved bind's fetch at
142
+ * HTML-parse time, so the data request runs parallel with `client.js` instead
143
+ * of waiting for it (ADR 0010 §3, the static default). Returns the script body
144
+ * (the caller wraps it in `<script>`); an empty string when the page has no
145
+ * unresolved binds, so a page without data emits no primer at all.
146
+ *
147
+ * The promise per source lands on `window.__lestoData[<name>]`, exactly what the
148
+ * hydration runtime awaits before mounting a bound island. The script embeds
149
+ * only framework-controlled, charset-validated names and hrefs (never data),
150
+ * so the JSON-encoded literals cannot break out of the `<script>`.
151
+ *
152
+ * Three properties the emitted body carries (ADR 0010 corrections #3, Seam 1 §3):
153
+ * - `w[name]=w[name]||fetch(...)` is IDEMPOTENT: two islands binding one source
154
+ * (each emitting its own primer) issue a single credentialed fetch, not two.
155
+ * - the `if(!r.ok)throw` rejects the stored promise on a 401/429 etc., so a
156
+ * JSON error body never becomes the island's prop value; `hydrateIslands`
157
+ * routes that rejection to `onMountError`/`failed` and the island keeps its
158
+ * fallback — correct for an unauthenticated visitor.
159
+ * - the trailing detached `.catch(function(){})` marks the rejection HANDLED so
160
+ * a failure that lands before hydration attaches its handler does not fire a
161
+ * spurious `unhandledrejection`. It does NOT swallow the error for the
162
+ * hydration runtime, which awaits the ORIGINAL stored promise (`w[name]`),
163
+ * not this detached branch.
164
+ */
165
+ export function dataPrimerScript(manifest: readonly IslandMount[]): string {
166
+ const sources = distinctSources(manifest);
167
+
168
+ if (sources.size === 0) return "";
169
+
170
+ const assignments = [...sources]
171
+ .map(([name, href]) => {
172
+ const w = `w[${JSON.stringify(name)}]`;
173
+
174
+ return (
175
+ `${w}=${w}||fetch(${JSON.stringify(href)},{credentials:"same-origin"})` +
176
+ `.then(function(r){if(!r.ok)throw new Error("lesto data "+r.status);return r.json()});` +
177
+ `${w}.catch(function(){})`
178
+ );
179
+ })
180
+ .join(";");
181
+
182
+ return `(function(){var w=window.__lestoData=window.__lestoData||{};${assignments};})()`;
183
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * `defineIsland` — a self-describing island usable directly in a `.page` (ADR 0011).
3
+ *
4
+ * Where the Registry/`UiNode` path collects every island into one page-wide
5
+ * `#lesto-islands` manifest emitted after the body, a `.page` is a plain React
6
+ * tree with nothing to walk. So an island describes ITSELF at render time: the
7
+ * component `defineIsland` returns emits, co-located in the stream, its marked
8
+ * shell, its own mount script, and (for bound data) its primer — siblings, not
9
+ * children, so the hydration container is exactly the shell and nothing else.
10
+ *
11
+ * export default defineIsland({ name: "Account", component: Account,
12
+ * fallback: AccountFallback, data: { session: sessionSource } });
13
+ * // in a page: <AccountIsland />
14
+ *
15
+ * Co-located emission is streaming-safe by construction: an island inside a late
16
+ * `<Suspense>` boundary flushes with its own mount script, so there is no
17
+ * "manifest after the body" ordering problem, and its primer fires the instant
18
+ * the parser reaches it. The client (`hydrateDocumentIslands`) scans the mount
19
+ * scripts and feeds the same `hydrateIslands` machinery — binds, strategies, and
20
+ * mount resilience all unchanged.
21
+ *
22
+ * The returned component carries its `.island` def so the build's client-entry
23
+ * synthesizer (`@lesto/assets`) and the client registry can read it from the
24
+ * island's module without a separate registration.
25
+ *
26
+ * ## Data resolution rule (ADR 0012, the canonical island)
27
+ *
28
+ * The component reads {@link IslandDataContext}. What it does with a `data`
29
+ * declaration depends on whether a render-time resolver is in scope (dynamic
30
+ * page) and on the island's own `ssr`/`hydrate`:
31
+ *
32
+ * | resolver | ssr | hydrate | behavior |
33
+ * |----------|---------|-------------|-----------------------------------------------------|
34
+ * | present | `true` | any | resolve ALL sources via `use()`, inline into props, |
35
+ * | | | | no bind, no primer; shell = the REAL component WITH |
36
+ * | | | | the data (the canonical island — no fallback flash). |
37
+ * | present | falsy | ≠ `visible` | resolve + inline too (0 RTT; the client `createRoot`s|
38
+ * | | | | with complete props), no bind, no primer. |
39
+ * | present | falsy | `visible` | do NOT resolve (work deferred with the mount); emit |
40
+ * | | | | bind, no primer (item 2's filter); fetch-on-view. |
41
+ * | absent | `true` | (has data) | THROW `UI_ISLAND_SSR_DATA_UNRESOLVED` — on a static |
42
+ * | | | | doc this would inline per-user bytes or guarantee a |
43
+ * | | | | mismatch; the build fails (ADR 0012, by design). |
44
+ * | absent | falsy | any | today's behavior: bind + primer (the static tier). |
45
+ */
46
+
47
+ import { createElement, Fragment, useContext, useId } from "react";
48
+ import * as React from "react";
49
+ import type { ComponentType, ReactElement, ReactNode } from "react";
50
+
51
+ import { dataPrimerScript } from "./data";
52
+ import type { DataSource } from "./data";
53
+ import { IslandDataContext } from "./data-resolve";
54
+ import type { SourceResolver } from "./data-resolve";
55
+ import { assertClientDef, ISLAND_ATTR, ISLAND_MOUNT_ATTR } from "./island";
56
+ import type { ClientComponentDef, HydrationStrategy } from "./island";
57
+ import { islandMount } from "./mount";
58
+ import { serializeScriptJson } from "./serialize";
59
+ import type { PropSpec } from "./types";
60
+
61
+ /**
62
+ * A React component for a `.page`, carrying its island declaration for the build +
63
+ * client registry. Generic over `Rest` — the props the CALLER must still pass in
64
+ * JSX (the component's props minus the ones the framework resolves from data).
65
+ */
66
+ export interface IslandComponent<Rest extends Record<string, unknown> = Record<string, unknown>> {
67
+ (props: Rest): ReactElement;
68
+
69
+ /** The island's declaration — read by the entry synthesizer and the client registry. */
70
+ readonly island: ClientComponentDef;
71
+ }
72
+
73
+ /**
74
+ * Resolve a `data`-declared island's sources at render time, or decide not to,
75
+ * per the resolution-rule table in the module doc. Returns the `{ prop: value }`
76
+ * bag to inline (and so omit each resolved source's `bind`), or `undefined` to
77
+ * keep the static bind + primer behavior.
78
+ *
79
+ * `React.use()` may be called here in a loop and conditionally — it is the one
80
+ * React Hook that may. A real promise suspends the render (Suspense + the
81
+ * stream's 10s deadline own that); a sync loader's pre-fulfilled thenable reads
82
+ * synchronously.
83
+ *
84
+ * `use` is read off the React namespace, NOT a named import: under the
85
+ * preact-dialect client alias (`react → preact/compat`, ADR 0007) `preact/compat`
86
+ * exports no `use`, and a named `import { use }` would fail the client bundle —
87
+ * `define-island` rides the client graph via the `@lesto/ui` barrel. It is only
88
+ * ever *called* here, server-side (a resolver is in scope only under
89
+ * `renderPageResponse`'s provider; the client mounts the registered component
90
+ * directly and never renders this wrapper), where React is real and `use`
91
+ * exists. The proper fix is splitting the `@lesto/ui` barrel so server-only
92
+ * machinery leaves the client graph entirely (chief-architect review 2a); this
93
+ * namespace access is the contained unbreak until then.
94
+ */
95
+ function resolveData(
96
+ def: ClientComponentDef,
97
+ resolver: SourceResolver | null,
98
+ ): Record<string, unknown> | undefined {
99
+ if (def.data === undefined) return undefined;
100
+
101
+ if (resolver === null) {
102
+ // No render-time resolver in scope: nothing is inlined. A deferred island
103
+ // keeps today's bind + primer behavior (the static tier). An `ssr: true` +
104
+ // `data` island is refused — but the refusal now lives uniformly in
105
+ // `islandMount` (it sees the unresolved `bind` on an ssr mount and throws
106
+ // UI_ISLAND_SSR_DATA_UNRESOLVED), so BOTH the Registry and `.page` paths
107
+ // obey it by construction (review 2c). We simply return no inlined data.
108
+ return undefined;
109
+ }
110
+
111
+ // A `visible` (lazy-mount) island's data is deferred along with its mount, so
112
+ // it is NOT resolved at render even under a resolver — it keeps its bind and
113
+ // fetches on first intersection (the meaning of "visible").
114
+ if (def.ssr !== true && def.hydrate === "visible") return undefined;
115
+
116
+ // Resolve every bound source (memoized: two islands binding one source share a
117
+ // single loader run) and inline the values.
118
+ const inlined: Record<string, unknown> = {};
119
+
120
+ for (const [prop, source] of Object.entries(def.data)) {
121
+ inlined[prop] = React.use(resolver.resolve(source.name));
122
+ }
123
+
124
+ return inlined;
125
+ }
126
+
127
+ /**
128
+ * The typed declaration `defineIsland` accepts (review F8).
129
+ *
130
+ * `P` is the component's full props. `D` binds a SUBSET of those props to data
131
+ * sources, each `DataSource<P[K]>` — so binding a `DataSource<number>` to a
132
+ * `string` prop is a compile error (the token's phantom type finally reaches the
133
+ * component). `fallback` and the returned component see only `Omit<P, keyof D>` —
134
+ * the props NOT supplied by data — so the island component requires the unbound
135
+ * props and rejects the bound ones.
136
+ */
137
+ export interface IslandDef<
138
+ P extends Record<string, unknown>,
139
+ D extends { [K in keyof P]?: DataSource<P[K]> },
140
+ > {
141
+ name: string;
142
+ component: ComponentType<P>;
143
+ ssr?: boolean;
144
+ hydrate?: HydrationStrategy;
145
+ fallback?: (props: Omit<P, keyof D>) => ReactNode;
146
+ data?: D;
147
+ props?: Record<string, PropSpec>;
148
+ }
149
+
150
+ /**
151
+ * Wrap a typed island declaration into a `.page`-usable React component that
152
+ * self-emits its shell + mount script + data primer.
153
+ *
154
+ * The public signature is generic over the component's props (review F8): `data`
155
+ * is typed as `{ [K in keyof P]?: DataSource<P[K]> }` and the returned island
156
+ * component accepts the non-bound remainder `Omit<P, keyof D>`. Internally the
157
+ * def is cast ONCE to the erased {@link ClientComponentDef} — the same
158
+ * one-erasure-boundary precedent `lesto().page()` uses, because a React component
159
+ * is contravariant in its props, so a specific def is not directly assignable to
160
+ * the open one. `Registry.defineClient` typing stays deferred (ADR 0011
161
+ * Increment 2 / the `island.ts` doc): its storage is erased, its consumers
162
+ * stringly, so generics there are cosmetic until that path migrates.
163
+ *
164
+ * The shell is the `ssr: true` real render or the deferred `fallback`; the mount
165
+ * script and primer are SIBLINGS of the shell wrapper so the client's
166
+ * `createRoot`/`hydrateRoot` adopts only the shell (a script inside the
167
+ * container would mismatch hydration). A non-serializable prop throws out of the
168
+ * render exactly as the Registry path's does — the page's error boundary owns it.
169
+ */
170
+ export function defineIsland<
171
+ P extends Record<string, unknown>,
172
+ const D extends { [K in keyof P]?: DataSource<P[K]> } = Record<never, never>,
173
+ >(declaration: IslandDef<P, D>): IslandComponent<Omit<P, keyof D>> {
174
+ const def = declaration as unknown as ClientComponentDef;
175
+
176
+ // The `.page` path never passes through a Registry, so the union rules are
177
+ // enforced here at wrap time (module init): an un-typed caller can hand
178
+ // `defineIsland` a broken union too.
179
+ assertClientDef(def);
180
+
181
+ function Island(props: Record<string, unknown>): ReactElement {
182
+ const id = useId();
183
+
184
+ const resolver = useContext(IslandDataContext);
185
+
186
+ // Decide whether to resolve this island's data AT RENDER (the canonical,
187
+ // dynamic tier) and inline it, per the table in the module doc.
188
+ const resolved = resolveData(def, resolver);
189
+
190
+ const { mount, props: validated } = islandMount(def, props, id, resolved);
191
+
192
+ const shell: ReactNode =
193
+ mount.ssr && def.component !== undefined
194
+ ? createElement(def.component as ComponentType<Record<string, unknown>>, mount.props)
195
+ : (def.fallback?.(validated) as ReactNode);
196
+
197
+ const primer = mount.bind === undefined ? "" : dataPrimerScript([mount]);
198
+
199
+ return createElement(
200
+ Fragment,
201
+ null,
202
+ createElement("div", { [ISLAND_ATTR]: id }, shell),
203
+ createElement("script", {
204
+ type: "application/json",
205
+ [ISLAND_MOUNT_ATTR]: "",
206
+ // serializeScriptJson is the one audited escape; dangerouslySetInnerHTML
207
+ // is required because React would HTML-entity-escape text children, which
208
+ // is NOT decoded inside <script> and would corrupt the JSON.
209
+ dangerouslySetInnerHTML: { __html: serializeScriptJson(mount) },
210
+ }),
211
+ ...(primer === ""
212
+ ? []
213
+ : [createElement("script", { dangerouslySetInnerHTML: { __html: primer } })]),
214
+ );
215
+ }
216
+
217
+ Island.island = def;
218
+
219
+ // One erasure boundary: the runtime component takes the open props record; the
220
+ // public type narrows it to the unbound remainder. The cast lives only here.
221
+ return Island as unknown as IslandComponent<Omit<P, keyof D>>;
222
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Errors carry codes, not just prose.
3
+ *
4
+ * Every failure surfaces a stable, machine-readable `code`. Callers branch on
5
+ * the code — never on a message string, which is free to change for humans
6
+ * without breaking machines.
7
+ */
8
+
9
+ import { LestoError } from "@lesto/errors";
10
+
11
+ export { LestoError };
12
+
13
+ export type UiErrorCode =
14
+ | "UI_CLIENT_COMPONENT_MISSING"
15
+ | "UI_CLIENT_SSR_NEEDS_COMPONENT"
16
+ | "UI_INVALID_DATA_SOURCE_NAME"
17
+ | "UI_INVALID_ENUM_SPEC"
18
+ | "UI_ISLAND_DATA_FETCH_FAILED"
19
+ | "UI_ISLAND_DATA_TIMEOUT"
20
+ | "UI_ISLAND_PROPS_NOT_SERIALIZABLE"
21
+ | "UI_ISLAND_SSR_DATA_UNRESOLVED"
22
+ | "UI_ISLAND_UNKNOWN_COMPONENT"
23
+ | "UI_RESERVED_COMPONENT_NAME"
24
+ | "UI_ROUTE_MISSING_PARAM"
25
+ | "UI_SOFTNAV_PREFETCH_UNSUPPORTED"
26
+ | "UI_STREAM_INCOMPLETE"
27
+ | "UI_STREAM_TIMEOUT";
28
+
29
+ /** Anything the UI engine can refuse to do while building a schema. */
30
+ export class UiError extends LestoError<UiErrorCode> {
31
+ constructor(code: UiErrorCode, message: string, details?: Record<string, unknown>) {
32
+ super(code, message, details);
33
+
34
+ this.name = "UiError";
35
+ }
36
+ }