@abide/abide 0.55.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,39 @@
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
+
23
+ ## 0.56.0
24
+
25
+ ### Minor Changes
26
+
27
+ - lift buried async sub-expressions in state/linked/computed seeds (ADR-0032, script side) ([`6b269f4`](https://github.com/briancray/abide/commit/6b269f45d8ab2e117d62684620542cfd1199631e))
28
+ - a linked seed that reads a pending blocking cell becomes a blocking AsyncState ([`8329365`](https://github.com/briancray/abide/commit/83293652689d6a95c8cf4ffe66722e33be1aa63b))
29
+ - a034b2d: `linked` seed that reads a pending blocking cell becomes a blocking `AsyncState`
30
+
31
+ A `linked()` whose seed transitively reads a blocking (`await`-marked) async cell used to throw a `SuspenseSignal` out of its eager reseed effect at construction — the effect is a leaf with no reader to swallow the suspend, so it escaped to the caller. The classification probe now routes a seed that suspends to a writable async cell, and that cell marks itself blocking when its seed synchronously suspends, so its own reads pause too. A blocking read now behaves the same inside a `linked` seed as it does in a `computed` seed or a template binding.
32
+
33
+ - a034b2d: Lift buried async sub-expressions in `state` / `state.linked` / `state.computed` seeds
34
+
35
+ A script seed whose argument buries a promise/stream call — `state.linked(getSession()?.filteredSources ?? [])` — now lifts that call to an injected streaming peek-cell, exactly as a `{#if getSession()?.x}` template position already does. It reads `undefined` while pending (composing with `?.`/`??`) instead of type-erroring on `Promise.filteredSources`, and reseeds when the source resolves. A bare whole-seed call/identifier (`state.computed(getRates())`, `chat.done()`) is unchanged — only a compound seed that buries an async call is lifted. This closes the gap where the script side was less forgiving than the template side.
36
+
3
37
  ## 0.55.0
4
38
 
5
39
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.55.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
  }
@@ -4,7 +4,11 @@ import { attrLiftPosition } from './attrLiftPosition.ts'
4
4
  import { hasTopLevelAwait } from './hasTopLevelAwait.ts'
5
5
  import { isFunctionScopeBoundary } from './isFunctionScopeBoundary.ts'
6
6
  import { isWhitespaceText } from './isWhitespaceText.ts'
7
- import { type LiftPosition, liftAsyncSubExpressions } from './liftAsyncSubExpressions.ts'
7
+ import {
8
+ type LiftPosition,
9
+ type LiftSpan,
10
+ liftAsyncSubExpressions,
11
+ } from './liftAsyncSubExpressions.ts'
8
12
  import { parseTemplate } from './parseTemplate.ts'
9
13
  import {
10
14
  NESTED_REACTIVE_BINDINGS,
@@ -78,7 +82,6 @@ export function compileShadow(
78
82
  propsType = 'Record<string, any>',
79
83
  classify?: InterpolationClassifier,
80
84
  ): CompiledShadow {
81
- const builder = createBuilder(classify)
82
85
  const leadingScript = source.match(LEADING_SCRIPT)
83
86
  const scriptBody = leadingScript?.[1] ?? ''
84
87
  /* Body starts just past the opening `<script …>`; template just past `</script>`. */
@@ -94,7 +97,12 @@ export function compileShadow(
94
97
  importedReactives,
95
98
  propsLocalName,
96
99
  watchLocalName,
97
- } = analyzeScript(scriptBody, scriptStart)
100
+ pendingCellNames,
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)
98
106
  builder.raw(shadowPreamble(importedReactives))
99
107
  /* Every `computed`/`linked` scope line reads through this: it invokes the seed thunk and
100
108
  unwraps a promise/stream to the value a BARE cell read peeks (ADR-0019/0032) — so an async
@@ -113,6 +121,14 @@ export function compileShadow(
113
121
  builder.raw(
114
122
  'declare function $$cellValuePending<R>(seed: () => R): (R extends AsyncIterable<infer F> ? F : Awaited<R>) | undefined\n',
115
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
+ }
116
132
  /* The peek helpers the async-interpolation wrap targets (ADR-0032): `$$peek` unwraps a promise
117
133
  sub-expression to its resolved value (`undefined` while pending) and `$$peekStream` reads an
118
134
  async iterable's latest frame, so `getFoo()?.name`/`{#if getFoo()}` type-check against the
@@ -241,6 +257,9 @@ type Builder = {
241
257
  /* A fresh shadow-local binding name (`__<base>_<n>`) — for synthesised bindings
242
258
  like an await's resolved value, kept distinct so nested blocks never collide. */
243
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>
244
263
  result: () => CompiledShadow
245
264
  }
246
265
 
@@ -248,7 +267,10 @@ type Builder = {
248
267
  emitted verbatim from the original script), offset-relative to the line start. */
249
268
  type ScopeLine = { text: string; segments: ShadowMapping[] }
250
269
 
251
- function createBuilder(classify?: InterpolationClassifier): Builder {
270
+ function createBuilder(
271
+ classify?: InterpolationClassifier,
272
+ pendingCellNames: ReadonlySet<string> = new Set(),
273
+ ): Builder {
252
274
  let code = ''
253
275
  let uniqueCounter = 0
254
276
  const mappings: ShadowMapping[] = []
@@ -298,6 +320,7 @@ function createBuilder(classify?: InterpolationClassifier): Builder {
298
320
  }
299
321
  }
300
322
  const builder: Builder = {
323
+ pendingCellNames,
301
324
  unique(base) {
302
325
  return `__${base}_${uniqueCounter++}`
303
326
  },
@@ -375,6 +398,9 @@ type ScriptAnalysis = {
375
398
  overload so the `watch(cell, handler)` form type-checks (the cell is projected to its
376
399
  value, so the real State/socket/rpc overloads never match it). */
377
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>
378
404
  }
379
405
 
380
406
  /* Pushes a diagnostic for every author binding whose name starts with the reserved `$$`
@@ -492,12 +518,17 @@ function collectNestedScriptAwaitDiagnostics(
492
518
  module-scope type declarations, the value-typed scope lines, and the `props<Shape>()`
493
519
  prop shapes. `scriptStart` is the body's absolute offset in the source, so verbatim
494
520
  spans map back exactly. */
495
- function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis {
521
+ function analyzeScript(
522
+ scriptBody: string,
523
+ scriptStart: number,
524
+ classify?: InterpolationClassifier,
525
+ ): ScriptAnalysis {
496
526
  const imports: ScopeLine[] = []
497
527
  const types: ScopeLine[] = []
498
528
  const scope: ScopeLine[] = []
499
529
  const propsShapes: string[] = []
500
530
  const diagnostics: ShadowDiagnostic[] = []
531
+ const pendingCellNames = new Set<string>()
501
532
  if (scriptBody.trim() === '') {
502
533
  return {
503
534
  imports,
@@ -508,6 +539,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
508
539
  importedReactives: new Set(),
509
540
  propsLocalName: undefined,
510
541
  watchLocalName: undefined,
542
+ pendingCellNames,
511
543
  }
512
544
  }
513
545
  const file = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
@@ -564,7 +596,17 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
564
596
  continue
565
597
  }
566
598
  for (const declaration of reactive) {
567
- scope.push(scopeLineFor(declaration, propsShapes, verbatim, span, bindings))
599
+ scope.push(
600
+ scopeLineFor(
601
+ declaration,
602
+ propsShapes,
603
+ verbatim,
604
+ span,
605
+ bindings,
606
+ classify,
607
+ pendingCellNames,
608
+ ),
609
+ )
568
610
  }
569
611
  }
570
612
  return {
@@ -576,6 +618,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
576
618
  importedReactives,
577
619
  propsLocalName,
578
620
  watchLocalName,
621
+ pendingCellNames,
579
622
  }
580
623
  }
581
624
 
@@ -623,12 +666,88 @@ function reactiveDeclarations(
623
666
 
624
667
  /* Builds one scope line for a reactive declaration, projecting it to its value
625
668
  type. A `props()` destructure contributes its whole shape (pushed into `propsShapes`). */
669
+ /* Splits a SEED's source text so each buried async sub-expression is wrapped in its shadow peek
670
+ helper (`$$peek`/`$$peekStream`) — the script-seed twin of the builder's `emitPeeked` (ADR-0032),
671
+ but returning a mapped `{ text, segments }` for a scope line rather than driving the builder. So a
672
+ bare `state.linked(getSession()?.x ?? [])` shadow-types via `$$peek(getSession())?.x ?? []` (the
673
+ RESOLVED value, `?.`/`??` compose) instead of erroring on `Promise.x` — mirroring exactly what the
674
+ `desugarSignals` lift builds. Fail-open: no classifier, a thunk seed (the walk stops at function
675
+ boundaries — matching the build, which also leaves a `() => …` seed unlifted), or an unparseable
676
+ span → the seed emits verbatim as one span. Every original char keeps its true source offset; the
677
+ inserted wrapper chars are unmapped, so diagnostics land precisely. */
678
+ function peekWrappedSeed(
679
+ seedText: string,
680
+ sourceStart: number,
681
+ prefixLength: number,
682
+ classify: InterpolationClassifier | undefined,
683
+ ): { text: string; segments: ShadowMapping[] } {
684
+ const verbatimResult = {
685
+ text: seedText,
686
+ segments: [{ shadowStart: prefixLength, sourceStart, length: seedText.length }],
687
+ }
688
+ if (classify === undefined) {
689
+ return verbatimResult
690
+ }
691
+ let spans: LiftSpan[]
692
+ try {
693
+ let counter = 0
694
+ spans = liftAsyncSubExpressions(
695
+ seedText,
696
+ sourceStart,
697
+ classify,
698
+ () => `__v${counter++}`,
699
+ 'content',
700
+ ).spans.filter((span) => !span.blocking)
701
+ } catch {
702
+ return verbatimResult
703
+ }
704
+ if (spans.length === 0) {
705
+ return verbatimResult
706
+ }
707
+ /* A single span covering the WHOLE seed is the whole-seed case (`computed(getRates())`): the
708
+ `$$cellValue`/`$$cellValuePending` wrapper the caller already applies unwraps it, so peek-
709
+ wrapping it here would double-unwrap (`T` → `T | undefined`). Skip it — only PROPER sub-
710
+ expressions wrap, matching `liftScriptSeed`'s identical whole-seed skip on the build side. */
711
+ const only = spans.length === 1 ? spans[0] : undefined
712
+ if (only !== undefined && seedText.slice(only.start, only.end).trim() === seedText.trim()) {
713
+ return verbatimResult
714
+ }
715
+ let text = ''
716
+ const segments: ShadowMapping[] = []
717
+ const pushMapped = (chunk: string, chunkSource: number): void => {
718
+ segments.push({
719
+ shadowStart: prefixLength + text.length,
720
+ sourceStart: chunkSource,
721
+ length: chunk.length,
722
+ })
723
+ text += chunk
724
+ }
725
+ let cursor = 0
726
+ for (const span of spans) {
727
+ if (span.start > cursor) {
728
+ pushMapped(seedText.slice(cursor, span.start), sourceStart + cursor)
729
+ }
730
+ text += span.kind === 'asyncIterable' ? '$$peekStream(' : '$$peek('
731
+ pushMapped(seedText.slice(span.start, span.end), sourceStart + span.start)
732
+ text += ')'
733
+ cursor = span.end
734
+ }
735
+ if (cursor < seedText.length) {
736
+ pushMapped(seedText.slice(cursor), sourceStart + cursor)
737
+ }
738
+ return { text, segments }
739
+ }
740
+
626
741
  function scopeLineFor(
627
742
  declaration: ts.VariableDeclaration,
628
743
  propsShapes: string[],
629
744
  verbatim: (node: ts.Node) => string,
630
745
  span: (node: ts.Node, prefixLength: number) => ShadowMapping,
631
746
  bindings: ReactiveImportBindings,
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>,
632
751
  ): ScopeLine {
633
752
  const name = ts.isIdentifier(declaration.name) ? declaration.name.text : '_'
634
753
  const call = declaration.initializer as ts.CallExpression
@@ -689,9 +808,12 @@ function scopeLineFor(
689
808
  })
690
809
  }
691
810
  const prefix = `let ${name}${annotation} = (`
811
+ /* Peek-wrap a buried async sub-expression in the initial too (the `desugarSignals` lift
812
+ runs on `state` seeds), so `state(getSession()?.x ?? [])` type-checks. */
813
+ const seed = seedPeekWrap(init, verbatim, span, prefix.length, classify)
692
814
  return withCalleeRef({
693
- text: `${prefix}${verbatim(init)});`,
694
- segments: [span(declaration.name, 4), span(init, prefix.length)],
815
+ text: `${prefix}${seed.text});`,
816
+ segments: [span(declaration.name, 4), ...seed.segments],
695
817
  })
696
818
  }
697
819
  /* computed<T>(compute) / linked<T>(seed) — the only callees left: T is the value
@@ -723,19 +845,49 @@ function scopeLineFor(
723
845
  const fnIsThunk = ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)
724
846
  const wrapPrefix = fnIsThunk ? '' : seedIsAsync(fn) ? 'async () => (' : '() => ('
725
847
  const wrapSuffix = fnIsThunk ? '' : ')'
726
- /* ADR-0042: a STREAMING promise cell (async seed, no top-level `await`) reads
727
- `undefined`-while-pending → `$$cellValuePending` (resolved `T | undefined`). A BLOCKING
728
- (`await`) cell suspends its region so it is always resolved, and a sync cell is never pending
729
- → `$$cellValue` (resolved `T`). (A bare stream seed keeps `$$cellValue`'s frame type — the
730
- pre-existing type-directed case, unchanged here.) */
731
- 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
+ }
732
863
  const prefix = `${keyword} ${name}${annotation} = ${helper}(${wrapPrefix}`
864
+ /* Peek-wrap any buried async sub-expression in the seed (ADR-0032, script side) so a bare
865
+ `getSession()?.x ?? []` types as its resolved value — matching the `desugarSignals` lift.
866
+ A whole-seed async call / a thunk seed yields no wrap (verbatim), exactly as the build. */
867
+ const seed = seedPeekWrap(fn, verbatim, span, prefix.length, classify)
733
868
  return withCalleeRef({
734
- text: `${prefix}${verbatim(fn)}${wrapSuffix});`,
735
- segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
869
+ text: `${prefix}${seed.text}${wrapSuffix});`,
870
+ segments: [span(declaration.name, keywordOffset), ...seed.segments],
736
871
  })
737
872
  }
738
873
 
874
+ /* The seed peek-wrap as a scope line contributes it: a bare CALL or IDENTIFIER seed (the whole-seed
875
+ / probe case — `getRates()`, `chat.done()`) stays verbatim so the `$$cellValue` wrapper unwraps it
876
+ and no probe receiver is lifted, mirroring `desugarSignals`'s bare-seed skip; every other seed
877
+ descends into `peekWrappedSeed`. */
878
+ function seedPeekWrap(
879
+ seed: ts.Expression,
880
+ verbatim: (node: ts.Node) => string,
881
+ span: (node: ts.Node, prefixLength: number) => ShadowMapping,
882
+ prefixLength: number,
883
+ classify: InterpolationClassifier | undefined,
884
+ ): { text: string; segments: ShadowMapping[] } {
885
+ if (ts.isCallExpression(seed) || ts.isIdentifier(seed)) {
886
+ return { text: verbatim(seed), segments: [span(seed, prefixLength)] }
887
+ }
888
+ return peekWrappedSeed(verbatim(seed), span(seed, 0).sourceStart, prefixLength, classify)
889
+ }
890
+
739
891
  /* True for a `computed`/`linked` seed that `wrapSeed` (desugarSignals) turns into an ASYNC thunk —
740
892
  a thunk the author wrote `async`, or a bare seed carrying a top-level `await` (`computed(await
741
893
  x)`). The shadow must agree so it unwraps the promise to the resolved value AND so the top-level
@@ -765,6 +917,31 @@ function seedIsBlocking(seed: ts.Expression): boolean {
765
917
  return hasTopLevelAwait(seed)
766
918
  }
767
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
+
768
945
  /* Whether any ELEMENT in the tree carries an `attach` — gates emitting the DOM-typed
769
946
  attachment aliases. All nested content (block bodies, await/switch branches, snippet
770
947
  bodies) routes through `children`, so a recursive `children` walk is complete. */
@@ -1018,9 +1195,17 @@ function emitNode(node: TemplateNode, builder: Builder): void {
1018
1195
  they're the pending content, checked without the resolved value. */
1019
1196
  const resolved = builder.unique('awaited')
1020
1197
  builder.raw('{\n')
1021
- 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 '}`)
1022
1207
  builder.expr(node.promise, node.loc)
1023
- builder.raw(`;\nvoid ${resolved};\n`)
1208
+ builder.raw(`${subjectIsPendingCell ? ')' : ''};\nvoid ${resolved};\n`)
1024
1209
  const pending = node.children.filter((child) => child.kind !== 'branch')
1025
1210
  const branches = node.children.filter((child) => child.kind === 'branch')
1026
1211
  if (node.blocking && node.as !== undefined) {
@@ -1,8 +1,11 @@
1
1
  import ts from 'typescript'
2
2
  import { assignmentTargetNames } from './assignmentTargetNames.ts'
3
3
  import { hasTopLevelAwait } from './hasTopLevelAwait.ts'
4
+ import { liftScriptSeed, type ScriptSeedLift } from './liftScriptSeed.ts'
5
+ import type { InjectedCell } from './lowerAsyncInterpolations.ts'
4
6
  import { type ReactiveImportBindings, reactiveImportBindings } from './resolveReactiveExport.ts'
5
7
  import { signalCallee } from './signalCallee.ts'
8
+ import type { InterpolationClassifier } from './types/InterpolationClassifier.ts'
6
9
  import type { InterpolationKind } from './types/InterpolationKind.ts'
7
10
  import type { SeedTypeClassifier } from './types/SeedTypeClassifier.ts'
8
11
  import { wrapSeed } from './wrapSeed.ts'
@@ -279,6 +282,77 @@ export function desugarSignals(
279
282
  }
280
283
  return seedKind(argument) === 'promise'
281
284
  }
285
+ /* ADR-0032 (script side): a `state`/`linked`/`computed` seed whose ARGUMENT buries a
286
+ promise/stream sub-expression (`getSession()?.filteredSources ?? []`) — not the whole seed
287
+ — gets that sub-expression lifted to an injected streaming peek-cell, exactly as a template
288
+ interpolation does, so it reads `undefined` while pending and composes with `?.`/`??`. The
289
+ lift runs on the seed's checker type via the SAME classifier the whole-seed routing uses,
290
+ bridged to the interpolation shape (its `undefined` failure sentinel folds to `sync`, safe
291
+ because the walk only branches on `promise`/`asyncIterable`). Cached per declaration so the
292
+ shared `__vs` mint stays consistent between the name-collection pass and the transform. */
293
+ const liftClassify: InterpolationClassifier | undefined =
294
+ seedClassify === undefined ? undefined : (loc, code) => seedClassify(loc, code) ?? 'sync'
295
+ let scriptSeedCounter = 0
296
+ const mintScriptSeed = (): string => `__vs${scriptSeedCounter++}`
297
+ const seedLifts = new Map<ts.VariableDeclaration, ScriptSeedLift | undefined>()
298
+ const seedLiftFor = (declaration: ts.VariableDeclaration): ScriptSeedLift | undefined => {
299
+ if (seedLifts.has(declaration)) {
300
+ return seedLifts.get(declaration)
301
+ }
302
+ const lift = computeSeedLift(declaration)
303
+ seedLifts.set(declaration, lift)
304
+ return lift
305
+ }
306
+ /* Applies the per-callee gate then runs the lift. A seed the WHOLE-seed classification already
307
+ claims — an author `await` (blocking), a bare promise/stream call — is left untouched so the
308
+ existing routing (or the runtime probe) owns it and nothing double-wraps; only a buried
309
+ sub-expression in an otherwise-sync seed lifts. */
310
+ function computeSeedLift(declaration: ts.VariableDeclaration): ScriptSeedLift | undefined {
311
+ if (liftClassify === undefined) {
312
+ return undefined
313
+ }
314
+ /* Only a single-declaration statement lifts: the injected peek-cells are emitted before the
315
+ owning statement, which can't split a `let a = …, b = …` list cleanly — and a
316
+ `rejectMixedDeclaration`-legal same-kind multi-declaration seed-lift is vanishingly rare.
317
+ Leaving it unlifted degrades to the pre-lift behavior (no injected orphan cell). */
318
+ const list = declaration.parent
319
+ if (!ts.isVariableDeclarationList(list) || list.declarations.length !== 1) {
320
+ return undefined
321
+ }
322
+ const callee = signalCallee(declaration, bindings)
323
+ if (callee === 'computed') {
324
+ if (
325
+ !isComputedSlot(declaration, bindings) ||
326
+ isAsyncComputed(declaration) ||
327
+ isEagerStreamComputed(declaration) ||
328
+ isPromiseComputed(declaration)
329
+ ) {
330
+ return undefined
331
+ }
332
+ } else if (callee === 'linked' || callee === 'state') {
333
+ const argument = seedArgument(declaration)
334
+ if (argument === undefined || isAsyncSeed(wrapSeed(argument))) {
335
+ return undefined
336
+ }
337
+ } else {
338
+ return undefined
339
+ }
340
+ const argument = seedArgument(declaration)
341
+ if (argument === undefined) {
342
+ return undefined
343
+ }
344
+ /* A bare CALL or IDENTIFIER seed is the whole-seed case (`computed(getRates())`,
345
+ `linked(chat.done())`, `state.computed(getRates.refreshing({...}))`) — the existing
346
+ whole-seed routing / runtime probe owns it, and descending into it would lift a probe
347
+ RECEIVER (`chat` out of `chat.done()`), breaking the call. Only a COMPOUND seed — a
348
+ member/binary/etc. that BURIES an async call — lifts. Mirrors `isBareCallComputed`. */
349
+ if (ts.isCallExpression(argument) || ts.isIdentifier(argument)) {
350
+ return undefined
351
+ }
352
+ const start = argument.getStart(source)
353
+ const seedText = source.text.slice(start, argument.getEnd())
354
+ return liftScriptSeed(seedText, scriptBase + start, liftClassify, mintScriptSeed)
355
+ }
282
356
  for (const statement of source.statements) {
283
357
  if (!ts.isVariableStatement(statement)) {
284
358
  continue
@@ -317,6 +391,16 @@ export function desugarSignals(
317
391
  cellReadNames.add(declaration.name.text)
318
392
  continue
319
393
  }
394
+ /* Register any injected `__vsN` peek-cell a buried async seed sub-expression lifts, so
395
+ the reference-lowering emits `$$readCell(__vsN)` for its reads. The owning
396
+ declaration keeps its own bucket (a lifted computed's seed is now SYNC — a lazy
397
+ `derive` — while a lifted `linked`/`state` stays its usual cell). */
398
+ const seedLift = seedLiftFor(declaration)
399
+ if (seedLift !== undefined) {
400
+ for (const cell of seedLift.cells) {
401
+ cellReadNames.add(cell.name)
402
+ }
403
+ }
320
404
  if (isPlainStateSlot(declaration, bindings)) {
321
405
  /* Plain `state(initial)` → a serializable `model` doc slot. */
322
406
  stateNames.add(declaration.name.text)
@@ -394,6 +478,7 @@ export function desugarSignals(
394
478
  writtenNames,
395
479
  isEagerStreamComputed,
396
480
  isPromiseComputed,
481
+ seedLiftFor,
397
482
  ),
398
483
  )
399
484
  }
@@ -422,9 +507,11 @@ function loweredStatement(
422
507
  writtenNames: ReadonlySet<string>,
423
508
  isEagerStreamComputed: EagerStreamPredicate,
424
509
  isPromiseComputed: EagerStreamPredicate,
510
+ seedLiftFor: (declaration: ts.VariableDeclaration) => ScriptSeedLift | undefined,
425
511
  ): ts.Statement[] {
426
512
  rejectMixedDeclaration(statement, bindings)
427
513
  return (
514
+ liftedSeedStatements(statement, bindings, seedLiftFor) ??
428
515
  injectedComputedStatements(statement, injectedCellNames, blockingCellNames) ??
429
516
  stateAssignmentStatements(statement, bindings) ??
430
517
  computedStatements(
@@ -439,6 +526,106 @@ function loweredStatement(
439
526
  )
440
527
  }
441
528
 
529
+ /* Parses a lifted seed fragment (`getSession()` / `__vs0?.filteredSources ?? []`) back into an
530
+ expression node. Wrapped in parens so an object-literal or comma seed parses as an expression;
531
+ the node is synthetic (mini-parse positions), safe because every downstream pass — reference
532
+ renaming, doc-access lowering — is position-independent. */
533
+ function parseSeedExpression(code: string): ts.Expression {
534
+ const file = ts.createSourceFile('__seed.ts', `(${code})`, ts.ScriptTarget.Latest, true)
535
+ const statement = file.statements[0]
536
+ if (statement !== undefined && ts.isExpressionStatement(statement)) {
537
+ const expression = statement.expression
538
+ return ts.isParenthesizedExpression(expression) ? expression.expression : expression
539
+ }
540
+ throw new Error(`abide: could not parse lifted seed expression: ${code}`)
541
+ }
542
+
543
+ /* `() => (<expr>)` — the bare thunk an injected STREAM peek-cell wraps its source in (byte-mirrors
544
+ an explicit `state.computed(getStream())`). */
545
+ function bareThunk(expression: ts.Expression): ts.Expression {
546
+ return factory.createArrowFunction(
547
+ undefined,
548
+ undefined,
549
+ [],
550
+ undefined,
551
+ factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
552
+ factory.createParenthesizedExpression(expression),
553
+ )
554
+ }
555
+
556
+ /* The injected `const __vsN = scope().trackedComputed(...)` cell for one lifted sub-expression —
557
+ the script-side twin of `injectedComputedStatements`: a PROMISE sub-expression becomes an async
558
+ unwrapping thunk carrying the streaming flag (`!blocking`), a STREAM stays a bare probe thunk. */
559
+ function injectedSeedCell(cell: InjectedCell): ts.Statement {
560
+ const source = parseSeedExpression(cell.code)
561
+ const args =
562
+ cell.kind === 'promise'
563
+ ? [wrapAwaitSeed(source), cell.blocking ? factory.createFalse() : factory.createTrue()]
564
+ : [bareThunk(source)]
565
+ return constDeclaration(cell.name, scopeMethodCall('trackedComputed', args))
566
+ }
567
+
568
+ /* If any declaration in `statement` carries a script-seed lift (ADR-0032), lowers the whole
569
+ statement HERE: each lifted declaration emits its injected `__vsN` peek-cells FIRST (declared
570
+ before the owning statement — the seed runs eagerly at construction, so an appended cell would
571
+ be a TDZ reference), then the owning declaration lowered over its now-SYNC rewritten seed (a
572
+ lifted `computed` is a lazy `derive`; a `linked`/`state` keeps its cell). A non-lifted sibling
573
+ in the same (same-kind) statement re-enters the normal lowering as its own single declaration.
574
+ Returns undefined when no declaration lifts, so the standard dispatch runs. */
575
+ function liftedSeedStatements(
576
+ statement: ts.Statement,
577
+ bindings: ReactiveImportBindings,
578
+ seedLiftFor: (declaration: ts.VariableDeclaration) => ScriptSeedLift | undefined,
579
+ ): ts.Statement[] | undefined {
580
+ if (!ts.isVariableStatement(statement) || statement.declarationList.declarations.length !== 1) {
581
+ return undefined
582
+ }
583
+ const declaration = statement.declarationList.declarations[0]!
584
+ const lift = seedLiftFor(declaration)
585
+ if (lift === undefined || !ts.isIdentifier(declaration.name)) {
586
+ return undefined
587
+ }
588
+ const output: ts.Statement[] = []
589
+ {
590
+ for (const cell of lift.cells) {
591
+ output.push(injectedSeedCell(cell))
592
+ }
593
+ const name = declaration.name.text
594
+ const seed = parseSeedExpression(lift.rewrittenSeedText)
595
+ const callee = signalCallee(declaration, bindings)
596
+ const rest = (declaration.initializer as ts.CallExpression).arguments.slice(1)
597
+ if (callee === 'computed') {
598
+ /* The rewritten seed is SYNC (it reads the peek-cells), so the computed is a lazy
599
+ `derive` doc slot read as `name()` — recomputes when a peek-cell reseeds. */
600
+ output.push(
601
+ constDeclaration(
602
+ name,
603
+ scopeMethodCall('derive', [factory.createStringLiteral(name), wrapSeed(seed)]),
604
+ ),
605
+ )
606
+ } else if (callee === 'linked') {
607
+ output.push(
608
+ constDeclaration(name, scopeMethodCall('linked', [wrapSeed(seed), ...rest])),
609
+ )
610
+ } else if (isPlainStateSlot(declaration, bindings)) {
611
+ output.push(
612
+ factory.createExpressionStatement(
613
+ factory.createAssignment(
614
+ factory.createPropertyAccessExpression(
615
+ factory.createIdentifier('$$model'),
616
+ name,
617
+ ),
618
+ seed,
619
+ ),
620
+ ),
621
+ )
622
+ } else {
623
+ output.push(constDeclaration(name, scopeMethodCall('state', [seed, ...rest])))
624
+ }
625
+ }
626
+ return output
627
+ }
628
+
442
629
  /* Lowers a synthetic `const __vN = computed(<seed>)` (an async (sub)expression peek-cell
443
630
  `analyzeComponent` injected, ADR-0032) to a `scope().trackedComputed(...)` cell read via
444
631
  `$$readCell`. Two shapes, by the seed:
@@ -0,0 +1,47 @@
1
+ import { liftAsyncSubExpressions } from './liftAsyncSubExpressions.ts'
2
+ import type { InjectedCell } from './lowerAsyncInterpolations.ts'
3
+ import type { InterpolationClassifier } from './types/InterpolationClassifier.ts'
4
+
5
+ /* The pieces a script-seed lift yields: the async sub-expressions hoisted out of a
6
+ `state`/`linked`/`computed` seed argument (each an injected peek-cell) plus the seed
7
+ rewritten to reference them by bare `__vsN`. `desugarSignals` turns `cells` into
8
+ `scope().trackedComputed(...)` consts emitted BEFORE the owning declaration and parses
9
+ `rewrittenSeedText` back into the lowered seed. */
10
+ export type ScriptSeedLift = { cells: InjectedCell[]; rewrittenSeedText: string }
11
+
12
+ /*
13
+ The script-side twin of the template `lowerAsyncInterpolations` walk (ADR-0032): runs
14
+ `liftAsyncSubExpressions` over a `state`/`state.linked`/`state.computed` seed ARGUMENT so a
15
+ buried promise/stream sub-expression (`getSession()?.filteredSources ?? []`) lifts to an
16
+ injected streaming peek-cell — reading `undefined` while pending so `?.`/`??` compose — exactly
17
+ as it would in a `{#if getSession()?.x}` template position. Only PROPER sub-expressions lift: a
18
+ seed that IS a single async expression (`computed(getSession())`, `linked(getStream())`) is left
19
+ whole for the existing whole-seed classification / runtime probe, so this never double-wraps.
20
+
21
+ `classify` absent (no warm program) ⇒ no lift — a buried bare-async sub-expression needs the
22
+ checker to know it is a promise, and a seed carrying a top-level `await` is already routed by the
23
+ caller's whole-seed classification, so fail-open here is a plain no-op. `seedLoc` is the seed's
24
+ absolute source offset, so each lifted span resolves against the shadow's source→shadow mappings.
25
+ */
26
+ export function liftScriptSeed(
27
+ seedText: string,
28
+ seedLoc: number,
29
+ classify: InterpolationClassifier | undefined,
30
+ mint: () => string,
31
+ ): ScriptSeedLift | undefined {
32
+ if (classify === undefined) {
33
+ return undefined
34
+ }
35
+ const result = liftAsyncSubExpressions(seedText, seedLoc, classify, mint, 'content')
36
+ if (result.lifts.length === 0) {
37
+ return undefined
38
+ }
39
+ /* A single span covering the whole seed is the whole-seed case (`computed(getSession())`) —
40
+ leave it to the existing classification (`isPromiseComputed`) / the runtime probe rather
41
+ than pointlessly wrapping the seed in its own cell. Only proper sub-expressions lift. */
42
+ const only = result.spans.length === 1 ? result.spans[0] : undefined
43
+ if (only !== undefined && seedText.slice(only.start, only.end).trim() === seedText.trim()) {
44
+ return undefined
45
+ }
46
+ return { cells: result.lifts, rewrittenSeedText: result.code }
47
+ }
@@ -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,12 +1,15 @@
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'
7
8
  import { routeBareSeed } from './runtime/routeBareSeed.ts'
9
+ import { SuspenseSignal } from './runtime/SuspenseSignal.ts'
8
10
  import type { State } from './runtime/types/State.ts'
9
11
  import { state } from './state.ts'
12
+ import type { Scope } from './types/Scope.ts'
10
13
 
11
14
  /*
12
15
  A writable cell seeded reactively from upstream — the abide form of Angular's
@@ -67,10 +70,26 @@ export function linked<T>(
67
70
  non-stream value falls through to the plain synchronous cell. */
68
71
  let probe: unknown
69
72
  let threw = false
73
+ let suspended = false
70
74
  try {
71
75
  probe = seed()
72
- } catch {
76
+ } catch (error) {
73
77
  threw = true
78
+ suspended = error instanceof SuspenseSignal
79
+ }
80
+ /* The seed SUSPENDED — it read a pending BLOCKING cell (`await`-marked). A sync `state`
81
+ here would hold `undefined` while the reseed effect re-threw the suspend, letting it
82
+ escape at construction. Route to the eager async cell instead: `createAsyncCell` already
83
+ treats a synchronously-suspending seed as "pending" — it stays in flight, the throwing
84
+ read subscribed its effect to the blocking dependency, and it re-runs on settle — and it
85
+ marks the cell BLOCKING so its own reads pause too, matching a lazy `computed(() =>
86
+ blockingCell)`. This makes a blocking read behave the same in a `linked` seed as in a
87
+ `computed` seed or a template. A real (non-suspense) throw keeps the sync fall-through. */
88
+ if (suspended) {
89
+ return createAsyncCell(seed as () => unknown, {
90
+ writable: true,
91
+ transform: coerce,
92
+ }) as AsyncState<T>
74
93
  }
75
94
  if (!threw && isAsyncIterable(probe)) {
76
95
  return createAsyncCell(seed as () => unknown, {
@@ -78,6 +97,19 @@ export function linked<T>(
78
97
  transform: coerce,
79
98
  }) as AsyncState<T>
80
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()
81
113
  /* The cell is a plain `state` — same store, same write path, so `transform` gates
82
114
  reseeds and explicit writes identically. */
83
115
  const cell = state<T>(undefined as T, transform)
@@ -210,6 +210,16 @@ export function createAsyncCell(
210
210
  promise is registered (there is none), so the SSR barrier's fixpoint drain
211
211
  picks this cell up on that re-run instead. */
212
212
  if (error instanceof SuspenseSignal) {
213
+ /* This cell's pending reads must PAUSE too — the seed derives from a blocking
214
+ source, so a reader peeking `undefined` (the streaming default) would race the
215
+ dependency instead of waiting for it. Mark blocking from the SUSPEND itself,
216
+ since a synchronously-suspending seed produces no promise for the `isThenable`
217
+ branch to read it from; honour a `streaming` opt-out for symmetry with that
218
+ branch. Once the seed re-runs and settles a value this flag is moot (a retained
219
+ value is returned before `blocking` is ever consulted). */
220
+ if (options.streaming !== true) {
221
+ blockingSource = true
222
+ }
213
223
  if (myRun === runId) {
214
224
  inFlight = undefined
215
225
  writeNode(errorNode, undefined)