@lesto/ui 0.1.0 → 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 CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@lesto/ui",
3
- "version": "0.1.0",
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": "workspace:*",
23
- "@lesto/router": "workspace:*",
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";
@@ -1,27 +1,38 @@
1
1
  /**
2
2
  * Client data hooks — `useQuery` / `useMutation` over a tiny shared cache.
3
3
  *
4
- * The smallest credible step of the reactive data layer (ADR 0027), and no more:
5
- * islands today hand-roll `useState`+`useEffect` to fetch (a re-implemented loading/error
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** — a resolved key stays cached until a
12
- * mutation (or a manual `refetch`) invalidates it, so a re-mount or a sibling
13
- * reading the same key paints instantly;
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 and an `onSuccess` hook that typically invalidates keys.
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. There is no schema-INFERRED invalidation (a mutation does not know which
19
- * queries it dirties you invalidate by key, explicitly), no normalized `(table, pk)`
20
- * store, no automatic background revalidation, and no cache EVICTION — a key's snapshot +
21
- * last-fetcher live for the page's lifetime (bounded by the distinct keys an app
22
- * queries; fine for a per-session SPA, revisit if a long-lived app accumulates many).
23
- * Those are the later ADR 0027 phases (server-pushed invalidation over LISTEN/NOTIFY,
24
- * durable storage); this is the thin hook layer an app adopts now and they build on.
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), and the
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
- const promise = fetcher().then(
133
- (data) => {
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
- this.#inflight.set(key, promise);
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
- // A deduped caller may never attach a handler; swallow the rejection on the
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
- return promise;
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 ADR 0027 phase). A
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
- /** Publish a new snapshot for `key` and notify its subscribers. */
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). The fetch fires once per
211
- * mount / key-change (the effect deps are only `[client, keyStr]`), never per
212
- * render, so there is no retry storm; an in-mount retry is a manual
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?: { client?: QueryClient },
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 `loading`/`success` key is left alone: a sibling
245
- // already has it in flight or cached (the dedupe at mount time).
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 (status === "idle" || status === "error") {
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
- /** Run after success — typically `client.invalidate(key)` to revalidate reads. */
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) and an
306
- * `onSuccess`/`onError` pair (the former usually invalidates queries).
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 rollback = optionsRef.current?.onMutate?.(input);
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
- optionsRef.current?.onSuccess?.(data, input);
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
- optionsRef.current?.onError?.(error, input);
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), 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.
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. `defineDataSource<User | null>("session")`
48
- * names the source and pins what its loader returns / its bound prop receives.
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-by-key invalidation NOT yet the schema-inferred invalidation a
72
- // later phase may add. Decoupled from
73
- // `@lesto/client` (the caller passes the fetcher/mutationFn thunk), so they stay
74
- // in this isomorphic core; the fetch is kicked from an effect, so SSR never fetches.
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
- // Server-safe: the `react-dom` resource functions are isomorphic and only emit
104
- // markup during an SSR render.
105
- export {
106
- lcpImage,
107
- modulePreload,
108
- preconnect,
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 imports `react-dom/server` (or its Preact twin,
5
- * `preact-render-to-string`) directly or transitively: the buffered and streamed
6
- * page renderers and the two server-render dialects. It is split out of the core
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";
@@ -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 (today's behavior), so this never becomes
51
- * load-bearing. EMITTING the marker is the server renderer's job (`@lesto/web`'s
52
- * `render-page.tsx` / the file-route layout composer) until it does, the partial
53
- * swap is dormant and the full swap runs, with no regression.
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
- * Swap the fetched document's body into the live one and return the new title.
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, swaps the body — LAYOUT-PRESERVING
123
- * when the document carries {@link LAYOUT_ATTR} markers, else replacing the whole
124
- * body's contents and returns the fetched `<title>`. A caller can inject a finer
125
- * swap (a single content region, a view-transition wrapper for Bet I) without this
126
- * module knowing how the page is structured.
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
- export type PageSwapper = (html: string, doc: Document) => string | undefined;
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
- if (shared === undefined) {
419
- // No shared layout boundary → swap the whole body (the original behavior).
420
- replaceContents(doc.body, parsed.body, doc);
421
- } else {
422
- // A shared outer layout → swap only its inner contents, preserving the outer
423
- // layout DOM (and any island state mounted in it) across the navigation.
424
- replaceContents(shared.live, shared.fetched, doc);
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 against the JUST-SWAPPED document — `root` is the seam islands
657
- // are looked up under, so the new body's mount scripts are the ones found,
658
- // not the ambient global `document`'s.
659
- //
660
- // ACTIVATION CAVEAT for the layout-preserving partial swap: this re-hydrates
661
- // the WHOLE document, which is correct ONLY for the full-body swap (today's
662
- // path no server emits `data-lesto-layout`, so `deepestSharedLayout` always
663
- // returns undefined). The moment a server DOES emit those markers and a partial
664
- // swap keeps an outer layout's DOM, re-scanning the whole doc here would re-mount
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
+ }