@abide/abide 0.56.0 → 0.57.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # abide
2
2
 
3
+ ## 0.57.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 866c96d: degrade a mangled **SSR** payload to a cold boot (ADR-0051) ([`adfee02`](https://github.com/briancray/abide/commit/adfee024d570e8bf46230fac1fa2521fcaae056f))
8
+ - 1abc943: Reuse the keyed-list (`{#for}`) reconcile scratch across passes so a steady-state reconcile allocates nothing
9
+
10
+ Each `each` reconcile built two fresh arrays per list change — `list.map(keyOf)` for the desired keys and `new Array(n)` for the resolved rows — then threw them away. On a list re-reconciled per keystroke that churn showed up as avoidable minor-GC pressure mid-interaction. The two buffers are now hoisted into persistent per-`each` scratch, reused every pass and cleared down to the live length on shrink (so no disposed-row references are retained). The `keyOf` pass is fused into the resolve loop — still exactly one `keyOf` call per item. Behaviour is unchanged: keyed diffing, in-place row updates, duplicate-key collapse, and prune-before-place all resolve identically; only the per-pass allocation is gone. The `[...source]` materialization for non-array iterables (generators must be drained) is unchanged.
11
+
12
+ - e137242: `spreadAttrs` — swallow a per-key suspend on a `{...rest}` spread
13
+
14
+ A `{...rest}` spread guards its enumeration against a pending blocking `await` read, but not the per-key value read. When `source()` is a restProps proxy (the `<Button {...rest}>` case), its `get` invokes the underlying prop thunk, so a key whose expression reads a still-pending blocking cell suspends inside `bindKeys` — after the enumeration guard has already passed — and the `SuspenseSignal` escaped the render, killing the mount (seen as a crash on a cold client remount, e.g. navigating back to a page whose top-level blocking cells re-fetch and are briefly `pending()`). The per-key read now catches the suspend and binds the key as a deferred attribute (its `attr` effect swallows the signal and fills the value in on settle), matching `attr` and the deferred-enumeration branch; event handlers, whose thunks return a function without reading a cell, never suspend and stay on the listener path.
15
+
16
+ - 7367a23: SSR hydration payload ships as inert JSON instead of executable JavaScript (ADR-0051)
17
+
18
+ The `window.__SSR__` boot payload now ships as `<script type="application/json">` parsed once by the deferred client bundle with `JSON.parse`, rather than a `window.__SSR__ = {…}` statement the browser had to compile and evaluate as a multi-MB program on the critical path. The global still exists for debugging — the bundle republishes it after parsing. Additionally, a json cache body is now seeded pre-parsed (`data`) rather than as a re-escaped JSON string nested in the payload, so it's single-encoded (roughly half the wire bytes and gzip for a json-heavy grid) and read without a second parse. Warm-seed semantics — zero refetch, zero hydration divergence — are unchanged.
19
+
20
+ ## 0.57.0
21
+
22
+ ### Minor Changes
23
+
24
+ - d09e821: `errorTtl` — opt a failed RPC load into the negative cache
25
+
26
+ By default a failed load evicts immediately so the next read retries. Setting `errorTtl` on an endpoint's `cache` policy (or a producer's `cache()` options) retains the failure for that many milliseconds instead: reads within the window re-surface the same rejection with no network round-trip — backing off a failing or rate-limited backend rather than hammering it — then the entry hard-evicts and the next read retries. The function form `errorTtl: (status) => number | undefined` returns a per-status window (`status` is `0` for a network-level fault), or `undefined` to keep the immediate-retry default for that status (e.g. back off a 429/503, retry a 500 at once). A `Retry-After` response header overrides the configured window as the authoritative delay. A negative-cached error is never shipped in the SSR snapshot, so it can't warm-hydrate a poisoned client entry.
27
+
28
+ ### Patch Changes
29
+
30
+ - 3ce8963: Fix an async-cell warm-seed ordering bug where a `linked` cell reading a blocking dependency could desync SSR and client
31
+
32
+ An async cell's warm-seed key is `${scope.id}:${index}`, where `index` is a per-scope counter drawn in construction order. Only a cell that actually becomes async draws one — and a `linked` seed becomes async (drawing an index) only when it _suspends_ by reading a still-pending blocking dependency, otherwise it resolves synchronously to a plain `state` and drew none. Because a blocking dependency is typically in flight on the server (the `linked` suspends → index drawn) but already warm-adopted on the client (the `linked` resolves synchronously → no index), every async cell declared _after_ the `linked` in the same scope keyed off-by-one across the handoff and warm-adopted the wrong cell's value (e.g. a grid cell rendering the layout string it followed). `linked` now reserves its ordinal on the synchronous path too, so it always occupies exactly one index — the same invariant `computed` already has from its static `async`-function routing — and downstream keys line up on both sides.
33
+
34
+ - e8fb019: Type a bare no-`await` promise/stream `state.computed` seed as pending (`T | undefined`)
35
+
36
+ `state.computed(getRates())` (no `await`) routes to a STREAMING cell at runtime — it peeks `undefined` while pending — but the type-check shadow projected it as the resolved `T`, so an unguarded read (`rates.map(...)`) type-checked yet could hit `undefined` at first paint. It now projects through `$$cellValuePending` (`T | undefined`), matching the runtime and the semantically identical `state.computed(async () => getRates())` form. A blocking `state.computed(await getRates())` (suspends until resolved) and a genuinely sync seed are unchanged.
37
+
38
+ Consuming such a cell with `{#await rates}{:then data}` still binds `data` as the RESOLVED value: the shadow's `{#await}` lowering now strips the pending `undefined` off a bare pending-cell subject (mirroring the runtime's `$$awaitSubject`, ADR-0047), so the idiomatic await/then path stays free of `| undefined` noise. Direct reads should guard with `?.`/`??`.
39
+
3
40
  ## 0.56.0
4
41
 
5
42
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.56.0",
3
+ "version": "0.57.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -6,6 +6,7 @@ import { formatTraceparent } from '../../shared/formatTraceparent.ts'
6
6
  import { hasSeedableRequest } from '../../shared/hasSeedableRequest.ts'
7
7
  import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
8
8
  import { resolvedCellsSlot } from '../../shared/resolvedCellsSlot.ts'
9
+ import { SSR_SCRIPT_ID } from '../../shared/SSR_SCRIPT_ID.ts'
9
10
  import { safeJsonForScript } from '../../shared/safeJsonForScript.ts'
10
11
  import { socketTailsSlot } from '../../shared/socketTailsSlot.ts'
11
12
  import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
@@ -213,7 +214,11 @@ export function createUiPageRenderer({
213
214
  health,
214
215
  clientTimeout,
215
216
  } satisfies SsrPayload)
216
- return `<script>window.__SSR__ = ${payload};</script>`
217
+ /* Inert JSON data, NOT a `window.__SSR__ = {…}` statement (ADR-0051): the browser never
218
+ compiles this multi-MB payload as JavaScript on the critical path — the deferred bundle
219
+ reads it with one `JSON.parse`. `safeJsonForScript` has already `<`-escaped every
220
+ `<`, so an embedded `</script>` can't close the tag early. */
221
+ return `<script type="application/json" id="${SSR_SCRIPT_ID}">${payload}</script>`
217
222
  }
218
223
 
219
224
  /* Per-route `<link rel=modulepreload>`s for the route's page + layout-chain chunks
@@ -41,6 +41,13 @@ export async function snapshotEntryFromCache(
41
41
  if (!response) {
42
42
  return undefined
43
43
  }
44
+ /* A failed load never seeds the client: negative caching (errorTtl) can leave an
45
+ error-status entry in the store at snapshot time, but shipping it would warm-hydrate
46
+ a poisoned entry. Skip it — the client live-fetches on the miss and runs its own
47
+ negative cache. (Before errorTtl, errors evicted at settle and never reached here.) */
48
+ if (!response.ok) {
49
+ return undefined
50
+ }
44
51
  if (store.entries.get(entry.key) !== entry) {
45
52
  return undefined
46
53
  }
@@ -80,15 +87,29 @@ export async function snapshotEntryFromCache(
80
87
  headers: response.headers,
81
88
  }),
82
89
  )
83
- return {
90
+ const wire: CacheSnapshotEntry = {
84
91
  key: entry.key,
85
92
  url: entry.request.url,
86
93
  method,
87
94
  status: response.status,
88
95
  statusText: response.statusText,
89
96
  headers: Array.from(response.headers.entries()),
90
- body,
91
97
  }
98
+ /* Ship a json body PARSED so it single-encodes in the payload rather than nesting a
99
+ re-escaped JSON string (ADR-0051 — the double-encoding that ~2.35×'d the wire). On a
100
+ parse failure ship the raw string under `body`: the client decodes it async exactly as
101
+ before (warmValueFromSnapshot's own try/catch), so a malformed-but-json-typed body still
102
+ round-trips rather than throwing here. A text body always ships raw. */
103
+ if (kind === 'json') {
104
+ try {
105
+ wire.data = JSON.parse(body)
106
+ return wire
107
+ } catch {
108
+ /* fall through to the raw-string form */
109
+ }
110
+ }
111
+ wire.body = body
112
+ return wire
92
113
  }
93
114
 
94
115
  async function readSettled(promise: Promise<Response>): Promise<Response | undefined> {
@@ -0,0 +1,11 @@
1
+ /*
2
+ The id of the `<script type="application/json">` element the server emits the
3
+ `window.__SSR__` payload into and the client (`startClient`) parses back out
4
+ (ADR-0051). Shipping the payload as inert JSON data — not a `window.__SSR__ = {…}`
5
+ executable statement — means the browser never COMPILES a multi-MB object literal
6
+ as JavaScript on the critical path; the deferred client bundle reads it with one
7
+ `JSON.parse` (a restricted grammar parsed several times faster than equivalent JS
8
+ source). Both sides import this constant so the write id and read id can't drift.
9
+ */
10
+ // @documentation plumbing
11
+ export const SSR_SCRIPT_ID = 'abide-ssr'
@@ -280,8 +280,28 @@ function readThrough<Args, Return>(
280
280
  warnEphemeralTtl(key)
281
281
  }
282
282
  store.subscribe(key)
283
- const existing = store.entries.get(key)
283
+ let existing = store.entries.get(key)
284
284
  recordRead(effectiveOptions?.shared ? activeCacheStore() : store, key, existing)
285
+ /*
286
+ Negative-cache short-circuit: a prior failed load retained under errorTtl
287
+ re-surfaces the same failure with no network until its window elapses — a
288
+ Response is re-served (raw) or re-decoded to throw the same HttpError, a
289
+ network Error is re-rejected. Past the deadline (the sweep may not have fired
290
+ yet) evict it here so the read below is a clean miss that retries.
291
+ */
292
+ if (existing?.errorResult !== undefined) {
293
+ if (existing.expiresAt !== undefined && existing.expiresAt > Date.now()) {
294
+ const failure = existing.errorResult
295
+ if (failure instanceof Response) {
296
+ return isRaw
297
+ ? (Promise.resolve(failure.clone()) as Promise<Return | Response>)
298
+ : (decodeResponse(failure.clone()) as Promise<Return | Response>)
299
+ }
300
+ return Promise.reject(failure)
301
+ }
302
+ evictIfCurrent(store, existing)
303
+ existing = undefined
304
+ }
285
305
  if (existing) {
286
306
  tagEntry(existing, effectiveOptions?.tags)
287
307
  attachPolicy(existing, effectiveOptions, () => remote(args as Args), retain)
@@ -380,6 +400,7 @@ function resolveEndpointCache<Args, Return>(
380
400
  shared: policy.shared,
381
401
  throttle: policy.throttle,
382
402
  debounce: policy.debounce,
403
+ errorTtl: policy.errorTtl,
383
404
  }
384
405
  }
385
406
 
@@ -565,61 +586,76 @@ function registerEntry(
565
586
  function deleteIfCurrent() {
566
587
  evictIfCurrent(store, entry)
567
588
  }
568
- promise.then((result) => {
569
- /*
589
+ promise.then(
590
+ (result) => {
591
+ /*
570
592
  Mark settled so SSR snapshot serialization can tell awaited entries
571
593
  (resolved by the time render() returns → inline) from {#await} ones
572
594
  (still pending → stream). Set before the ttl branches below since a
573
595
  ttl=0 server entry stays in the store for the snapshot.
574
596
  */
575
- entry.settled = true
576
- /* The reload finished — this entry now holds fresh data, no longer refreshing. */
577
- entry.refreshing = false
578
- store.markLifecycle(key)
579
- /*
597
+ entry.settled = true
598
+ /* The reload finished — this entry now holds fresh data, no longer refreshing. */
599
+ entry.refreshing = false
600
+ store.markLifecycle(key)
601
+ /*
580
602
  An error-status Response is a failed load, not a value to retain: fetch
581
603
  resolves (it only rejects on a network fault) on a 4xx/5xx, so without
582
604
  this the entry would be served as a hit for the whole ttl, even after the
583
- backend recovers. Evict it so the next read retries — mirroring fireRefetch,
584
- which already guards revalidation results with the same `!result.ok` check.
605
+ backend recovers. Evict it so the next read retries — unless the endpoint
606
+ opted into the negative cache (errorTtl), which retains the failure for a
607
+ bounded window (see retainErroredEntry). Mirrors fireRefetch, which guards
608
+ revalidation results with the same `!result.ok` check.
585
609
  */
586
- if (result instanceof Response && !result.ok) {
587
- deleteIfCurrent()
588
- return
589
- }
590
- /*
610
+ if (result instanceof Response && !result.ok) {
611
+ retainErroredEntry(store, entry, options, result.status, result)
612
+ return
613
+ }
614
+ /*
591
615
  A post-invalidate reload registers with refreshing=true; an invalidation
592
616
  firing while it was in flight parked its refetch on policy.pending
593
617
  (fireRefetch bails when refreshing). This settle path — not fireRefetch —
594
618
  cleared the flag, so drain the parked refetch here or it's lost and the
595
619
  entry keeps data that predates the invalidation.
596
620
  */
597
- if (entry.invalidation?.pending) {
598
- reschedulePendingRefetch(store, entry, entry.invalidation)
599
- }
600
- /*
621
+ if (entry.invalidation?.pending) {
622
+ reschedulePendingRefetch(store, entry, entry.invalidation)
623
+ }
624
+ /*
601
625
  Smart retained read: the display value is kept unconditionally — never
602
626
  hard-evicted on settle. ttl marks a staleness deadline (the next read past
603
627
  it revalidates in the background, stale stays visible, refreshing() true)
604
628
  instead of eviction; ttl 0/undefined retain with no staleness clock.
605
629
  */
606
- if (entry.retain) {
607
- materializeRetained(store, entry, result)
608
- if (ttl !== undefined && ttl > 0) {
609
- stampStaleDeadline(entry, ttl)
630
+ if (entry.retain) {
631
+ materializeRetained(store, entry, result)
632
+ if (ttl !== undefined && ttl > 0) {
633
+ stampStaleDeadline(entry, ttl)
634
+ }
635
+ return
610
636
  }
611
- return
612
- }
613
- if (ttl === 0) {
614
- if (!keepZeroTtlForRequest) {
615
- deleteIfCurrent()
637
+ if (ttl === 0) {
638
+ if (!keepZeroTtlForRequest) {
639
+ deleteIfCurrent()
640
+ }
641
+ return
616
642
  }
617
- return
618
- }
619
- if (ttl !== undefined) {
620
- armTtlExpiry(store, entry, ttl)
621
- }
622
- }, deleteIfCurrent)
643
+ if (ttl !== undefined) {
644
+ armTtlExpiry(store, entry, ttl)
645
+ }
646
+ },
647
+ (error) => {
648
+ /* A network-level fault (fetch rejects — no Response, so no status/Retry-After):
649
+ negative-cache it under the status-0 window if opted in, else evict-and-retry. */
650
+ retainErroredEntry(
651
+ store,
652
+ entry,
653
+ options,
654
+ 0,
655
+ error instanceof Error ? error : new Error(String(error)),
656
+ )
657
+ },
658
+ )
623
659
  return entry
624
660
  }
625
661
 
@@ -636,6 +672,75 @@ function evictIfCurrent(store: CacheStore, entry: CacheEntry): void {
636
672
  }
637
673
  }
638
674
 
675
+ /*
676
+ A failed load is retained (negative cache), not evicted, only when the endpoint
677
+ opted in with `errorTtl` for this status — otherwise it evicts so the next read
678
+ retries (the default). With a window the failure is buffered so reads within it
679
+ re-surface it with no network: a Response is stored as a pristine clone the read
680
+ path re-clones per serve (never consuming errorResult itself), a network Error is
681
+ stored as-is and re-rejected. `armTtlExpiry` hard-evicts at the deadline, so the
682
+ next read retries. A `Retry-After` header overrides the configured window as the
683
+ authoritative delay.
684
+ */
685
+ function retainErroredEntry(
686
+ store: CacheStore,
687
+ entry: CacheEntry,
688
+ options: CacheOptions | undefined,
689
+ status: number,
690
+ failure: Response | Error,
691
+ ): void {
692
+ const configured = errorTtlFor(options, status)
693
+ /* Not opted in for this status → default evict-and-retry (Retry-After is honoured
694
+ only under an explicit errorTtl, so no existing endpoint's behaviour shifts). */
695
+ if (configured === undefined) {
696
+ evictIfCurrent(store, entry)
697
+ return
698
+ }
699
+ const window =
700
+ failure instanceof Response ? (retryAfterMs(failure.headers) ?? configured) : configured
701
+ if (window <= 0) {
702
+ evictIfCurrent(store, entry)
703
+ return
704
+ }
705
+ if (store.entries.get(entry.key) !== entry) {
706
+ return
707
+ }
708
+ entry.errorResult = failure instanceof Response ? failure.clone() : failure
709
+ entry.settled = true
710
+ armTtlExpiry(store, entry, window)
711
+ }
712
+
713
+ /* Resolves the endpoint's `errorTtl` against a failed status: a number applies to
714
+ any status, a function returns a per-status window (or undefined to keep the
715
+ immediate-retry default for that status). `status` is 0 for a network fault. */
716
+ function errorTtlFor(options: CacheOptions | undefined, status: number): number | undefined {
717
+ const policy = options?.errorTtl
718
+ if (typeof policy === 'function') {
719
+ return policy(status)
720
+ }
721
+ return policy
722
+ }
723
+
724
+ /* Parses a `Retry-After` header to ms — a delta-seconds integer or an HTTP-date —
725
+ clamped to >= 0. Returns undefined when absent or unparseable so the caller falls
726
+ back to the configured errorTtl. */
727
+ function retryAfterMs(headers: Headers): number | undefined {
728
+ const value = headers.get('retry-after')
729
+ if (value === null) {
730
+ return undefined
731
+ }
732
+ const seconds = Number(value)
733
+ if (Number.isFinite(seconds)) {
734
+ return seconds > 0 ? seconds * 1000 : 0
735
+ }
736
+ const when = Date.parse(value)
737
+ if (Number.isNaN(when)) {
738
+ return undefined
739
+ }
740
+ const delta = when - Date.now()
741
+ return delta > 0 ? delta : 0
742
+ }
743
+
639
744
  /* Arms the ttl > 0 expiry sweep; `expiresAt` re-checks at fire time so a refreshed deadline survives. */
640
745
  function armTtlExpiry(store: CacheStore, entry: CacheEntry, ttl: number): void {
641
746
  entry.expiresAt = Date.now() + ttl
@@ -15,7 +15,12 @@ the first cache() read adopts its call site's ttl (see CacheEntry).
15
15
  */
16
16
  export function cacheEntryFromSnapshot(entry: CacheSnapshotEntry): CacheEntry {
17
17
  const headers = new Headers(entry.headers)
18
- const response = new Response(entry.body, {
18
+ /* Body text for async readers / shareable clones. A json body arrives PARSED as `data`
19
+ (ADR-0051): re-stringify it (compact JSON — value-identical to the server body, whose
20
+ `json()` also emits compact JSON) so a later `.json()`/`.text()`/`.clone()` still works.
21
+ A text (or parse-failed json) body arrives raw as `body`. */
22
+ const bodyText = 'data' in entry ? JSON.stringify(entry.data) : (entry.body ?? '')
23
+ const response = new Response(bodyText, {
19
24
  status: entry.status,
20
25
  statusText: entry.statusText,
21
26
  headers,
@@ -26,7 +31,7 @@ export function cacheEntryFromSnapshot(entry: CacheSnapshotEntry): CacheEntry {
26
31
  request: new Request(entry.url, { method: entry.method }),
27
32
  ttl: undefined,
28
33
  expiresAt: undefined,
29
- value: warmValueFromSnapshot(entry.status, headers, entry.body),
34
+ value: warmValueFromSnapshot(entry, headers, bodyText),
30
35
  settled: true,
31
36
  hydrated: true,
32
37
  }
@@ -42,19 +47,23 @@ non-2xx, 204 (the status gate below), streaming, binary (the DEFER sentinel) —
42
47
  yields no warm value and defers to the async path, which throws HttpError /
43
48
  returns the streaming error / blobs exactly as a live call would.
44
49
  */
45
- function warmValueFromSnapshot(status: number, headers: Headers, body: string): unknown {
50
+ function warmValueFromSnapshot(entry: CacheSnapshotEntry, headers: Headers, body: string): unknown {
46
51
  /* Status gate is side-specific: an error/204 has no warm value here, but the live
47
52
  read throws HttpError / returns undefined for the same status — so it stays out of
48
53
  the shared kind mapping. */
49
- if (status === 204 || status < 200 || status >= 300) {
54
+ if (entry.status === 204 || entry.status < 200 || entry.status >= 300) {
50
55
  return undefined
51
56
  }
52
57
  const kind = contentBodyKind(contentTypeOf(headers))
53
58
  const value = bodyValueForKind(
54
59
  kind,
55
- /* The body may still be malformed JSON; fall back to the async path rather than
56
- throwing a SyntaxError synchronously during hydration. */
60
+ /* Pre-parsed json body (`data`, ADR-0051) IS the value — no second parse. A raw json
61
+ body may still be malformed; fall back to the async path rather than throwing a
62
+ SyntaxError synchronously during hydration. */
57
63
  () => {
64
+ if ('data' in entry) {
65
+ return entry.data
66
+ }
58
67
  try {
59
68
  return JSON.parse(body)
60
69
  } catch {
@@ -73,6 +73,15 @@ export type CacheEntry = {
73
73
  its drop-on-ttl / drop-on-invalidate behaviour.
74
74
  */
75
75
  retain?: boolean
76
+ /*
77
+ A retained failed load (negative cache — opt in with `errorTtl`). Holds a
78
+ pristine Response clone (re-served per read by cloning it, never consumed) or
79
+ the network Error, so reads within the window re-surface the same failure with
80
+ no network round-trip. `expiresAt` (armed via the ttl sweep) hard-evicts it when
81
+ the window elapses, so the next read retries. Unset on a successful entry; its
82
+ presence is what the read path checks to short-circuit to the retained error.
83
+ */
84
+ errorResult?: Response | Error
76
85
  }
77
86
 
78
87
  /* Per-key invalidate coalescing: the throttle/debounce policy plus the timer/in-flight state. */
@@ -31,6 +31,17 @@ and coalesces a background refetch (leading-edge-then-coalesce, or
31
31
  fire-after-quiet respectively) instead of dropping the entry. Set one, not both.
32
32
  SWR is otherwise unconditional for replayable remote reads; there is no `swr`
33
33
  toggle.
34
+
35
+ `errorTtl` opts a failed load into the negative cache: without it a failure
36
+ evicts immediately so the next read retries (the default); with it, the failure
37
+ is retained for that many ms — reads within the window re-surface the same
38
+ rejection with no network round-trip, backing off a failing/rate-limited backend
39
+ instead of hammering it — then hard-evicts so the next read retries. A function
40
+ form receives the failed response's HTTP status (0 for a network-level fault with
41
+ no response) and returns a per-status window, or `undefined` to fall back to the
42
+ immediate-retry default for that status (e.g. back off a 429/503, retry a 500 at
43
+ once). A `Retry-After` header on the response overrides the number as the
44
+ authoritative delay. Off (evict-and-retry) when unset.
34
45
  */
35
46
  export type CacheOptions = {
36
47
  ttl?: number
@@ -38,4 +49,5 @@ export type CacheOptions = {
38
49
  shared?: boolean
39
50
  throttle?: number
40
51
  debounce?: number
52
+ errorTtl?: number | ((status: number) => number | undefined)
41
53
  }
@@ -1,10 +1,10 @@
1
1
  /*
2
2
  Endpoint cache policy, declared once on the rpc definition (ADR-0020). Same value
3
3
  shape as the producer-side `CacheOptions` (`ttl` / `tags` / `throttle` / `debounce`
4
- / `shared`, no `swr` — SWR is unconditional for replayable reads), except `tags`
5
- also accepts an arg-derived function so the group can vary by call. That makes the
6
- type generic over the rpc's `Args`: `(args) => ['rates:' + args.base]` is typed
7
- against the endpoint's own argument shape.
4
+ / `shared` / `errorTtl`, no `swr` — SWR is unconditional for replayable reads),
5
+ except `tags` also accepts an arg-derived function so the group can vary by call.
6
+ That makes the type generic over the rpc's `Args`: `(args) => ['rates:' + args.base]`
7
+ is typed against the endpoint's own argument shape.
8
8
 
9
9
  - `ttl` — retention/staleness in ms. Default Infinity: the entry is retained for its
10
10
  store's lifetime — the request on the server (a non-shared read dies with the request,
@@ -15,6 +15,11 @@ against the endpoint's own argument shape.
15
15
  - `shared` — opt the entry into the process-level store (never per-user data — the store
16
16
  is keyed by method+url+args, not by user). With the default Infinity ttl this memoises
17
17
  across requests; an explicit ttl bounds it. A client no-op (one tab store).
18
+ - `errorTtl` — negative-cache window in ms: retain a failed load for that long (reads
19
+ within it re-surface the rejection with no network) instead of the default
20
+ evict-and-retry, then hard-evict. A function of the failed status (0 = network fault)
21
+ returns a per-status window or `undefined` to keep the immediate-retry default for that
22
+ status; a `Retry-After` header overrides the number. Off when unset.
18
23
  */
19
24
  export type CachePolicy<Args> = {
20
25
  ttl?: number
@@ -22,4 +27,5 @@ export type CachePolicy<Args> = {
22
27
  throttle?: number
23
28
  debounce?: number
24
29
  shared?: boolean
30
+ errorTtl?: number | ((status: number) => number | undefined)
25
31
  }
@@ -6,6 +6,15 @@ Any entry called inline during render (any method) with a textual Content-Type i
6
6
  emitted, so the client hydrates warm instead of re-firing the call after render;
7
7
  binary bodies don't survive a JSON round-trip. The seeded value is read warm once
8
8
  — it is never auto-replayed (that stays GET-only, see REPLAYABLE_METHODS).
9
+
10
+ The body travels in ONE of two shapes, never both (ADR-0051): a json-kind body
11
+ ships PARSED as `data` — a live JSON value nested directly in the payload — so the
12
+ whole snapshot serialises once (the payload's own `JSON.stringify`) instead of the
13
+ body being pre-serialised to a string and then re-escaped as a nested string
14
+ literal (the double-encoding that ~2.35×'d the wire and forced the client to parse
15
+ the body a second time). A text-kind body — or a json body the server couldn't
16
+ parse — ships raw as `body`. The client re-derives which from the presence of
17
+ `data` (`cacheEntryFromSnapshot`).
9
18
  */
10
19
  export type CacheSnapshotEntry = {
11
20
  key: string
@@ -14,5 +23,10 @@ export type CacheSnapshotEntry = {
14
23
  status: number
15
24
  statusText: string
16
25
  headers: Array<[string, string]>
17
- body: string
26
+ /* Raw body text — present for a text-kind body, or a json body that failed to parse
27
+ server-side (shipped raw, decoded async client-side as before). Absent when `data` is. */
28
+ body?: string
29
+ /* Pre-parsed json-kind body, single-encoded. Present iff the body was json and parsed;
30
+ `'data' in entry` is the discriminator (a `null` json body still keys present). */
31
+ data?: unknown
18
32
  }
@@ -82,7 +82,6 @@ export function compileShadow(
82
82
  propsType = 'Record<string, any>',
83
83
  classify?: InterpolationClassifier,
84
84
  ): CompiledShadow {
85
- const builder = createBuilder(classify)
86
85
  const leadingScript = source.match(LEADING_SCRIPT)
87
86
  const scriptBody = leadingScript?.[1] ?? ''
88
87
  /* Body starts just past the opening `<script …>`; template just past `</script>`. */
@@ -98,7 +97,12 @@ export function compileShadow(
98
97
  importedReactives,
99
98
  propsLocalName,
100
99
  watchLocalName,
100
+ pendingCellNames,
101
101
  } = analyzeScript(scriptBody, scriptStart, classify)
102
+ /* Built AFTER the script analysis so the `pendingCellNames` set (the `$$cellValuePending`
103
+ cells) can ride onto the builder — the `{#await}` emit reads it to strip a bare
104
+ pending-cell subject's pending `undefined` off its resolved binding (ADR-0047 fidelity). */
105
+ const builder = createBuilder(classify, pendingCellNames)
102
106
  builder.raw(shadowPreamble(importedReactives))
103
107
  /* Every `computed`/`linked` scope line reads through this: it invokes the seed thunk and
104
108
  unwraps a promise/stream to the value a BARE cell read peeks (ADR-0019/0032) — so an async
@@ -117,6 +121,14 @@ export function compileShadow(
117
121
  builder.raw(
118
122
  'declare function $$cellValuePending<R>(seed: () => R): (R extends AsyncIterable<infer F> ? F : Awaited<R>) | undefined\n',
119
123
  )
124
+ /* `{#await <pendingCell>}` awaits the CELL (ADR-0047 `$$awaitSubject`) — it shows pending then
125
+ binds the RESOLVED value, never the pending `undefined`. So a bare pending-cell subject's
126
+ resolved/`:then` binding must drop the `| undefined` the `$$cellValuePending` peek carries;
127
+ `$$awaitCell` strips exactly `undefined` (`Exclude`, so a genuine `null` in the value survives).
128
+ Emitted only when a pending cell exists (else the `{#await}` emit never references it). */
129
+ if (pendingCellNames.size > 0) {
130
+ builder.raw('declare function $$awaitCell<V>(v: V): Exclude<V, undefined>\n')
131
+ }
120
132
  /* The peek helpers the async-interpolation wrap targets (ADR-0032): `$$peek` unwraps a promise
121
133
  sub-expression to its resolved value (`undefined` while pending) and `$$peekStream` reads an
122
134
  async iterable's latest frame, so `getFoo()?.name`/`{#if getFoo()}` type-check against the
@@ -245,6 +257,9 @@ type Builder = {
245
257
  /* A fresh shadow-local binding name (`__<base>_<n>`) — for synthesised bindings
246
258
  like an await's resolved value, kept distinct so nested blocks never collide. */
247
259
  unique: (base: string) => string
260
+ /* The `$$cellValuePending` (STREAMING) cells declared in the leading script — a bare
261
+ `{#await <name>}` over one strips the pending `undefined` off its resolved binding. */
262
+ pendingCellNames: ReadonlySet<string>
248
263
  result: () => CompiledShadow
249
264
  }
250
265
 
@@ -252,7 +267,10 @@ type Builder = {
252
267
  emitted verbatim from the original script), offset-relative to the line start. */
253
268
  type ScopeLine = { text: string; segments: ShadowMapping[] }
254
269
 
255
- function createBuilder(classify?: InterpolationClassifier): Builder {
270
+ function createBuilder(
271
+ classify?: InterpolationClassifier,
272
+ pendingCellNames: ReadonlySet<string> = new Set(),
273
+ ): Builder {
256
274
  let code = ''
257
275
  let uniqueCounter = 0
258
276
  const mappings: ShadowMapping[] = []
@@ -302,6 +320,7 @@ function createBuilder(classify?: InterpolationClassifier): Builder {
302
320
  }
303
321
  }
304
322
  const builder: Builder = {
323
+ pendingCellNames,
305
324
  unique(base) {
306
325
  return `__${base}_${uniqueCounter++}`
307
326
  },
@@ -379,6 +398,9 @@ type ScriptAnalysis = {
379
398
  overload so the `watch(cell, handler)` form type-checks (the cell is projected to its
380
399
  value, so the real State/socket/rpc overloads never match it). */
381
400
  watchLocalName: string | undefined
401
+ /* The STREAMING (`$$cellValuePending`) cell names — a bare `{#await <name>}` over one awaits
402
+ the cell and strips its pending `undefined` for the resolved binding (ADR-0047). */
403
+ pendingCellNames: Set<string>
382
404
  }
383
405
 
384
406
  /* Pushes a diagnostic for every author binding whose name starts with the reserved `$$`
@@ -506,6 +528,7 @@ function analyzeScript(
506
528
  const scope: ScopeLine[] = []
507
529
  const propsShapes: string[] = []
508
530
  const diagnostics: ShadowDiagnostic[] = []
531
+ const pendingCellNames = new Set<string>()
509
532
  if (scriptBody.trim() === '') {
510
533
  return {
511
534
  imports,
@@ -516,6 +539,7 @@ function analyzeScript(
516
539
  importedReactives: new Set(),
517
540
  propsLocalName: undefined,
518
541
  watchLocalName: undefined,
542
+ pendingCellNames,
519
543
  }
520
544
  }
521
545
  const file = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
@@ -572,7 +596,17 @@ function analyzeScript(
572
596
  continue
573
597
  }
574
598
  for (const declaration of reactive) {
575
- scope.push(scopeLineFor(declaration, propsShapes, verbatim, span, bindings, classify))
599
+ scope.push(
600
+ scopeLineFor(
601
+ declaration,
602
+ propsShapes,
603
+ verbatim,
604
+ span,
605
+ bindings,
606
+ classify,
607
+ pendingCellNames,
608
+ ),
609
+ )
576
610
  }
577
611
  }
578
612
  return {
@@ -584,6 +618,7 @@ function analyzeScript(
584
618
  importedReactives,
585
619
  propsLocalName,
586
620
  watchLocalName,
621
+ pendingCellNames,
587
622
  }
588
623
  }
589
624
 
@@ -710,6 +745,9 @@ function scopeLineFor(
710
745
  span: (node: ts.Node, prefixLength: number) => ShadowMapping,
711
746
  bindings: ReactiveImportBindings,
712
747
  classify?: InterpolationClassifier,
748
+ /* Collects the names that project through `$$cellValuePending` (STREAMING cells) so the
749
+ `{#await}` emit can strip their pending `undefined`. Absent for nested scripts. */
750
+ pendingCellNames?: Set<string>,
713
751
  ): ScopeLine {
714
752
  const name = ts.isIdentifier(declaration.name) ? declaration.name.text : '_'
715
753
  const call = declaration.initializer as ts.CallExpression
@@ -807,12 +845,21 @@ function scopeLineFor(
807
845
  const fnIsThunk = ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)
808
846
  const wrapPrefix = fnIsThunk ? '' : seedIsAsync(fn) ? 'async () => (' : '() => ('
809
847
  const wrapSuffix = fnIsThunk ? '' : ')'
810
- /* ADR-0042: a STREAMING promise cell (async seed, no top-level `await`) reads
811
- `undefined`-while-pending → `$$cellValuePending` (resolved `T | undefined`). A BLOCKING
812
- (`await`) cell suspends its region so it is always resolved, and a sync cell is never pending
813
- → `$$cellValue` (resolved `T`). (A bare stream seed keeps `$$cellValue`'s frame type — the
814
- pre-existing type-directed case, unchanged here.) */
815
- const helper = seedIsAsync(fn) && !seedIsBlocking(fn) ? '$$cellValuePending' : '$$cellValue'
848
+ /* ADR-0042/0043: a STREAMING cell reads `undefined`-while-pending → `$$cellValuePending`
849
+ (resolved `T | undefined`). Two seed shapes stream: a no-`await` async thunk (`async () =>
850
+ x`), and a bare call/identifier whose type resolves to a promise/stream — the latter is what
851
+ desugarSignals routes to `trackedComputed(…, streaming)` (`isPromiseComputed` /
852
+ `isEagerStreamComputed`), so the shadow must agree or a `state.computed(getFoo())` reads as
853
+ non-optional `T` while the runtime peeks `undefined`. A BLOCKING (`await`) cell suspends its
854
+ region so it is always resolved, and a genuinely sync seed is never pending → `$$cellValue`
855
+ (resolved `T`). The bare-seed arm needs the warm classifier; fail-open keeps `$$cellValue`. */
856
+ const streamingSeed =
857
+ (seedIsAsync(fn) && !seedIsBlocking(fn)) || bareSeedStreams(fn, span, verbatim, classify)
858
+ const helper = streamingSeed ? '$$cellValuePending' : '$$cellValue'
859
+ /* Record the pending cell so `{#await <name>}` strips its pending `undefined` (ADR-0047). */
860
+ if (streamingSeed && ts.isIdentifier(declaration.name)) {
861
+ pendingCellNames?.add(name)
862
+ }
816
863
  const prefix = `${keyword} ${name}${annotation} = ${helper}(${wrapPrefix}`
817
864
  /* Peek-wrap any buried async sub-expression in the seed (ADR-0032, script side) so a bare
818
865
  `getSession()?.x ?? []` types as its resolved value — matching the `desugarSignals` lift.
@@ -870,6 +917,31 @@ function seedIsBlocking(seed: ts.Expression): boolean {
870
917
  return hasTopLevelAwait(seed)
871
918
  }
872
919
 
920
+ /* True for a bare (non-thunk, no-`await`) call/identifier seed whose checker type resolves to a
921
+ promise or async iterable — the STREAMING cell desugarSignals routes to `trackedComputed(…,
922
+ streaming)` (`isPromiseComputed` / `isEagerStreamComputed`). Such a cell peeks `undefined` while
923
+ pending, so its shadow type must be `$$cellValuePending` (resolved `T | undefined`), matching the
924
+ `async () => x` streaming form. Restricted to a bare CALL/IDENTIFIER seed — the whole-seed shape,
925
+ mirroring desugarSignals' bare-seed routing; a compound seed's buried async sub-expression is
926
+ peek-wrapped separately. Needs the warm classifier — absent (or on a resolution throw) it returns
927
+ false, keeping today's `$$cellValue`. */
928
+ function bareSeedStreams(
929
+ seed: ts.Expression,
930
+ span: (node: ts.Node, prefixLength: number) => ShadowMapping,
931
+ verbatim: (node: ts.Node) => string,
932
+ classify: InterpolationClassifier | undefined,
933
+ ): boolean {
934
+ if (classify === undefined || (!ts.isCallExpression(seed) && !ts.isIdentifier(seed))) {
935
+ return false
936
+ }
937
+ try {
938
+ const kind = classify(span(seed, 0).sourceStart, verbatim(seed))
939
+ return kind === 'promise' || kind === 'asyncIterable'
940
+ } catch {
941
+ return false
942
+ }
943
+ }
944
+
873
945
  /* Whether any ELEMENT in the tree carries an `attach` — gates emitting the DOM-typed
874
946
  attachment aliases. All nested content (block bodies, await/switch branches, snippet
875
947
  bodies) routes through `children`, so a recursive `children` walk is complete. */
@@ -1123,9 +1195,17 @@ function emitNode(node: TemplateNode, builder: Builder): void {
1123
1195
  they're the pending content, checked without the resolved value. */
1124
1196
  const resolved = builder.unique('awaited')
1125
1197
  builder.raw('{\n')
1126
- builder.raw(`const ${resolved} = await `)
1198
+ /* A bare `{#await <pendingCell>}` awaits the CELL (ADR-0047 `$$awaitSubject`): it shows
1199
+ pending then binds the RESOLVED value, never the pending `undefined` the
1200
+ `$$cellValuePending` peek carries. So strip that `undefined` with `$$awaitCell` — the
1201
+ resolved/`:then` bindings all derive from `resolved`, so one strip covers every one.
1202
+ Only a whole-subject bare pending-cell reference qualifies (mirrors the runtime
1203
+ `awaitSubjectExpr` classification); any other subject (a plain promise, a blocking
1204
+ cell, a compound expression) stays a plain `await`. */
1205
+ const subjectIsPendingCell = builder.pendingCellNames.has(node.promise.trim())
1206
+ builder.raw(`const ${resolved} = ${subjectIsPendingCell ? '$$awaitCell(' : 'await '}`)
1127
1207
  builder.expr(node.promise, node.loc)
1128
- builder.raw(`;\nvoid ${resolved};\n`)
1208
+ builder.raw(`${subjectIsPendingCell ? ')' : ''};\nvoid ${resolved};\n`)
1129
1209
  const pending = node.children.filter((child) => child.kind !== 'branch')
1130
1210
  const branches = node.children.filter((child) => child.kind === 'branch')
1131
1211
  if (node.blocking && node.as !== undefined) {
@@ -85,7 +85,14 @@ export function createScope(
85
85
  declaration order — the local half of a cell's serialization-stable warm-seed key
86
86
  (`${scope.id}:${index}`, see `createAsyncCell`). Per-scope (not global) so a client-only
87
87
  sibling component can't shift another component's cell keys: divergence stays local. Both
88
- SSR and client construct a component's cells in the same order, so the indices agree. */
88
+ SSR and client construct a component's cells in the same order, so the indices agree — but
89
+ ONLY because every cell that CAN become async draws exactly one index per construction,
90
+ independent of whether it materializes as async on a given render. `computed` gets this from
91
+ its static `isAsyncFunction` routing; `linked` decides at runtime (it draws in
92
+ `createAsyncCell` when its seed suspends, else RESERVES the index in its plain-`state`
93
+ fall-through), so a `linked` whose blocking dep is in flight on the server but warm on the
94
+ client still occupies its one slot on both sides. Break that "exactly one per construction"
95
+ rule and every downstream cell keys off-by-one across the handoff. */
89
96
  let cellIndex = 0
90
97
 
91
98
  /* `cell` and `nextCellIndex` are not on the public `Scope` type — `cell` is the compiler-only
@@ -62,6 +62,12 @@ export function each<T>(
62
62
  /* Each row's scope, registered with the owner so every live row disposes on owner
63
63
  teardown (the effect's own disposer only unsubscribes it from `items()`). */
64
64
  const group = scopeGroup()
65
+ /* Persistent reconcile scratch, reused across passes so a steady-state reconcile
66
+ allocates nothing: `keys` holds each desired key (one `keyOf` call per item), `resolved`
67
+ its surviving row (or undefined → build). Both are sized to the current list each pass
68
+ and their stale tail is cleared, so a shrinking list retains no disposed-row refs. */
69
+ const keys: string[] = []
70
+ const resolved: (EachRow | undefined)[] = []
65
71
 
66
72
  /* Build a row's range. Hydrate mode (only while the claim cursor is active —
67
73
  read fresh, since a row built by a post-hydration reconcile must create, not
@@ -177,22 +183,30 @@ export function each<T>(
177
183
  withoutHydration(() => {
178
184
  /* Undefined source → empty list (see the hydration loop, ADR-0032 D3). */
179
185
  const list = source == null ? [] : Array.isArray(source) ? source : [...source]
180
- const keys = list.map(keyOf)
186
+ const count = list.length
181
187
  generation += 1
182
188
  const pass = generation
183
- /* Resolve each desired key to its surviving row once, here, stamping it with
184
- this pass — so prune below is an int compare (no `Set`) and the placement
185
- walk reuses the resolved row instead of a second `rows.get`. Halving the map
186
- lookups per reconcile is a real win once the harness's O(n) `nextSibling` no
187
- longer swamps it. `undefined` marks a key with no surviving row — a build. */
188
- const resolved: (EachRow | undefined)[] = new Array(keys.length)
189
- for (let index = 0; index < keys.length; index += 1) {
190
- const row = rows.get(keys[index] as string)
189
+ /* Fill the reused scratch and resolve each desired key to its surviving row once,
190
+ here, stamping it with this pass — so prune below is an int compare (no `Set`)
191
+ and the placement walk reuses the resolved row instead of a second `rows.get`.
192
+ Halving the map lookups per reconcile is a real win once the harness's O(n)
193
+ `nextSibling` no longer swamps it. `undefined` marks a key with no surviving row
194
+ — a build. One `keyOf` call per item, cached in `keys` for the placement walk. */
195
+ for (let index = 0; index < count; index += 1) {
196
+ const key = keyOf(list[index] as T)
197
+ keys[index] = key
198
+ const row = rows.get(key)
191
199
  if (row !== undefined) {
192
200
  row.gen = pass
193
201
  }
194
202
  resolved[index] = row
195
203
  }
204
+ /* Drop the previous pass's longer tail so a shrunk list retains no disposed-row
205
+ refs and the scratch stays sized to the live list. */
206
+ if (keys.length > count) {
207
+ keys.length = count
208
+ resolved.length = count
209
+ }
196
210
  /* Prune departed rows first (those the resolve loop didn't reach this pass) so
197
211
  their ranges don't sit between survivors and throw off the in-place sibling
198
212
  checks below. */
@@ -70,7 +70,22 @@ function bindKeys(
70
70
  if (skip.has(key)) {
71
71
  continue
72
72
  }
73
- const value = object[key]
73
+ /* Reading the value can itself throw a `SuspenseSignal`: when `source()` is a
74
+ restProps proxy (the `<Button {...rest}>` case), its `get` invokes the underlying
75
+ prop thunk, so a key whose expression reads a still-pending blocking `await` suspends
76
+ HERE — after the `source()` enumeration guard already passed. Classify it as an
77
+ attribute; `attr`'s own effect swallows the suspend and fills the value in on settle.
78
+ An event handler never suspends — its thunk returns a function without reading a cell. */
79
+ let value: unknown
80
+ try {
81
+ value = object[key]
82
+ } catch (signal) {
83
+ if (!(signal instanceof SuspenseSignal)) {
84
+ throw signal
85
+ }
86
+ attr(element, key, () => source()[key])
87
+ continue
88
+ }
74
89
  if (key.startsWith('on') && typeof value === 'function') {
75
90
  on(element, key.slice(2), value as EventListener)
76
91
  } else if (typeof value !== 'function') {
@@ -1,6 +1,7 @@
1
1
  import { isAsyncIterable } from '../shared/isAsyncIterable.ts'
2
2
  import type { AsyncState } from '../shared/types/AsyncState.ts'
3
3
  import type { NamedAsyncIterable } from '../shared/types/NamedAsyncIterable.ts'
4
+ import { CURRENT_SCOPE } from './runtime/CURRENT_SCOPE.ts'
4
5
  import { createAsyncCell } from './runtime/createAsyncCell.ts'
5
6
  import { createEffectNode } from './runtime/createEffectNode.ts'
6
7
  import { isAsyncFunction } from './runtime/isAsyncFunction.ts'
@@ -8,6 +9,7 @@ import { routeBareSeed } from './runtime/routeBareSeed.ts'
8
9
  import { SuspenseSignal } from './runtime/SuspenseSignal.ts'
9
10
  import type { State } from './runtime/types/State.ts'
10
11
  import { state } from './state.ts'
12
+ import type { Scope } from './types/Scope.ts'
11
13
 
12
14
  /*
13
15
  A writable cell seeded reactively from upstream — the abide form of Angular's
@@ -95,6 +97,19 @@ export function linked<T>(
95
97
  transform: coerce,
96
98
  }) as AsyncState<T>
97
99
  }
100
+ /* Reserve this cell's async-cell ordinal even though the seed resolved synchronously to a
101
+ plain `state`. Whether a `linked` seed suspends (→ `createAsyncCell`, which draws a
102
+ `nextCellIndex()` for its warm-seed key) or resolves synchronously (→ this plain `state`,
103
+ drawing none) is DATA-dependent, and that data can differ across the SSR→client handoff: a
104
+ blocking dependency still in flight on the server (→ suspend → async cell → index drawn) is
105
+ already warm-adopted on the warm client (→ sync resolve → plain state → no index). Without
106
+ this reserve, every async cell declared AFTER a `linked` in the same scope would key
107
+ off-by-one between sides and adopt the wrong warm seed. Drawing the index here makes a
108
+ `linked` ALWAYS occupy exactly one ordinal — the invariant `computed` gets for free from its
109
+ static `isAsyncFunction` routing — so downstream keys align regardless of which path ran. A
110
+ detached cell (no scope) never warm-seeds, so it draws nothing, mirroring `createAsyncCell`. */
111
+ const scope = CURRENT_SCOPE.current as (Scope & { nextCellIndex: () => number }) | undefined
112
+ scope?.nextCellIndex()
98
113
  /* The cell is a plain `state` — same store, same write path, so `transform` gates
99
114
  reseeds and explicit writes identically. */
100
115
  const cell = state<T>(undefined as T, transform)
@@ -7,6 +7,7 @@ import { cacheStoreSlot } from '../shared/cacheStoreSlot.ts'
7
7
  import { createCacheStore } from '../shared/createCacheStore.ts'
8
8
  import { pageSlot } from '../shared/pageSlot.ts'
9
9
  import { SOCKET_SEED } from '../shared/SOCKET_SEED.ts'
10
+ import { SSR_SCRIPT_ID } from '../shared/SSR_SCRIPT_ID.ts'
10
11
  import { sharedCacheStoreSlot } from '../shared/sharedCacheStoreSlot.ts'
11
12
  import type { SsrPayload } from '../shared/types/SsrPayload.ts'
12
13
  import type { StreamedResolution } from '../shared/types/StreamedResolution.ts'
@@ -32,6 +33,39 @@ navigation — importing each further page's chunk on first visit and probing th
32
33
  destination through the server's app.handle so auth/redirect gating still applies.
33
34
  Returns a disposer. `target` defaults to `#app`; pass one explicitly in tests.
34
35
  */
36
+ /*
37
+ Reads the server's SSR payload. Production ships it as an inert `<script
38
+ type="application/json">` (ADR-0051) parsed once here in the deferred bundle — off the
39
+ critical path, and through the fast JSON grammar rather than a compiled multi-MB JS object
40
+ literal. A pre-set `globalThis.__SSR__` wins first: the uiStartClient tests stamp it
41
+ directly, and it preserves any pre-bundle inline stamp. The parsed payload is republished
42
+ onto `globalThis.__SSR__` for devtools / inspector visibility.
43
+ */
44
+ function readSsrPayload(): Partial<SsrPayload> {
45
+ const globalScope = globalThis as { __SSR__?: Partial<SsrPayload> }
46
+ const preset = globalScope.__SSR__
47
+ if (preset !== undefined) {
48
+ return preset
49
+ }
50
+ const element = typeof document !== 'undefined' ? document.getElementById(SSR_SCRIPT_ID) : null
51
+ const text = element?.textContent
52
+ if (!text) {
53
+ return {}
54
+ }
55
+ /* Degrade a mangled payload to a cold boot rather than throwing out of startClient (a
56
+ proxy/extension could corrupt the script text). The old inline `window.__SSR__ = {…}`
57
+ statement failed the same way — its throw left the global undefined and the `?? {}`
58
+ fallback booted cold — so keep that resilience instead of failing the whole mount. */
59
+ let parsed: Partial<SsrPayload>
60
+ try {
61
+ parsed = JSON.parse(text) as Partial<SsrPayload>
62
+ } catch {
63
+ return {}
64
+ }
65
+ globalScope.__SSR__ = parsed
66
+ return parsed
67
+ }
68
+
35
69
  // @documentation plumbing
36
70
  export function startClient(
37
71
  routes: Record<string, RouteLoader>,
@@ -43,7 +77,7 @@ export function startClient(
43
77
  if (target === null) {
44
78
  throw new Error('[abide] startClient: missing #app target')
45
79
  }
46
- const ssr = (globalThis as { __SSR__?: Partial<SsrPayload> }).__SSR__ ?? {}
80
+ const ssr = readSsrPayload()
47
81
  /* Seed the per-page __SSR__ stamps into their shared slots before mount: the mount
48
82
  base, app name (default log channel), health payload (so health()'s first probe is
49
83
  warm), and the env-configured RPC timeout (ABIDE_CLIENT_TIMEOUT, shipped per