@abide/abide 0.56.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # abide
2
2
 
3
+ ## 0.57.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d09e821: `errorTtl` — opt a failed RPC load into the negative cache
8
+
9
+ 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.
10
+
11
+ ### Patch Changes
12
+
13
+ - 3ce8963: Fix an async-cell warm-seed ordering bug where a `linked` cell reading a blocking dependency could desync SSR and client
14
+
15
+ 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.
16
+
17
+ - e8fb019: Type a bare no-`await` promise/stream `state.computed` seed as pending (`T | undefined`)
18
+
19
+ `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.
20
+
21
+ 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 `?.`/`??`.
22
+
3
23
  ## 0.56.0
4
24
 
5
25
  ### 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.0",
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",
@@ -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
  }
@@ -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
@@ -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
  }
@@ -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
@@ -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)