@lesto/ui 0.1.1 → 0.1.2
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 +4 -3
- package/src/client.ts +7 -0
- package/src/data-client.ts +391 -53
- package/src/data.ts +50 -7
- package/src/index.ts +35 -26
- package/src/live.ts +191 -0
- package/src/server.ts +27 -3
- package/src/softnav-contract.ts +6 -4
- package/src/softnav.ts +158 -34
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lesto/ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Lesto's AI-native UI rendering engine core — validate a JSON UI tree and render it to React against a vetted component registry.",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
7
8
|
"exports": {
|
|
8
9
|
".": {
|
|
9
10
|
"types": "./src/index.ts",
|
|
@@ -19,8 +20,8 @@
|
|
|
19
20
|
}
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
|
-
"@lesto/errors": "0.1.
|
|
23
|
-
"@lesto/router": "0.1.
|
|
23
|
+
"@lesto/errors": "0.1.2",
|
|
24
|
+
"@lesto/router": "0.1.2",
|
|
24
25
|
"react": "^19",
|
|
25
26
|
"react-dom": "^19"
|
|
26
27
|
},
|
package/src/client.ts
CHANGED
|
@@ -51,4 +51,11 @@ export type {
|
|
|
51
51
|
SoftNavKind,
|
|
52
52
|
SoftNavOptions,
|
|
53
53
|
SoftNavWindow,
|
|
54
|
+
SwapResult,
|
|
54
55
|
} from "./softnav";
|
|
56
|
+
|
|
57
|
+
// The `lesto dev` page-refresh hook (DX-parity R2): a saved `app/routes/*` file does a
|
|
58
|
+
// server re-render + DOM swap instead of a full reload. The synthesized dev entry calls
|
|
59
|
+
// `enableDevPageRefresh` so the CLI's live-reload client can refresh the page in place.
|
|
60
|
+
export { DEV_PAGE_REFRESH_GLOBAL, enableDevPageRefresh } from "./softnav";
|
|
61
|
+
export type { DevPageRefreshOptions } from "./softnav";
|
package/src/data-client.ts
CHANGED
|
@@ -1,27 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client data hooks — `useQuery` / `useMutation` over a tiny shared cache.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
4
|
+
* The client-local layer of the reactive data layer (ADR 0027 Phase 1): islands
|
|
5
|
+
* otherwise hand-roll `useState`+`useEffect` to fetch (a re-implemented loading/error
|
|
6
6
|
* machine per island, no sharing) and re-build a mutation client per submit. These
|
|
7
7
|
* hooks replace that with one cache that gives:
|
|
8
8
|
*
|
|
9
9
|
* - **in-flight dedupe** — N components asking for the same key while a request
|
|
10
10
|
* is in flight share ONE request, not N;
|
|
11
|
-
* - **an explicit-invalidation cache** —
|
|
12
|
-
* mutation
|
|
13
|
-
*
|
|
11
|
+
* - **an explicit-invalidation cache** — by KEY (`invalidate(key)`) or by **topic**:
|
|
12
|
+
* a mutation declares the topics it dirties (`invalidates: ["posts"]`) and a query
|
|
13
|
+
* subscribes to topics (`useQuery(key, fetcher, { topics: ["posts"] })`). The client
|
|
14
|
+
* keeps a **topic → keys** registry; invalidating a topic refetches every mounted
|
|
15
|
+
* reader registered under it. Still EXPLICIT — the writer names the topic; nothing is
|
|
16
|
+
* schema-inferred. The keyspaces are NOT unified (a `useQuery` key, a `@lesto/client`
|
|
17
|
+
* `"METHOD /path"` key, and a `defineDataSource` name stay independent); the **topic**
|
|
18
|
+
* is the addressable unit, decoupled from any key format — which is exactly what the
|
|
19
|
+
* later server-push phase targets.
|
|
20
|
+
* - **opt-in background revalidation** — `staleTime`, refetch-on-focus,
|
|
21
|
+
* refetch-on-reconnect, and a polling `refetchInterval`, with the focus/online/timer
|
|
22
|
+
* events behind an injected {@link RevalidationEnvironment} seam (so it is testable and
|
|
23
|
+
* SSR-safe, and absent when not opted into — the default behaviour is unchanged);
|
|
14
24
|
* - **`useMutation`** — `{ mutate, isPending, error, data }` with optimistic
|
|
15
|
-
* update + rollback
|
|
25
|
+
* update + rollback, a declarative `invalidates` (the topics to drop on success), and
|
|
26
|
+
* an `onSuccess` hook.
|
|
16
27
|
*
|
|
17
28
|
* What this is NOT (so the doc never over-promises): it is NOT the full reactive
|
|
18
|
-
* layer.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
29
|
+
* layer. Topic invalidation is EXPLICIT, never schema-INFERRED (a mutation declares its
|
|
30
|
+
* topics; the client does not derive them from the rows a write touched). There is no
|
|
31
|
+
* normalized `(table, pk)` store and no cache EVICTION — a key's snapshot + last-fetcher
|
|
32
|
+
* live for the page's lifetime (bounded by the distinct keys an app queries; fine for a
|
|
33
|
+
* per-session SPA). The push that makes a topic invalidation cross processes / tabs /
|
|
34
|
+
* clients (LISTEN/NOTIFY + browser fan-out) and durable storage are the later ADR 0027
|
|
35
|
+
* phases; the topic registry defined here is the seam they build on.
|
|
25
36
|
*
|
|
26
37
|
* Decoupled by design: the hooks never import `@lesto/client`. The caller passes a
|
|
27
38
|
* `fetcher` / `mutationFn` thunk (typically closing over a `createApi` /
|
|
@@ -64,9 +75,13 @@ export function serializeQueryKey(key: QueryKey): string {
|
|
|
64
75
|
* One instance backs every hook by default ({@link defaultQueryClient}); a test
|
|
65
76
|
* (or an app wanting an isolated cache) constructs its own and passes it via the
|
|
66
77
|
* hook's `client` option. Holds the published snapshots, the in-flight promises
|
|
67
|
-
* (for dedupe), the last fetcher per key (so `invalidate` can refetch),
|
|
78
|
+
* (for dedupe), the last fetcher per key (so `invalidate` can refetch), the
|
|
68
79
|
* per-key subscriber sets — kept OUT of the snapshot so a re-render is driven only
|
|
69
|
-
* by a value change
|
|
80
|
+
* by a value change — the per-key "last fetched at" stamp (for `staleTime`), and the
|
|
81
|
+
* **topic → keys** registry (for topic invalidation).
|
|
82
|
+
*
|
|
83
|
+
* `now` is injected for testability: a fake clock drives `staleTime` math without real
|
|
84
|
+
* time. It defaults to `Date.now`.
|
|
70
85
|
*/
|
|
71
86
|
export class QueryClient {
|
|
72
87
|
readonly #snapshots = new Map<string, QuerySnapshot<unknown>>();
|
|
@@ -77,6 +92,20 @@ export class QueryClient {
|
|
|
77
92
|
|
|
78
93
|
readonly #listeners = new Map<string, Set<() => void>>();
|
|
79
94
|
|
|
95
|
+
/** When `key` last became `success` — used by {@link isStale} for `staleTime`. */
|
|
96
|
+
readonly #fetchedAt = new Map<string, number>();
|
|
97
|
+
|
|
98
|
+
/** topic -> (key -> mount count): the readers registered to each topic. N mounted
|
|
99
|
+
* readers of one key under one topic register once; the key leaves the topic only
|
|
100
|
+
* when the last reader unmounts. The single source of truth for topic membership. */
|
|
101
|
+
readonly #topicKeyCounts = new Map<string, Map<string, number>>();
|
|
102
|
+
|
|
103
|
+
readonly #now: () => number;
|
|
104
|
+
|
|
105
|
+
constructor(now: () => number = Date.now) {
|
|
106
|
+
this.#now = now;
|
|
107
|
+
}
|
|
108
|
+
|
|
80
109
|
/** The current published snapshot for `key` — the shared idle one until it has one. */
|
|
81
110
|
getSnapshot(key: string): QuerySnapshot<unknown> {
|
|
82
111
|
return this.#snapshots.get(key) ?? IDLE_SNAPSHOT;
|
|
@@ -129,36 +158,36 @@ export class QueryClient {
|
|
|
129
158
|
|
|
130
159
|
this.#publish(key, { status: "loading", data: previous?.data });
|
|
131
160
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
this.#inflight.delete(key);
|
|
135
|
-
this.#publish(key, { status: "success", data });
|
|
136
|
-
|
|
137
|
-
return data;
|
|
138
|
-
},
|
|
139
|
-
(error: unknown) => {
|
|
140
|
-
this.#inflight.delete(key);
|
|
141
|
-
this.#publish(key, { status: "error", error });
|
|
142
|
-
|
|
143
|
-
throw error;
|
|
144
|
-
},
|
|
145
|
-
);
|
|
161
|
+
return this.#track(key, fetcher());
|
|
162
|
+
}
|
|
146
163
|
|
|
147
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Seed `key` from an ALREADY-in-flight request — the parse-time data primer
|
|
166
|
+
* (`window.__lestoData[source]`; see {@link hydrateQueryClient}). Registers the
|
|
167
|
+
* promise as `key`'s in-flight request so a `useQuery(key)` that mounts during
|
|
168
|
+
* hydration DEDUPES its own fetch onto it (no second network request) and paints
|
|
169
|
+
* the value the instant the parse-time fetch lands — no `JS → fetch → data`
|
|
170
|
+
* waterfall.
|
|
171
|
+
*
|
|
172
|
+
* Unlike {@link fetch} it does NOT remember the promise as the refetch fetcher: a
|
|
173
|
+
* one-shot primer must never be replayed by {@link invalidate} (that would re-serve
|
|
174
|
+
* the pre-write value); the real fetcher is recorded when the reader mounts. A
|
|
175
|
+
* no-op if `key` is already loading (a request is in flight) or already holds a
|
|
176
|
+
* value — priming never clobbers live state.
|
|
177
|
+
*/
|
|
178
|
+
prime(key: string, promise: Promise<unknown>): void {
|
|
179
|
+
if (this.#inflight.has(key) || this.getSnapshot(key).status === "success") return;
|
|
148
180
|
|
|
149
|
-
|
|
150
|
-
// STORED branch so it raises no `unhandledrejection`. The error is already on
|
|
151
|
-
// the snapshot, and a direct awaiter still sees the re-thrown rejection above.
|
|
152
|
-
promise.catch(() => {});
|
|
181
|
+
this.#publish(key, { status: "loading", data: this.#snapshots.get(key)?.data });
|
|
153
182
|
|
|
154
|
-
|
|
183
|
+
this.#track(key, promise);
|
|
155
184
|
}
|
|
156
185
|
|
|
157
186
|
/**
|
|
158
187
|
* Invalidate `key`: drop its cached value and refetch with its last fetcher, so
|
|
159
188
|
* every mounted `useQuery(key)` re-renders fresh. Explicit-only — a mutation
|
|
160
|
-
* names the keys it dirties; there is no inferred invalidation (a later
|
|
161
|
-
* key never fetched (no remembered fetcher) is simply reset to idle.
|
|
189
|
+
* names the keys (or topics) it dirties; there is no inferred invalidation (a later
|
|
190
|
+
* ADR 0027 phase). A key never fetched (no remembered fetcher) is simply reset to idle.
|
|
162
191
|
*/
|
|
163
192
|
invalidate(key: string): Promise<unknown> | undefined {
|
|
164
193
|
const fetcher = this.#fetchers.get(key);
|
|
@@ -172,10 +201,121 @@ export class QueryClient {
|
|
|
172
201
|
return this.fetch(key, fetcher);
|
|
173
202
|
}
|
|
174
203
|
|
|
175
|
-
/**
|
|
204
|
+
/**
|
|
205
|
+
* Register `key` as a reader of every `topic`, so a later {@link invalidateTopic}
|
|
206
|
+
* refetches it. Returns an unregister thunk (call on unmount). Reference-counted: M
|
|
207
|
+
* mounted readers of the same key+topic register once; the key leaves the topic only
|
|
208
|
+
* when the last unregisters. The registry holds only currently-mounted readers.
|
|
209
|
+
*/
|
|
210
|
+
registerTopics(key: string, topics: readonly string[]): () => void {
|
|
211
|
+
for (const topic of topics) {
|
|
212
|
+
let counts = this.#topicKeyCounts.get(topic);
|
|
213
|
+
|
|
214
|
+
if (counts === undefined) {
|
|
215
|
+
counts = new Map();
|
|
216
|
+
this.#topicKeyCounts.set(topic, counts);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return () => {
|
|
223
|
+
for (const topic of topics) {
|
|
224
|
+
const counts = this.#topicKeyCounts.get(topic);
|
|
225
|
+
|
|
226
|
+
if (counts === undefined) continue;
|
|
227
|
+
|
|
228
|
+
const remaining = (counts.get(key) ?? 0) - 1;
|
|
229
|
+
|
|
230
|
+
if (remaining > 0) {
|
|
231
|
+
counts.set(key, remaining);
|
|
232
|
+
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
counts.delete(key);
|
|
237
|
+
|
|
238
|
+
if (counts.size === 0) this.#topicKeyCounts.delete(topic);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Invalidate every key registered to `topic`. The seam the server-push phase targets. */
|
|
244
|
+
invalidateTopic(topic: string): Promise<void> {
|
|
245
|
+
return this.invalidateTopics([topic]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Invalidate every key registered to ANY of `topics` (a mutation's `invalidates`).
|
|
250
|
+
* A key dirtied by two topics is invalidated once. Resolves when every refetch settles.
|
|
251
|
+
*/
|
|
252
|
+
invalidateTopics(topics: readonly string[]): Promise<void> {
|
|
253
|
+
const keys = new Set<string>();
|
|
254
|
+
|
|
255
|
+
for (const topic of topics) {
|
|
256
|
+
const counts = this.#topicKeyCounts.get(topic);
|
|
257
|
+
|
|
258
|
+
if (counts !== undefined) for (const key of counts.keys()) keys.add(key);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const pending: Array<Promise<unknown>> = [];
|
|
262
|
+
|
|
263
|
+
for (const key of keys) {
|
|
264
|
+
const promise = this.invalidate(key);
|
|
265
|
+
|
|
266
|
+
if (promise !== undefined) pending.push(promise);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return Promise.all(pending).then(() => undefined);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether `key`'s cached value is older than `staleTimeMs` (by the injected clock).
|
|
274
|
+
* A key that never succeeded is considered stale. The hook guards its calls with a
|
|
275
|
+
* `success` check, so the caller controls when this matters.
|
|
276
|
+
*/
|
|
277
|
+
isStale(key: string, staleTimeMs: number): boolean {
|
|
278
|
+
const at = this.#fetchedAt.get(key);
|
|
279
|
+
|
|
280
|
+
return at === undefined ? true : this.#now() - at >= staleTimeMs;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Wire a request `promise` into `key`'s lifecycle: settle it to `success`/`error`,
|
|
285
|
+
* clear the in-flight slot, and store it for dedupe. Shared by {@link fetch} (which
|
|
286
|
+
* also remembers the fetcher) and {@link prime} (which does not). The STORED branch
|
|
287
|
+
* swallows its rejection so a deduped caller that never attaches a handler raises no
|
|
288
|
+
* `unhandledrejection`; a direct awaiter still sees the re-thrown rejection.
|
|
289
|
+
*/
|
|
290
|
+
#track(key: string, promise: Promise<unknown>): Promise<unknown> {
|
|
291
|
+
const tracked = promise.then(
|
|
292
|
+
(data) => {
|
|
293
|
+
this.#inflight.delete(key);
|
|
294
|
+
this.#publish(key, { status: "success", data });
|
|
295
|
+
|
|
296
|
+
return data;
|
|
297
|
+
},
|
|
298
|
+
(error: unknown) => {
|
|
299
|
+
this.#inflight.delete(key);
|
|
300
|
+
this.#publish(key, { status: "error", error });
|
|
301
|
+
|
|
302
|
+
throw error;
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
this.#inflight.set(key, tracked);
|
|
307
|
+
|
|
308
|
+
tracked.catch(() => {});
|
|
309
|
+
|
|
310
|
+
return tracked;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Publish a new snapshot for `key`, stamp its fetch time on success, and notify subscribers. */
|
|
176
314
|
#publish(key: string, snapshot: QuerySnapshot<unknown>): void {
|
|
177
315
|
this.#snapshots.set(key, snapshot);
|
|
178
316
|
|
|
317
|
+
if (snapshot.status === "success") this.#fetchedAt.set(key, this.#now());
|
|
318
|
+
|
|
179
319
|
const set = this.#listeners.get(key);
|
|
180
320
|
|
|
181
321
|
if (set !== undefined) for (const listener of set) listener();
|
|
@@ -185,6 +325,116 @@ export class QueryClient {
|
|
|
185
325
|
/** The cache every hook shares unless given its own `client` — the common case. */
|
|
186
326
|
export const defaultQueryClient = new QueryClient();
|
|
187
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Seed a {@link QueryClient} from the parse-time data primer, so a `useQuery(key)`
|
|
330
|
+
* that mounts during hydration paints the server-fetched value with no second request
|
|
331
|
+
* and no `JS → fetch → data` waterfall.
|
|
332
|
+
*
|
|
333
|
+
* The primer (`dataPrimerScript`, ADR 0010) kicks each `defineDataSource` fetch at
|
|
334
|
+
* HTML-parse time and stores the in-flight PROMISE on `window.__lestoData[source]`.
|
|
335
|
+
* This hands each such promise to {@link QueryClient.prime}, so the matching
|
|
336
|
+
* `useQuery` dedupes its mount-time fetch onto the already-running request.
|
|
337
|
+
*
|
|
338
|
+
* `sourceToKey` names the correspondence from a primer source name to the `useQuery`
|
|
339
|
+
* key its reader passes: the keyspaces are decoupled by design (a source name, a
|
|
340
|
+
* `@lesto/client` `"METHOD /path"`, and a `useQuery` key are independent), so the
|
|
341
|
+
* caller — which knows both — declares the mapping. Sources that were not primed are
|
|
342
|
+
* skipped. A no-op on the server (no `window`) and when nothing was primed; safe to
|
|
343
|
+
* call once during client setup, before mounting islands.
|
|
344
|
+
*/
|
|
345
|
+
export function hydrateQueryClient(
|
|
346
|
+
client: QueryClient,
|
|
347
|
+
sourceToKey: Readonly<Record<string, QueryKey>>,
|
|
348
|
+
): void {
|
|
349
|
+
if (typeof window === "undefined") return;
|
|
350
|
+
|
|
351
|
+
// The parse-time primer stores its in-flight promises here (declared globally by the
|
|
352
|
+
// hydration runtime). Read it through a local shape so this isomorphic core stays
|
|
353
|
+
// self-contained — it never depends on that ambient augmentation being in scope.
|
|
354
|
+
const primed = (window as { __lestoData?: Record<string, Promise<unknown>> }).__lestoData;
|
|
355
|
+
|
|
356
|
+
if (primed === undefined) return;
|
|
357
|
+
|
|
358
|
+
for (const [source, key] of Object.entries(sourceToKey)) {
|
|
359
|
+
const promise = primed[source];
|
|
360
|
+
|
|
361
|
+
if (promise !== undefined) client.prime(serializeQueryKey(key), promise);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The focus / online / timer events that drive background revalidation, injected so
|
|
367
|
+
* the hook stays testable (a fake env, no real time or DOM events) and SSR-safe (the
|
|
368
|
+
* methods are only ever called from a client-only effect, never during render).
|
|
369
|
+
*/
|
|
370
|
+
export interface RevalidationEnvironment {
|
|
371
|
+
/** Call `cb` when the tab regains focus / becomes visible. Returns an unsubscribe. */
|
|
372
|
+
onFocus(cb: () => void): () => void;
|
|
373
|
+
|
|
374
|
+
/** Call `cb` when the browser comes back online. Returns an unsubscribe. */
|
|
375
|
+
onReconnect(cb: () => void): () => void;
|
|
376
|
+
|
|
377
|
+
/** Call `cb` every `ms` until the returned cancel thunk runs (the poll loop). */
|
|
378
|
+
setInterval(cb: () => void, ms: number): () => void;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The default {@link RevalidationEnvironment} over real `window`/`document` events + timers. */
|
|
382
|
+
export const browserRevalidationEnvironment: RevalidationEnvironment = {
|
|
383
|
+
onFocus(cb) {
|
|
384
|
+
const onVisible = (): void => {
|
|
385
|
+
if (document.visibilityState === "visible") cb();
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
389
|
+
window.addEventListener("focus", cb);
|
|
390
|
+
|
|
391
|
+
return () => {
|
|
392
|
+
document.removeEventListener("visibilitychange", onVisible);
|
|
393
|
+
window.removeEventListener("focus", cb);
|
|
394
|
+
};
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
onReconnect(cb) {
|
|
398
|
+
window.addEventListener("online", cb);
|
|
399
|
+
|
|
400
|
+
return () => window.removeEventListener("online", cb);
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
setInterval(cb, ms) {
|
|
404
|
+
const id = setInterval(cb, ms);
|
|
405
|
+
|
|
406
|
+
return () => clearInterval(id);
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
/** Options for {@link useQuery} — cache selection, topic subscription, and revalidation. */
|
|
411
|
+
export interface QueryOptions {
|
|
412
|
+
/** The cache to read/write — defaults to the shared {@link defaultQueryClient}. */
|
|
413
|
+
client?: QueryClient;
|
|
414
|
+
|
|
415
|
+
/** Topics this query reads; invalidating any of them refetches this key while mounted. */
|
|
416
|
+
topics?: readonly string[];
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Treat a cached value younger than this (ms) as fresh. When set, a mount or a
|
|
420
|
+
* focus/reconnect event refetches only if the value is older. Absent ⇒ no
|
|
421
|
+
* staleness-driven refetch (the default; a cached success is never auto-refetched).
|
|
422
|
+
*/
|
|
423
|
+
staleTime?: number;
|
|
424
|
+
|
|
425
|
+
/** Refetch when the tab regains focus (gated by {@link QueryOptions.staleTime}). */
|
|
426
|
+
refetchOnWindowFocus?: boolean;
|
|
427
|
+
|
|
428
|
+
/** Refetch when the browser comes back online (gated by {@link QueryOptions.staleTime}). */
|
|
429
|
+
refetchOnReconnect?: boolean;
|
|
430
|
+
|
|
431
|
+
/** Poll: refetch every this-many ms while mounted (ignores `staleTime` — it is a poll). */
|
|
432
|
+
refetchInterval?: number;
|
|
433
|
+
|
|
434
|
+
/** The focus/online/timer seam — defaults to {@link browserRevalidationEnvironment}. */
|
|
435
|
+
environment?: RevalidationEnvironment;
|
|
436
|
+
}
|
|
437
|
+
|
|
188
438
|
/** What `useQuery` returns. `data` is undefined until the first success. */
|
|
189
439
|
export interface QueryResult<T> {
|
|
190
440
|
data: T | undefined;
|
|
@@ -207,24 +457,43 @@ export interface QueryResult<T> {
|
|
|
207
457
|
* `isLoading` is true while uncached or in flight. A FRESH mount of an uncached
|
|
208
458
|
* OR previously-errored key kicks a fetch — so navigating away and back to a
|
|
209
459
|
* listing that failed transiently retries (matching a plain mount-effect fetch),
|
|
210
|
-
* while a SUCCESS stays cached (the dedupe/cache win)
|
|
211
|
-
* mount / key-change (the effect
|
|
212
|
-
*
|
|
213
|
-
* {@link QueryResult.refetch}.
|
|
460
|
+
* while a SUCCESS stays cached (the dedupe/cache win) UNLESS a `staleTime` is set and
|
|
461
|
+
* the value has aged past it. The fetch fires once per mount / key-change (the effect
|
|
462
|
+
* deps are only `[client, keyStr, staleTime]`), never per render, so there is no retry
|
|
463
|
+
* storm; an in-mount retry is a manual {@link QueryResult.refetch}.
|
|
464
|
+
*
|
|
465
|
+
* Opt-in background revalidation ({@link QueryOptions}): `staleTime`,
|
|
466
|
+
* `refetchOnWindowFocus`, `refetchOnReconnect`, and a polling `refetchInterval`, all
|
|
467
|
+
* wired through the injected {@link RevalidationEnvironment}. None of it runs unless
|
|
468
|
+
* opted into — the default is the explicit-invalidation behaviour above. A query may
|
|
469
|
+
* also declare the `topics` it reads, so a mutation's topic invalidation refetches it.
|
|
214
470
|
*/
|
|
215
471
|
export function useQuery<T>(
|
|
216
472
|
key: QueryKey,
|
|
217
473
|
fetcher: () => Promise<T>,
|
|
218
|
-
options?:
|
|
474
|
+
options?: QueryOptions,
|
|
219
475
|
): QueryResult<T> {
|
|
220
476
|
const client = options?.client ?? defaultQueryClient;
|
|
221
477
|
const keyStr = serializeQueryKey(key);
|
|
222
478
|
|
|
479
|
+
const topics = options?.topics;
|
|
480
|
+
const staleTime = options?.staleTime;
|
|
481
|
+
const refetchOnWindowFocus = options?.refetchOnWindowFocus ?? false;
|
|
482
|
+
const refetchOnReconnect = options?.refetchOnReconnect ?? false;
|
|
483
|
+
const refetchInterval = options?.refetchInterval;
|
|
484
|
+
const environment = options?.environment ?? browserRevalidationEnvironment;
|
|
485
|
+
|
|
223
486
|
// The latest fetcher closure, read through a ref so `refetch`/the effect always
|
|
224
487
|
// run the current one without re-subscribing when the closure identity changes.
|
|
225
488
|
const fetcherRef = useRef(fetcher);
|
|
226
489
|
fetcherRef.current = fetcher;
|
|
227
490
|
|
|
491
|
+
// Topics through a ref so the registration effect re-runs only when the topic SET
|
|
492
|
+
// changes (keyed by `topicsKey`), not on every render's fresh array identity.
|
|
493
|
+
const topicsRef = useRef(topics);
|
|
494
|
+
topicsRef.current = topics;
|
|
495
|
+
const topicsKey = topics === undefined ? "" : topics.join("");
|
|
496
|
+
|
|
228
497
|
// One getSnapshot for both client and server reads (same idle/cached value), so
|
|
229
498
|
// there is a single function to cover and no SSR/CSR snapshot divergence.
|
|
230
499
|
const getSnapshot = useCallback(
|
|
@@ -241,14 +510,66 @@ export function useQuery<T>(
|
|
|
241
510
|
useEffect(() => {
|
|
242
511
|
// Fetch on a fresh mount when the key is uncached (`idle`) OR previously
|
|
243
512
|
// errored — so a remount retries a transient failure rather than showing a
|
|
244
|
-
// terminal stale error. A `
|
|
245
|
-
//
|
|
513
|
+
// terminal stale error. A cached `success` is left alone UNLESS a `staleTime`
|
|
514
|
+
// is set and the value has aged past it (then revalidate on mount).
|
|
246
515
|
const status = client.getSnapshot(keyStr).status;
|
|
247
516
|
|
|
248
|
-
if (
|
|
517
|
+
if (
|
|
518
|
+
status === "idle" ||
|
|
519
|
+
status === "error" ||
|
|
520
|
+
(status === "success" && staleTime !== undefined && client.isStale(keyStr, staleTime))
|
|
521
|
+
) {
|
|
249
522
|
void client.fetch(keyStr, () => fetcherRef.current());
|
|
250
523
|
}
|
|
251
|
-
}, [client, keyStr]);
|
|
524
|
+
}, [client, keyStr, staleTime]);
|
|
525
|
+
|
|
526
|
+
useEffect(() => {
|
|
527
|
+
// Register while mounted so a topic invalidation refetches this key; re-runs only
|
|
528
|
+
// when the topic SET changes (keyed by `topicsKey`, not the array's render identity).
|
|
529
|
+
const current = topicsRef.current;
|
|
530
|
+
|
|
531
|
+
if (current === undefined || current.length === 0) return undefined;
|
|
532
|
+
|
|
533
|
+
return client.registerTopics(keyStr, current);
|
|
534
|
+
}, [client, keyStr, topicsKey]);
|
|
535
|
+
|
|
536
|
+
useEffect(() => {
|
|
537
|
+
const offs: Array<() => void> = [];
|
|
538
|
+
|
|
539
|
+
// Revalidate on an event only when the cached value is success and stale (an
|
|
540
|
+
// absent `staleTime` means "always stale on the event" — i.e. refetch every time).
|
|
541
|
+
const revalidate = (): void => {
|
|
542
|
+
if (
|
|
543
|
+
client.getSnapshot(keyStr).status === "success" &&
|
|
544
|
+
client.isStale(keyStr, staleTime ?? 0)
|
|
545
|
+
) {
|
|
546
|
+
void client.fetch(keyStr, () => fetcherRef.current());
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
if (refetchOnWindowFocus) offs.push(environment.onFocus(revalidate));
|
|
551
|
+
if (refetchOnReconnect) offs.push(environment.onReconnect(revalidate));
|
|
552
|
+
|
|
553
|
+
if (refetchInterval !== undefined) {
|
|
554
|
+
offs.push(
|
|
555
|
+
environment.setInterval(() => {
|
|
556
|
+
void client.fetch(keyStr, () => fetcherRef.current());
|
|
557
|
+
}, refetchInterval),
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return () => {
|
|
562
|
+
for (const off of offs) off();
|
|
563
|
+
};
|
|
564
|
+
}, [
|
|
565
|
+
client,
|
|
566
|
+
keyStr,
|
|
567
|
+
staleTime,
|
|
568
|
+
refetchOnWindowFocus,
|
|
569
|
+
refetchOnReconnect,
|
|
570
|
+
refetchInterval,
|
|
571
|
+
environment,
|
|
572
|
+
]);
|
|
252
573
|
|
|
253
574
|
const refetch = useCallback(() => {
|
|
254
575
|
void client.fetch(keyStr, () => fetcherRef.current());
|
|
@@ -274,7 +595,16 @@ export interface MutationOptions<Input, Data> {
|
|
|
274
595
|
*/
|
|
275
596
|
onMutate?: (input: Input) => (() => void) | void;
|
|
276
597
|
|
|
277
|
-
/**
|
|
598
|
+
/**
|
|
599
|
+
* The topics this mutation dirties. On success the client invalidates every key
|
|
600
|
+
* registered to them — the declarative alternative to hand-calling `invalidate`.
|
|
601
|
+
*/
|
|
602
|
+
invalidates?: readonly string[];
|
|
603
|
+
|
|
604
|
+
/** The cache `invalidates` targets — defaults to the shared {@link defaultQueryClient}. */
|
|
605
|
+
client?: QueryClient;
|
|
606
|
+
|
|
607
|
+
/** Run after success (after `invalidates`) — for side effects beyond revalidation. */
|
|
278
608
|
onSuccess?: (data: Data, input: Input) => void;
|
|
279
609
|
|
|
280
610
|
/** Run after a failed request (after any rollback). */
|
|
@@ -302,8 +632,8 @@ export interface MutationResultApi<Input, Data> {
|
|
|
302
632
|
/**
|
|
303
633
|
* Manage one write: `mutate(input)` flips `isPending`, runs `mutationFn`, and lands
|
|
304
634
|
* the result on `data` or the throw on `error` (never re-thrown — the result-union
|
|
305
|
-
* style). Supports an optimistic `onMutate` (with rollback on failure)
|
|
306
|
-
* `
|
|
635
|
+
* style). Supports an optimistic `onMutate` (with rollback on failure), a declarative
|
|
636
|
+
* `invalidates` (the topics dropped on success), and an `onSuccess`/`onError` pair.
|
|
307
637
|
*
|
|
308
638
|
* `mutationFn` may itself return a discriminated result (e.g. a `@lesto/client`
|
|
309
639
|
* mutation's `{ ok, … }` union) — that union simply becomes `data`; throw inside
|
|
@@ -328,13 +658,21 @@ export function useMutation<Input, Data>(
|
|
|
328
658
|
const mutate = useCallback(async (input: Input): Promise<Data | undefined> => {
|
|
329
659
|
setState({ status: "pending" });
|
|
330
660
|
|
|
331
|
-
const
|
|
661
|
+
const opts = optionsRef.current;
|
|
662
|
+
const rollback = opts?.onMutate?.(input);
|
|
332
663
|
|
|
333
664
|
try {
|
|
334
665
|
const data = await fnRef.current(input);
|
|
335
666
|
|
|
336
667
|
setState({ status: "success", data });
|
|
337
|
-
|
|
668
|
+
|
|
669
|
+
// Declarative revalidation first (drop the dirtied topics' keys), then the
|
|
670
|
+
// success side-effect hook.
|
|
671
|
+
if (opts?.invalidates !== undefined && opts.invalidates.length > 0) {
|
|
672
|
+
void (opts.client ?? defaultQueryClient).invalidateTopics(opts.invalidates);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
opts?.onSuccess?.(data, input);
|
|
338
676
|
|
|
339
677
|
return data;
|
|
340
678
|
} catch (error) {
|
|
@@ -342,7 +680,7 @@ export function useMutation<Input, Data>(
|
|
|
342
680
|
if (typeof rollback === "function") rollback();
|
|
343
681
|
|
|
344
682
|
setState({ status: "error", error });
|
|
345
|
-
|
|
683
|
+
opts?.onError?.(error, input);
|
|
346
684
|
|
|
347
685
|
return undefined;
|
|
348
686
|
}
|
package/src/data.ts
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
* write — the whole point.
|
|
11
11
|
*
|
|
12
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),
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* route's cache header, ADR 0010 §3a), an ACCESS posture (whether a per-user
|
|
14
|
+
* source may be served without a guard chain — see {@link DataSourceAccess}), and
|
|
15
|
+
* a phantom TYPE. It deliberately holds no implementation, so importing the shared
|
|
16
|
+
* token module from the client bundle (as the island registry does) drags no
|
|
17
|
+
* server code across the wire.
|
|
16
18
|
*
|
|
17
19
|
* Delivery is chosen by topology, never by the author:
|
|
18
20
|
* - dynamically rendered → the render-time resolver in `data-resolve.tsx`
|
|
@@ -42,10 +44,36 @@ import type { IslandMount } from "./island";
|
|
|
42
44
|
*/
|
|
43
45
|
export type DataSourceScope = "private" | "shared";
|
|
44
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Whether a per-user (`scope: "private"`) source's auto-exposed route may be
|
|
49
|
+
* served WITHOUT a guard chain — the secure-by-default decision a private source
|
|
50
|
+
* must make (ADR 0010 §5a).
|
|
51
|
+
*
|
|
52
|
+
* - `"guarded"` (the default for a private source): the route serves per-user
|
|
53
|
+
* data, so it MUST be registered behind guards. `lesto().data(source, loader)`
|
|
54
|
+
* with neither a guard chain nor this posture relaxed throws
|
|
55
|
+
* `WEB_PRIVATE_DATA_UNGUARDED` at registration — before any request — so a
|
|
56
|
+
* private source cannot be shipped on the unguarded `/__lesto/data/<name>`
|
|
57
|
+
* route by omission. A file-route `middleware.ts` guards only the page
|
|
58
|
+
* document GET, never the separate data route, so the framework refuses to
|
|
59
|
+
* guess that "no guards" was intentional.
|
|
60
|
+
* - `"request-scoped"`: the explicit opt-out — the loader derives its result
|
|
61
|
+
* SOLELY from the caller's own request (its cookie / session / params), so an
|
|
62
|
+
* unguarded route leaks nothing across users (each caller sees only their own
|
|
63
|
+
* data). A `"who am I"` session source is the canonical case. Declaring it is
|
|
64
|
+
* a visible statement of that invariant, not a silent default.
|
|
65
|
+
*
|
|
66
|
+
* It is moot for a `scope: "shared"` source — shared data is the same for every
|
|
67
|
+
* visitor and publicly cacheable by construction, so its route is never guarded
|
|
68
|
+
* on these grounds and `access` is ignored.
|
|
69
|
+
*/
|
|
70
|
+
export type DataSourceAccess = "guarded" | "request-scoped";
|
|
71
|
+
|
|
45
72
|
/**
|
|
46
73
|
* A typed handle to a named data source — no implementation, just a name, a
|
|
47
|
-
* scope, and a phantom value type.
|
|
48
|
-
* names the source and pins what its
|
|
74
|
+
* scope, an access posture, and a phantom value type.
|
|
75
|
+
* `defineDataSource<User | null>("session")` names the source and pins what its
|
|
76
|
+
* loader returns / its bound prop receives.
|
|
49
77
|
*/
|
|
50
78
|
export interface DataSource<T = unknown> {
|
|
51
79
|
readonly name: string;
|
|
@@ -53,6 +81,13 @@ export interface DataSource<T = unknown> {
|
|
|
53
81
|
/** Per-user or shared — drives the route's cache header. Defaults to `"private"`. */
|
|
54
82
|
readonly scope: DataSourceScope;
|
|
55
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Whether a private source may serve unguarded — {@link DataSourceAccess}.
|
|
86
|
+
* Defaults to `"guarded"` (a private source must be guarded or explicitly
|
|
87
|
+
* declared `"request-scoped"`). Moot for a shared source.
|
|
88
|
+
*/
|
|
89
|
+
readonly access: DataSourceAccess;
|
|
90
|
+
|
|
56
91
|
/** Phantom: carries the resolved value's type to the binding. Never present at runtime. */
|
|
57
92
|
readonly __value?: T;
|
|
58
93
|
}
|
|
@@ -85,10 +120,18 @@ export function dataSourceHref(name: string): string {
|
|
|
85
120
|
* route's cache header (ADR 0010 §3a). Private-by-default keeps the dangerous
|
|
86
121
|
* configuration (a per-user value heuristically cached by a CDN) unrepresentable
|
|
87
122
|
* without a visible declaration.
|
|
123
|
+
*
|
|
124
|
+
* `access` declares whether a private source's route may serve UNGUARDED
|
|
125
|
+
* (`"guarded"`, the default, vs `"request-scoped"` — {@link DataSourceAccess}).
|
|
126
|
+
* It pairs with the scope default: a private source is both not-shared-cacheable
|
|
127
|
+
* AND not-servable-unguarded unless the author says otherwise, so the bypass
|
|
128
|
+
* (a per-user route the page's `middleware.ts` never reaches) is closed by
|
|
129
|
+
* default rather than by remembering to pass guards. It is recorded on the token
|
|
130
|
+
* here and enforced where the route is registered (`lesto().data(...)`).
|
|
88
131
|
*/
|
|
89
132
|
export function defineDataSource<T>(
|
|
90
133
|
name: string,
|
|
91
|
-
options?: { scope?: DataSourceScope },
|
|
134
|
+
options?: { scope?: DataSourceScope; access?: DataSourceAccess },
|
|
92
135
|
): DataSource<T> {
|
|
93
136
|
if (!VALID_SOURCE_NAME.test(name)) {
|
|
94
137
|
throw new UiError(
|
|
@@ -98,7 +141,7 @@ export function defineDataSource<T>(
|
|
|
98
141
|
);
|
|
99
142
|
}
|
|
100
143
|
|
|
101
|
-
return { name, scope: options?.scope ?? "private" };
|
|
144
|
+
return { name, scope: options?.scope ?? "private", access: options?.access ?? "guarded" };
|
|
102
145
|
}
|
|
103
146
|
|
|
104
147
|
/** One bound prop on an island: which source feeds it, and where the client fetches it. */
|
package/src/index.ts
CHANGED
|
@@ -57,7 +57,7 @@ export { islandMount } from "./mount";
|
|
|
57
57
|
// + `dataPrimerScript` are the STATIC-tier server delivery seams (the client half
|
|
58
58
|
// lives in `@lesto/ui/client`'s hydration runtime).
|
|
59
59
|
export { DATA_ROUTE_PREFIX, dataPrimerScript, dataSourceHref, defineDataSource } from "./data";
|
|
60
|
-
export type { DataSource, DataSourceScope, IslandBind } from "./data";
|
|
60
|
+
export type { DataSource, DataSourceAccess, DataSourceScope, IslandBind } from "./data";
|
|
61
61
|
|
|
62
62
|
// The render-time source resolver (ADR 0012): the DYNAMIC-tier delivery that runs
|
|
63
63
|
// loaders during the render and inlines the values — feeding the canonical
|
|
@@ -67,13 +67,17 @@ export { createSourceResolver, IslandDataContext, IslandDataProvider } from "./d
|
|
|
67
67
|
export type { SourceResolver } from "./data-resolve";
|
|
68
68
|
|
|
69
69
|
// Client data hooks (the first step of the reactive data layer, ADR 0027):
|
|
70
|
-
// `useQuery`/`useMutation` over one shared cache giving in-flight dedupe
|
|
71
|
-
// explicit
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
70
|
+
// `useQuery`/`useMutation` over one shared cache giving in-flight dedupe,
|
|
71
|
+
// explicit invalidation by key OR by topic (a mutation declares the topics it
|
|
72
|
+
// dirties; a query subscribes to them — still EXPLICIT, never schema-inferred), and
|
|
73
|
+
// opt-in background revalidation (staleTime / focus / reconnect / interval behind an
|
|
74
|
+
// injected RevalidationEnvironment). Decoupled from `@lesto/client` (the caller passes
|
|
75
|
+
// the fetcher/mutationFn thunk), so they stay in this isomorphic core; the fetch is
|
|
76
|
+
// kicked from an effect, so SSR never fetches.
|
|
75
77
|
export {
|
|
78
|
+
browserRevalidationEnvironment,
|
|
76
79
|
defaultQueryClient,
|
|
80
|
+
hydrateQueryClient,
|
|
77
81
|
QueryClient,
|
|
78
82
|
serializeQueryKey,
|
|
79
83
|
useMutation,
|
|
@@ -84,11 +88,29 @@ export type {
|
|
|
84
88
|
MutationResultApi,
|
|
85
89
|
MutationStatus,
|
|
86
90
|
QueryKey,
|
|
91
|
+
QueryOptions,
|
|
87
92
|
QueryResult,
|
|
88
93
|
QuerySnapshot,
|
|
89
94
|
QueryStatus,
|
|
95
|
+
RevalidationEnvironment,
|
|
90
96
|
} from "./data-client";
|
|
91
97
|
|
|
98
|
+
// The realtime consumer (ADR 0027 Phase 2 over the ADR 0040 transport): `connectLive`
|
|
99
|
+
// opens the app's `GET /__lesto/live` SSE stream and drives `QueryClient.invalidateTopic`
|
|
100
|
+
// so a mounted `useQuery` goes LIVE — a peer's write refetches it. `useLive` is the
|
|
101
|
+
// island hook that holds the subscription for the component's lifetime. The wire carries
|
|
102
|
+
// a topic, never row data (the ADR 0027 invariant), so this stays in the isomorphic core
|
|
103
|
+
// (SSR-safe: `EventSource` is touched only from a client effect); it drives the
|
|
104
|
+
// `QueryClient` seam and reads an `EventSource`, taking NO `@lesto/realtime` dependency.
|
|
105
|
+
export { browserLiveEnvironment, connectLive, useLive } from "./live";
|
|
106
|
+
export type {
|
|
107
|
+
ConnectLiveOptions,
|
|
108
|
+
LiveEnvironment,
|
|
109
|
+
LiveEventSource,
|
|
110
|
+
LiveMessageEvent,
|
|
111
|
+
UseLiveOptions,
|
|
112
|
+
} from "./live";
|
|
113
|
+
|
|
92
114
|
// The audited seam for inlining island JSON into a `<script>`: escapes the
|
|
93
115
|
// breakout characters `JSON.stringify` leaves raw. ALL island-manifest emission
|
|
94
116
|
// MUST go through this — never a bare stringify or a `String.replace` splice.
|
|
@@ -99,26 +121,13 @@ export type {
|
|
|
99
121
|
// `#lesto-islands` manifest.
|
|
100
122
|
export { serializeManifest, serializeScriptJson } from "./serialize";
|
|
101
123
|
|
|
102
|
-
// Resource hints + LCP/modulepreload conventions over React 19's native APIs
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
prefetchDNS,
|
|
110
|
-
preinit,
|
|
111
|
-
preinitModule,
|
|
112
|
-
preload,
|
|
113
|
-
} from "./resources";
|
|
114
|
-
export type {
|
|
115
|
-
LcpImageProps,
|
|
116
|
-
PreconnectOptions,
|
|
117
|
-
PreinitModuleOptions,
|
|
118
|
-
PreinitOptions,
|
|
119
|
-
PreloadOptions,
|
|
120
|
-
ResourceRegistrar,
|
|
121
|
-
} from "./resources";
|
|
124
|
+
// Resource hints + LCP/modulepreload conventions over React 19's native APIs
|
|
125
|
+
// (`lcpImage`/`modulePreload`/`preconnect`/…) are an SSR document-head concern and
|
|
126
|
+
// import the resource functions from bare `react-dom`, so they are NOT re-exported
|
|
127
|
+
// here — they live behind the `@lesto/ui/server` subpath. Keeping them off this
|
|
128
|
+
// isomorphic barrel means the client/island bundle never even references
|
|
129
|
+
// `react-dom`, so a stray future re-export can't drag it in (defense-in-depth over
|
|
130
|
+
// the `sideEffects:false` tree-shake that already drops the unused symbols).
|
|
122
131
|
|
|
123
132
|
// Document metadata helpers + the dedup convention React's hoist-without-dedupe
|
|
124
133
|
// leaves to the framework. Pure: React elements / data in, data out.
|
package/src/live.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `connectLive` / `useLive` — the browser consumer of the realtime SSE fan-out
|
|
3
|
+
* (ADR 0040 Phase B) that turns a plain {@link useQuery} into a **live** one.
|
|
4
|
+
*
|
|
5
|
+
* It opens the app's `GET /__lesto/live` Server-Sent-Events stream and maps each
|
|
6
|
+
* frame onto the {@link QueryClient}: an `invalidate` frame drops one topic (so a
|
|
7
|
+
* mounted `useQuery` refetches it through its own authorized read), and a `resync`
|
|
8
|
+
* frame refetches every subscribed topic (the always-correct floor when the server
|
|
9
|
+
* cannot prove continuity). The wire carries a **topic, never row data** (the ADR
|
|
10
|
+
* 0027 invariant); this file never sees a row.
|
|
11
|
+
*
|
|
12
|
+
* Decoupled from `@lesto/realtime` on purpose: this consumer only drives the
|
|
13
|
+
* `QueryClient` seam and reads an `EventSource`, so `@lesto/ui` keeps no realtime
|
|
14
|
+
* dependency. Opt-in and side-effect-free until called, so it tree-shakes away on a
|
|
15
|
+
* page that never goes live.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useEffect, useRef } from "react";
|
|
19
|
+
|
|
20
|
+
import { defaultQueryClient } from "./data-client";
|
|
21
|
+
import type { QueryClient } from "./data-client";
|
|
22
|
+
|
|
23
|
+
/** The reserved path the runtime recognizes as a long-lived stream (ADR 0040). */
|
|
24
|
+
const DEFAULT_LIVE_PATH = "/__lesto/live";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The slice of an SSE message event {@link connectLive} reads — just its `data`. A
|
|
28
|
+
* named `invalidate` event carries the topic here; a `resync` carries the empty
|
|
29
|
+
* string. Narrow on purpose, so a fake satisfies it without a real `EventSource`.
|
|
30
|
+
*/
|
|
31
|
+
export interface LiveMessageEvent {
|
|
32
|
+
readonly data: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The slice of an `EventSource` {@link connectLive} drives — a listener for the
|
|
37
|
+
* named events, and a close. The browser `EventSource` satisfies this through
|
|
38
|
+
* {@link browserLiveEnvironment}; a test injects a fake.
|
|
39
|
+
*/
|
|
40
|
+
export interface LiveEventSource {
|
|
41
|
+
addEventListener(
|
|
42
|
+
type: "invalidate" | "resync" | "error",
|
|
43
|
+
listener: (event: LiveMessageEvent) => void,
|
|
44
|
+
): void;
|
|
45
|
+
|
|
46
|
+
close(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Opens a {@link LiveEventSource} for a URL — the seam through which
|
|
51
|
+
* {@link connectLive} reaches `EventSource`. Injected so a test fakes the whole
|
|
52
|
+
* stream and importing this module stays SSR-safe: the global `EventSource` is
|
|
53
|
+
* touched only inside the default's `open`, never at import or during a render.
|
|
54
|
+
*/
|
|
55
|
+
export interface LiveEnvironment {
|
|
56
|
+
open(url: string): LiveEventSource;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The default {@link LiveEnvironment} over the browser's native `EventSource`. */
|
|
60
|
+
export const browserLiveEnvironment: LiveEnvironment = {
|
|
61
|
+
open(url) {
|
|
62
|
+
const source = new EventSource(url);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
addEventListener: (type, listener) =>
|
|
66
|
+
source.addEventListener(type, (event) => listener(event as MessageEvent)),
|
|
67
|
+
|
|
68
|
+
close: () => source.close(),
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Options for {@link connectLive}. */
|
|
74
|
+
export interface ConnectLiveOptions {
|
|
75
|
+
/** The topics to subscribe to; each is invalidated live as the server pushes it. */
|
|
76
|
+
readonly topics: readonly string[];
|
|
77
|
+
|
|
78
|
+
/** The cache to invalidate — defaults to the shared {@link defaultQueryClient}. */
|
|
79
|
+
readonly client?: QueryClient;
|
|
80
|
+
|
|
81
|
+
/** The live-stream path the app mounted. Defaults to `/__lesto/live`. */
|
|
82
|
+
readonly path?: string;
|
|
83
|
+
|
|
84
|
+
/** The `EventSource` seam — defaults to {@link browserLiveEnvironment}. */
|
|
85
|
+
readonly environment?: LiveEnvironment;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Notified of a stream `error` (a transient disconnect). Informational only:
|
|
89
|
+
* `EventSource` reconnects on its own — resuming from its last `id:` via the
|
|
90
|
+
* `Last-Event-ID` header — so a handler here need not reconnect, only observe.
|
|
91
|
+
*/
|
|
92
|
+
readonly onError?: (event: LiveMessageEvent) => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Open a live subscription: stream `(topic, cursor)` invalidations from the app's
|
|
97
|
+
* `GET /__lesto/live` SSE endpoint and drive {@link QueryClient.invalidateTopic}, so
|
|
98
|
+
* a mounted `useQuery` refetches the instant a peer's write dirties one of its topics
|
|
99
|
+
* — "live `useQuery`" (ADR 0027 Phase 2 over the ADR 0040 transport).
|
|
100
|
+
*
|
|
101
|
+
* `EventSource` handles reconnect and the resume cursor (`Last-Event-ID`) natively
|
|
102
|
+
* and ignores the heartbeat comments, so this consumer only maps the two named
|
|
103
|
+
* events onto the cache: an `invalidate` frame's `data` is the single topic to drop;
|
|
104
|
+
* a `resync` frame refetches every subscribed topic.
|
|
105
|
+
*
|
|
106
|
+
* Unauthorized topics are **not** an error here: the server drops them silently — no
|
|
107
|
+
* delivery, no timing signal (ADR 0040's `selectAuthorizedTopics`) — so a client that
|
|
108
|
+
* asks for a topic it may not see simply never receives it, and this consumer never
|
|
109
|
+
* learns the difference.
|
|
110
|
+
*
|
|
111
|
+
* Returns a disconnect thunk — call it on unmount (or use {@link useLive}, which
|
|
112
|
+
* does). Framework-agnostic; the React lifetime lives in {@link useLive}.
|
|
113
|
+
*/
|
|
114
|
+
export function connectLive(options: ConnectLiveOptions): () => void {
|
|
115
|
+
const client = options.client ?? defaultQueryClient;
|
|
116
|
+
const path = options.path ?? DEFAULT_LIVE_PATH;
|
|
117
|
+
const environment = options.environment ?? browserLiveEnvironment;
|
|
118
|
+
const { topics, onError } = options;
|
|
119
|
+
|
|
120
|
+
const query = new URLSearchParams({ topics: topics.join(",") });
|
|
121
|
+
|
|
122
|
+
const source = environment.open(`${path}?${query.toString()}`);
|
|
123
|
+
|
|
124
|
+
// An `invalidate` frame's `data` is the single topic to drop and refetch.
|
|
125
|
+
source.addEventListener("invalidate", (event) => {
|
|
126
|
+
void client.invalidateTopic(event.data);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// A `resync` frame carries no topic: refetch everything this connection subscribes to.
|
|
130
|
+
source.addEventListener("resync", () => {
|
|
131
|
+
void client.invalidateTopics(topics);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// A stream error is informational (EventSource reconnects itself); forward it only
|
|
135
|
+
// when the caller asked to observe.
|
|
136
|
+
if (onError !== undefined) {
|
|
137
|
+
source.addEventListener("error", onError);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return () => source.close();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Options for {@link useLive} — everything {@link connectLive} takes but the topics. */
|
|
144
|
+
export type UseLiveOptions = Omit<ConnectLiveOptions, "topics">;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The island hook: hold a live subscription for `topics` open while the component is
|
|
148
|
+
* mounted, re-subscribing when the topic SET changes and disconnecting on unmount. A
|
|
149
|
+
* thin {@link connectLive} wrapper — the effect owns the connection's lifetime.
|
|
150
|
+
*
|
|
151
|
+
* SSR-safe: the connection is opened from an effect (never during render), so a
|
|
152
|
+
* server render neither touches `EventSource` nor holds a stream. Pair it with a
|
|
153
|
+
* `useQuery` reading the same topics and the query goes live.
|
|
154
|
+
*
|
|
155
|
+
* **One connection per call — call it ONCE, high in a view.** Unlike `useQuery` (which
|
|
156
|
+
* dedupes by key), each `useLive` opens its OWN `EventSource`. Calling it in many
|
|
157
|
+
* components — e.g. once per list row — opens many streams and can exhaust the browser's
|
|
158
|
+
* per-origin connection budget and the server's per-IP stream cap. Subscribe to every
|
|
159
|
+
* topic a view needs in a single `useLive` at the top of that view.
|
|
160
|
+
*
|
|
161
|
+
* **Options are read when the subscription opens and must be STABLE across renders.**
|
|
162
|
+
* Only a change to the topic SET reopens the stream; a new `client`/`path`/`environment`/
|
|
163
|
+
* `onError` identity on a later render is ignored. Pass a stable `client` (not
|
|
164
|
+
* `new QueryClient()` per render) — the same expectation `useQuery` has of its `client`.
|
|
165
|
+
*/
|
|
166
|
+
export function useLive(topics: readonly string[], options?: UseLiveOptions): void {
|
|
167
|
+
// Topics through a ref so the effect re-runs only when the SET changes (keyed by
|
|
168
|
+
// `topicsKey`), not on every render's fresh array identity. Joined on "," — the wire
|
|
169
|
+
// delimiter a topic can never contain — so the key is unambiguous (a space-joined key
|
|
170
|
+
// would collide `["a b"]` with `["a", "b"]`).
|
|
171
|
+
const topicsRef = useRef(topics);
|
|
172
|
+
topicsRef.current = topics;
|
|
173
|
+
const topicsKey = topics.join(",");
|
|
174
|
+
|
|
175
|
+
// The latest options through a ref, so a new `client`/`onError` closure identity
|
|
176
|
+
// does not tear down and reopen the stream — only a topic-set change does.
|
|
177
|
+
const optionsRef = useRef(options);
|
|
178
|
+
optionsRef.current = options;
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
const o: UseLiveOptions = optionsRef.current ?? {};
|
|
182
|
+
|
|
183
|
+
return connectLive({
|
|
184
|
+
topics: topicsRef.current,
|
|
185
|
+
...(o.client !== undefined ? { client: o.client } : {}),
|
|
186
|
+
...(o.path !== undefined ? { path: o.path } : {}),
|
|
187
|
+
...(o.environment !== undefined ? { environment: o.environment } : {}),
|
|
188
|
+
...(o.onError !== undefined ? { onError: o.onError } : {}),
|
|
189
|
+
});
|
|
190
|
+
}, [topicsKey]);
|
|
191
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@lesto/ui/server` — the server-render half of the engine.
|
|
3
3
|
*
|
|
4
|
-
* Everything here
|
|
5
|
-
* `preact-render-to-string`)
|
|
6
|
-
* page renderers and the two server-render dialects
|
|
4
|
+
* Everything here pulls in the `react-dom` family directly or transitively:
|
|
5
|
+
* `react-dom/server` (or its Preact twin, `preact-render-to-string`) for the
|
|
6
|
+
* buffered and streamed page renderers and the two server-render dialects, plus
|
|
7
|
+
* bare `react-dom` for the React 19 resource-hint helpers. It is split out of the core
|
|
7
8
|
* `@lesto/ui` barrel for one load-bearing reason — a CLIENT bundle that imports
|
|
8
9
|
* `@lesto/ui` (for `Registry`, `defineIsland`, the island/data tokens) must NEVER
|
|
9
10
|
* drag `react-dom/server` into the browser graph. React's `react-dom/server` is
|
|
@@ -42,3 +43,26 @@ export type {
|
|
|
42
43
|
// (`preact-render-to-string`), present only when an adopter chooses Preact, so a
|
|
43
44
|
// default React server never drags Preact's renderer into its build.
|
|
44
45
|
export { preactServerRenderer } from "./server-preact";
|
|
46
|
+
|
|
47
|
+
// Resource hints + LCP/modulepreload conventions over React 19's native APIs.
|
|
48
|
+
// These import the resource functions from bare `react-dom` and only emit markup
|
|
49
|
+
// during an SSR render (an SSR document-head concern, never called client-side),
|
|
50
|
+
// so they live behind this server subpath — off the isomorphic barrel — to keep
|
|
51
|
+
// `react-dom` out of the client/island bundle's import graph entirely.
|
|
52
|
+
export {
|
|
53
|
+
lcpImage,
|
|
54
|
+
modulePreload,
|
|
55
|
+
preconnect,
|
|
56
|
+
prefetchDNS,
|
|
57
|
+
preinit,
|
|
58
|
+
preinitModule,
|
|
59
|
+
preload,
|
|
60
|
+
} from "./resources";
|
|
61
|
+
export type {
|
|
62
|
+
LcpImageProps,
|
|
63
|
+
PreconnectOptions,
|
|
64
|
+
PreinitModuleOptions,
|
|
65
|
+
PreinitOptions,
|
|
66
|
+
PreloadOptions,
|
|
67
|
+
ResourceRegistrar,
|
|
68
|
+
} from "./resources";
|
package/src/softnav-contract.ts
CHANGED
|
@@ -47,10 +47,12 @@ export type PrefetchStrategy = "viewport" | "hover";
|
|
|
47
47
|
* below the deepest common layout.
|
|
48
48
|
*
|
|
49
49
|
* The runtime treats this as ENTIRELY OPTIONAL: a document with no such markers
|
|
50
|
-
* falls back to the full-body swap
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
50
|
+
* falls back to the full-body swap, so this never becomes load-bearing. EMITTING the
|
|
51
|
+
* marker is the server renderer's job — `@lesto/web`'s `wrap` (`render-page.tsx`,
|
|
52
|
+
* shared by the app-level and file-route layout chains) stamps one `display:contents`
|
|
53
|
+
* marker around each layout's children, so a page with layouts gets a marker per
|
|
54
|
+
* layout depth and the partial swap activates; a page with none emits no marker and
|
|
55
|
+
* the full-body swap runs, with no regression.
|
|
54
56
|
*/
|
|
55
57
|
export const LAYOUT_ATTR = "data-lesto-layout";
|
|
56
58
|
|
package/src/softnav.ts
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
|
|
47
47
|
import { UiError } from "./errors";
|
|
48
48
|
import { hydrateDocumentIslands } from "./hydrate";
|
|
49
|
-
import type { HydrateOptions, HydrationResult } from "./hydrate";
|
|
49
|
+
import type { HydrateOptions, HydrationResult, IslandRoot } from "./hydrate";
|
|
50
50
|
import type { Registry } from "./registry";
|
|
51
51
|
import { eligibleAnchor, LAYOUT_ATTR, PREFETCH_ATTR, RELOAD_ATTR } from "./softnav-contract";
|
|
52
52
|
import type { PrefetchStrategy, SoftNavAnchor, SoftNavClick } from "./softnav-contract";
|
|
@@ -117,15 +117,47 @@ export interface FetchedPage {
|
|
|
117
117
|
export type PageFetcher = (url: string, signal: AbortSignal) => Promise<FetchedPage>;
|
|
118
118
|
|
|
119
119
|
/**
|
|
120
|
-
*
|
|
120
|
+
* What a swap REPLACED: the new `<title>` to adopt (or `undefined` to leave the tab
|
|
121
|
+
* alone) and the live SUBTREE whose contents now hold the swapped-in page — the
|
|
122
|
+
* element the caller re-hydrates, and ONLY that element. Scoping re-hydration to the
|
|
123
|
+
* swapped subtree is what makes the layout-preserving partial swap safe: re-scanning
|
|
124
|
+
* the whole document would re-mount the PRESERVED outer layout's islands (there is no
|
|
125
|
+
* idempotency guard in `hydrateDocumentIslands`) and reset the very state the partial
|
|
126
|
+
* swap kept.
|
|
127
|
+
*/
|
|
128
|
+
export interface SwapResult {
|
|
129
|
+
/** The fetched `<title>`, or `undefined` when the page is title-less (don't blank the tab). */
|
|
130
|
+
title: string | undefined;
|
|
131
|
+
|
|
132
|
+
/** The live element whose CONTENTS were replaced — the re-hydration scope. */
|
|
133
|
+
root: IslandRoot;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Swap the fetched document's body into the live one. Returns a {@link SwapResult}
|
|
138
|
+
* (the new title + the swapped subtree to re-hydrate), or — the back-compatible
|
|
139
|
+
* shorthand — a bare title `string`/`undefined`, which the runtime reads as "re-hydrate
|
|
140
|
+
* the WHOLE document" (the pre-partial-swap behavior).
|
|
121
141
|
*
|
|
122
|
-
* The default {@link defaultSwap} parses the HTML
|
|
123
|
-
* when the document carries {@link LAYOUT_ATTR} markers
|
|
124
|
-
*
|
|
125
|
-
* swap (a single content region, a
|
|
126
|
-
* module knowing how the page is
|
|
142
|
+
* The default {@link defaultSwap} parses the HTML and swaps the body — LAYOUT-PRESERVING
|
|
143
|
+
* when the document carries {@link LAYOUT_ATTR} markers (it returns just the deepest
|
|
144
|
+
* shared layout's inner element as `root`), else replacing the whole body's contents
|
|
145
|
+
* (root = `<body>`). A caller can inject a finer swap (a single content region, a
|
|
146
|
+
* view-transition wrapper for Bet I) without this module knowing how the page is
|
|
147
|
+
* structured; a finer swap that wants scoped re-hydration returns its own `root`.
|
|
148
|
+
*/
|
|
149
|
+
export type PageSwapper = (html: string, doc: Document) => SwapResult | string | undefined;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Normalize a {@link PageSwapper}'s result to a {@link SwapResult}: a bare title
|
|
153
|
+
* (the back-compatible shorthand) becomes `{ title, root: <whole document> }`, so an
|
|
154
|
+
* old-style swap keeps the pre-partial-swap whole-document re-hydrate while a
|
|
155
|
+
* `SwapResult`-returning swap drives the scoped re-hydrate. The one place the union is
|
|
156
|
+
* collapsed, shared by both the soft-nav and dev-page-swap appliers.
|
|
127
157
|
*/
|
|
128
|
-
|
|
158
|
+
function readSwapResult(result: SwapResult | string | undefined, doc: Document): SwapResult {
|
|
159
|
+
return typeof result === "object" ? result : { title: result, root: doc };
|
|
160
|
+
}
|
|
129
161
|
|
|
130
162
|
/**
|
|
131
163
|
* The re-hydrate call after a swap — defaults to {@link hydrateDocumentIslands}.
|
|
@@ -409,26 +441,30 @@ function deepestSharedLayout(
|
|
|
409
441
|
* the live nodes — and the click listener delegated to `<body>`'s document — attached
|
|
410
442
|
* across the swap. The head is left alone (the client module + styles already ran);
|
|
411
443
|
* only the per-page `<title>` is carried over.
|
|
444
|
+
*
|
|
445
|
+
* Returns the swapped element as `root` — the body for a full swap, the deepest shared
|
|
446
|
+
* layout's inner element for a partial one — so the caller re-hydrates ONLY that
|
|
447
|
+
* subtree and a preserved outer layout's islands are never re-mounted.
|
|
412
448
|
*/
|
|
413
449
|
const defaultSwap: PageSwapper = (html, doc) => {
|
|
414
450
|
const parsed = new DOMParser().parseFromString(html, "text/html");
|
|
415
451
|
|
|
416
452
|
const shared = deepestSharedLayout(doc.body, parsed.body);
|
|
417
453
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
454
|
+
// The live element whose CONTENTS we replace, and therefore the subtree to
|
|
455
|
+
// re-hydrate. A shared outer layout → swap only its inner contents (the page + any
|
|
456
|
+
// deeper layouts), preserving every outer layout's DOM and island state; no shared
|
|
457
|
+
// boundary → the whole `<body>`, the original full-swap behavior.
|
|
458
|
+
const root = shared?.live ?? doc.body;
|
|
459
|
+
const fetched = shared?.fetched ?? parsed.body;
|
|
460
|
+
|
|
461
|
+
replaceContents(root, fetched, doc);
|
|
426
462
|
|
|
427
463
|
// `textContent` is "" when the fetched page has no <title>; treat that as "no
|
|
428
464
|
// title to set" so we never blank the tab on a title-less page.
|
|
429
465
|
const title = parsed.title;
|
|
430
466
|
|
|
431
|
-
return title === "" ? undefined : title;
|
|
467
|
+
return { title: title === "" ? undefined : title, root };
|
|
432
468
|
};
|
|
433
469
|
|
|
434
470
|
/** The id of the framework-owned `aria-live` region soft nav announces routes through. */
|
|
@@ -643,7 +679,7 @@ export function enableSoftNav(registry: Registry, options: SoftNavOptions = {}):
|
|
|
643
679
|
return;
|
|
644
680
|
}
|
|
645
681
|
|
|
646
|
-
const title = swap(html, doc);
|
|
682
|
+
const { title, root } = readSwapResult(swap(html, doc), doc);
|
|
647
683
|
|
|
648
684
|
if (title !== undefined) doc.title = title;
|
|
649
685
|
|
|
@@ -653,22 +689,15 @@ export function enableSoftNav(registry: Registry, options: SoftNavOptions = {}):
|
|
|
653
689
|
hist.pushState({ lestoSoftNav: true, scroll: { x: 0, y: 0 } }, "", landed);
|
|
654
690
|
}
|
|
655
691
|
|
|
656
|
-
// Re-hydrate
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
|
|
665
|
-
// that preserved layout's islands (no idempotency guard in `hydrateDocumentIslands`)
|
|
666
|
-
// and DESTROY the very state the partial swap preserves. So before wiring the
|
|
667
|
-
// marker, this must scope to the swapped subtree (the `defaultSwap` would have to
|
|
668
|
-
// surface which element it replaced) OR `hydrateDocumentIslands` must skip an
|
|
669
|
-
// already-mounted shell. Until then the partial-swap branch stays a dormant, pure
|
|
670
|
-
// fallback (see docs/plans/dx-parity.md W8) — never activate one without the other.
|
|
671
|
-
const hydration = rehydrate(registry, { root: doc });
|
|
692
|
+
// Re-hydrate ONLY the swapped subtree — `root` is the element whose contents
|
|
693
|
+
// the swap just replaced (the `<body>` for a full swap, or just the deepest
|
|
694
|
+
// shared layout's inner region for a layout-preserving partial swap). Scoping
|
|
695
|
+
// here is what makes the partial swap safe: re-scanning the WHOLE document would
|
|
696
|
+
// re-mount the preserved outer layout's islands (no idempotency guard in
|
|
697
|
+
// `hydrateDocumentIslands`) and DESTROY the very state the partial swap keeps.
|
|
698
|
+
// A full-body swap's `root` is the body, so this stays equivalent to the old
|
|
699
|
+
// whole-document re-hydrate (mount scripts live in the body, never the head).
|
|
700
|
+
const hydration = rehydrate(registry, { root });
|
|
672
701
|
|
|
673
702
|
// The swapped-in page brings its own `viewport`-prefetch links; register them
|
|
674
703
|
// so they warm as the user scrolls the new page. (Hover links are caught by
|
|
@@ -932,3 +961,98 @@ export function enableSoftNav(registry: Registry, options: SoftNavOptions = {}):
|
|
|
932
961
|
|
|
933
962
|
return disable;
|
|
934
963
|
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* The window-global the `lesto dev` live-reload client calls to refresh the current
|
|
967
|
+
* page in place — the wire contract between the CLI's injected reload script
|
|
968
|
+
* (`@lesto/cli`'s `dev-overlay.ts`, a raw string that cannot import this module) and
|
|
969
|
+
* the hook {@link enableDevPageRefresh} installs. A literal both sides pin, like the
|
|
970
|
+
* live-reload WebSocket's message types: `dev-overlay.ts` hardcodes the SAME literal
|
|
971
|
+
* (it cannot import this constant across the package boundary), so a rename here must
|
|
972
|
+
* update it there too — `packages/e2e/page-swap.spec.ts` guards the pair end to end (a
|
|
973
|
+
* mismatch makes the client miss the hook → full reload → the spec's marker assertion fails).
|
|
974
|
+
*/
|
|
975
|
+
export const DEV_PAGE_REFRESH_GLOBAL = "__lestoDevRefreshPage";
|
|
976
|
+
|
|
977
|
+
/** The injectable seams {@link enableDevPageRefresh} runs on; all default to the real browser. */
|
|
978
|
+
export interface DevPageRefreshOptions {
|
|
979
|
+
/** The document to refresh + re-hydrate. Defaults to `document`. */
|
|
980
|
+
document?: Document;
|
|
981
|
+
|
|
982
|
+
/** How to fetch the current page's fresh HTML. Defaults to the same same-origin GET soft nav uses. */
|
|
983
|
+
fetchPage?: PageFetcher;
|
|
984
|
+
|
|
985
|
+
/** How to swap the fetched body in. Defaults to {@link defaultSwap} (layout-aware, full-body fallback). */
|
|
986
|
+
swap?: PageSwapper;
|
|
987
|
+
|
|
988
|
+
/** How to re-hydrate after the swap. Defaults to {@link hydrateDocumentIslands}. */
|
|
989
|
+
rehydrate?: Rehydrate;
|
|
990
|
+
|
|
991
|
+
/** Where the refresh hook is installed for the dev client to call. Defaults to the document's window. */
|
|
992
|
+
target?: Record<string, unknown>;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Install the `lesto dev` PAGE refresh hook: a function that re-fetches the CURRENT
|
|
997
|
+
* url, swaps its body in, and re-hydrates — the same fetch-and-swap soft nav does, but
|
|
998
|
+
* for a server-driven dev signal (a saved `app/routes/*` file) rather than a click.
|
|
999
|
+
*
|
|
1000
|
+
* The CLI's live-reload client (`dev-overlay.ts`) calls the installed
|
|
1001
|
+
* {@link DEV_PAGE_REFRESH_GLOBAL} on a `{type:"page-swap"}` frame instead of
|
|
1002
|
+
* `location.reload()`, so a page edit re-renders WITHOUT the jarring full reload
|
|
1003
|
+
* (scroll kept, no white flash, no asset re-download). A page is server-rendered, so
|
|
1004
|
+
* there is no client component state to preserve — re-hydrating the whole document is
|
|
1005
|
+
* exactly right here; the win is purely avoiding the reload. If the hook is absent or
|
|
1006
|
+
* its refresh throws, the client falls back to a real reload — the floor always holds.
|
|
1007
|
+
*
|
|
1008
|
+
* The synthesized client entry (`@lesto/assets`'s `synthesizeEntry`) calls this only
|
|
1009
|
+
* when built with `beacon.dev` — TODAY the island-dev (Vite) dev entry, the scaffold
|
|
1010
|
+
* default. The Bun client build (`buildClient`, which serves `lesto dev`'s non-island-dev
|
|
1011
|
+
* path AND prod) does not set it, so there the dev client falls back to a full reload and
|
|
1012
|
+
* prod ships neither symbol nor call (extending it to the Bun dev path is tracked separately).
|
|
1013
|
+
*
|
|
1014
|
+
* Reuses {@link defaultSwap}, so when the server emits {@link LAYOUT_ATTR} markers the
|
|
1015
|
+
* DOM swap is layout-preserving AND the re-hydrate is scoped to the swapped subtree
|
|
1016
|
+
* (via the swap's returned `root`) — so editing an `app/routes/*` page re-renders just
|
|
1017
|
+
* the page region and the unchanged layout's islands keep their state across the swap,
|
|
1018
|
+
* no full reload and no re-mount. A page with no layouts swaps the whole body and
|
|
1019
|
+
* re-hydrates it, exactly as before.
|
|
1020
|
+
*
|
|
1021
|
+
* Returns the refresh function (also installed on the target) so a test can drive it
|
|
1022
|
+
* directly without reaching through the global.
|
|
1023
|
+
*/
|
|
1024
|
+
export function enableDevPageRefresh(
|
|
1025
|
+
registry: Registry,
|
|
1026
|
+
options: DevPageRefreshOptions = {},
|
|
1027
|
+
): () => Promise<void> {
|
|
1028
|
+
const doc: Document = options.document ?? document;
|
|
1029
|
+
const fetchPage: PageFetcher = options.fetchPage ?? defaultFetchPage;
|
|
1030
|
+
const swap: PageSwapper = options.swap ?? defaultSwap;
|
|
1031
|
+
const rehydrate: Rehydrate = options.rehydrate ?? hydrateDocumentIslands;
|
|
1032
|
+
|
|
1033
|
+
const refresh = async (): Promise<void> => {
|
|
1034
|
+
// A dev refresh has no supersession/cancel story (unlike soft nav's click navigations),
|
|
1035
|
+
// so a fresh, never-aborted controller just satisfies `PageFetcher`'s required signal.
|
|
1036
|
+
const { html } = await fetchPage(doc.URL, new AbortController().signal);
|
|
1037
|
+
|
|
1038
|
+
const { title, root } = readSwapResult(swap(html, doc), doc);
|
|
1039
|
+
|
|
1040
|
+
if (title !== undefined) doc.title = title;
|
|
1041
|
+
|
|
1042
|
+
// Re-hydrate ONLY the swapped subtree — `root` is the element the swap replaced.
|
|
1043
|
+
// For a full-body swap that is the body (re-mount every island, correct since the
|
|
1044
|
+
// whole page changed); for a layout-preserving partial swap it is just the inner
|
|
1045
|
+
// page region, so the unchanged outer layout's islands are NOT re-mounted and keep
|
|
1046
|
+
// their state — the deferred half of the dev page-swap, now wired.
|
|
1047
|
+
rehydrate(registry, { root });
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
// Install the hook for the dev client to call. The document's window is the default
|
|
1051
|
+
// target; a detached document (a test fake) with no `defaultView` gets none — the
|
|
1052
|
+
// returned `refresh` is still usable directly.
|
|
1053
|
+
const target = options.target ?? (doc.defaultView as unknown as Record<string, unknown> | null);
|
|
1054
|
+
|
|
1055
|
+
if (target !== null && target !== undefined) target[DEV_PAGE_REFRESH_GLOBAL] = refresh;
|
|
1056
|
+
|
|
1057
|
+
return refresh;
|
|
1058
|
+
}
|