@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
|
@@ -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
|
+
}
|