@abide/abide 0.55.0 → 0.56.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,19 @@
1
1
  # abide
2
2
 
3
+ ## 0.56.0
4
+
5
+ ### Minor Changes
6
+
7
+ - lift buried async sub-expressions in state/linked/computed seeds (ADR-0032, script side) ([`6b269f4`](https://github.com/briancray/abide/commit/6b269f45d8ab2e117d62684620542cfd1199631e))
8
+ - a linked seed that reads a pending blocking cell becomes a blocking AsyncState ([`8329365`](https://github.com/briancray/abide/commit/83293652689d6a95c8cf4ffe66722e33be1aa63b))
9
+ - a034b2d: `linked` seed that reads a pending blocking cell becomes a blocking `AsyncState`
10
+
11
+ 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.
12
+
13
+ - a034b2d: Lift buried async sub-expressions in `state` / `state.linked` / `state.computed` seeds
14
+
15
+ 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.
16
+
3
17
  ## 0.55.0
4
18
 
5
19
  ### 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.56.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",
@@ -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,
@@ -94,7 +98,7 @@ export function compileShadow(
94
98
  importedReactives,
95
99
  propsLocalName,
96
100
  watchLocalName,
97
- } = analyzeScript(scriptBody, scriptStart)
101
+ } = analyzeScript(scriptBody, scriptStart, classify)
98
102
  builder.raw(shadowPreamble(importedReactives))
99
103
  /* Every `computed`/`linked` scope line reads through this: it invokes the seed thunk and
100
104
  unwraps a promise/stream to the value a BARE cell read peeks (ADR-0019/0032) — so an async
@@ -492,7 +496,11 @@ function collectNestedScriptAwaitDiagnostics(
492
496
  module-scope type declarations, the value-typed scope lines, and the `props<Shape>()`
493
497
  prop shapes. `scriptStart` is the body's absolute offset in the source, so verbatim
494
498
  spans map back exactly. */
495
- function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis {
499
+ function analyzeScript(
500
+ scriptBody: string,
501
+ scriptStart: number,
502
+ classify?: InterpolationClassifier,
503
+ ): ScriptAnalysis {
496
504
  const imports: ScopeLine[] = []
497
505
  const types: ScopeLine[] = []
498
506
  const scope: ScopeLine[] = []
@@ -564,7 +572,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
564
572
  continue
565
573
  }
566
574
  for (const declaration of reactive) {
567
- scope.push(scopeLineFor(declaration, propsShapes, verbatim, span, bindings))
575
+ scope.push(scopeLineFor(declaration, propsShapes, verbatim, span, bindings, classify))
568
576
  }
569
577
  }
570
578
  return {
@@ -623,12 +631,85 @@ function reactiveDeclarations(
623
631
 
624
632
  /* Builds one scope line for a reactive declaration, projecting it to its value
625
633
  type. A `props()` destructure contributes its whole shape (pushed into `propsShapes`). */
634
+ /* Splits a SEED's source text so each buried async sub-expression is wrapped in its shadow peek
635
+ helper (`$$peek`/`$$peekStream`) — the script-seed twin of the builder's `emitPeeked` (ADR-0032),
636
+ but returning a mapped `{ text, segments }` for a scope line rather than driving the builder. So a
637
+ bare `state.linked(getSession()?.x ?? [])` shadow-types via `$$peek(getSession())?.x ?? []` (the
638
+ RESOLVED value, `?.`/`??` compose) instead of erroring on `Promise.x` — mirroring exactly what the
639
+ `desugarSignals` lift builds. Fail-open: no classifier, a thunk seed (the walk stops at function
640
+ boundaries — matching the build, which also leaves a `() => …` seed unlifted), or an unparseable
641
+ span → the seed emits verbatim as one span. Every original char keeps its true source offset; the
642
+ inserted wrapper chars are unmapped, so diagnostics land precisely. */
643
+ function peekWrappedSeed(
644
+ seedText: string,
645
+ sourceStart: number,
646
+ prefixLength: number,
647
+ classify: InterpolationClassifier | undefined,
648
+ ): { text: string; segments: ShadowMapping[] } {
649
+ const verbatimResult = {
650
+ text: seedText,
651
+ segments: [{ shadowStart: prefixLength, sourceStart, length: seedText.length }],
652
+ }
653
+ if (classify === undefined) {
654
+ return verbatimResult
655
+ }
656
+ let spans: LiftSpan[]
657
+ try {
658
+ let counter = 0
659
+ spans = liftAsyncSubExpressions(
660
+ seedText,
661
+ sourceStart,
662
+ classify,
663
+ () => `__v${counter++}`,
664
+ 'content',
665
+ ).spans.filter((span) => !span.blocking)
666
+ } catch {
667
+ return verbatimResult
668
+ }
669
+ if (spans.length === 0) {
670
+ return verbatimResult
671
+ }
672
+ /* A single span covering the WHOLE seed is the whole-seed case (`computed(getRates())`): the
673
+ `$$cellValue`/`$$cellValuePending` wrapper the caller already applies unwraps it, so peek-
674
+ wrapping it here would double-unwrap (`T` → `T | undefined`). Skip it — only PROPER sub-
675
+ expressions wrap, matching `liftScriptSeed`'s identical whole-seed skip on the build side. */
676
+ const only = spans.length === 1 ? spans[0] : undefined
677
+ if (only !== undefined && seedText.slice(only.start, only.end).trim() === seedText.trim()) {
678
+ return verbatimResult
679
+ }
680
+ let text = ''
681
+ const segments: ShadowMapping[] = []
682
+ const pushMapped = (chunk: string, chunkSource: number): void => {
683
+ segments.push({
684
+ shadowStart: prefixLength + text.length,
685
+ sourceStart: chunkSource,
686
+ length: chunk.length,
687
+ })
688
+ text += chunk
689
+ }
690
+ let cursor = 0
691
+ for (const span of spans) {
692
+ if (span.start > cursor) {
693
+ pushMapped(seedText.slice(cursor, span.start), sourceStart + cursor)
694
+ }
695
+ text += span.kind === 'asyncIterable' ? '$$peekStream(' : '$$peek('
696
+ pushMapped(seedText.slice(span.start, span.end), sourceStart + span.start)
697
+ text += ')'
698
+ cursor = span.end
699
+ }
700
+ if (cursor < seedText.length) {
701
+ pushMapped(seedText.slice(cursor), sourceStart + cursor)
702
+ }
703
+ return { text, segments }
704
+ }
705
+
626
706
  function scopeLineFor(
627
707
  declaration: ts.VariableDeclaration,
628
708
  propsShapes: string[],
629
709
  verbatim: (node: ts.Node) => string,
630
710
  span: (node: ts.Node, prefixLength: number) => ShadowMapping,
631
711
  bindings: ReactiveImportBindings,
712
+ classify?: InterpolationClassifier,
632
713
  ): ScopeLine {
633
714
  const name = ts.isIdentifier(declaration.name) ? declaration.name.text : '_'
634
715
  const call = declaration.initializer as ts.CallExpression
@@ -689,9 +770,12 @@ function scopeLineFor(
689
770
  })
690
771
  }
691
772
  const prefix = `let ${name}${annotation} = (`
773
+ /* Peek-wrap a buried async sub-expression in the initial too (the `desugarSignals` lift
774
+ runs on `state` seeds), so `state(getSession()?.x ?? [])` type-checks. */
775
+ const seed = seedPeekWrap(init, verbatim, span, prefix.length, classify)
692
776
  return withCalleeRef({
693
- text: `${prefix}${verbatim(init)});`,
694
- segments: [span(declaration.name, 4), span(init, prefix.length)],
777
+ text: `${prefix}${seed.text});`,
778
+ segments: [span(declaration.name, 4), ...seed.segments],
695
779
  })
696
780
  }
697
781
  /* computed<T>(compute) / linked<T>(seed) — the only callees left: T is the value
@@ -730,12 +814,33 @@ function scopeLineFor(
730
814
  pre-existing type-directed case, unchanged here.) */
731
815
  const helper = seedIsAsync(fn) && !seedIsBlocking(fn) ? '$$cellValuePending' : '$$cellValue'
732
816
  const prefix = `${keyword} ${name}${annotation} = ${helper}(${wrapPrefix}`
817
+ /* Peek-wrap any buried async sub-expression in the seed (ADR-0032, script side) so a bare
818
+ `getSession()?.x ?? []` types as its resolved value — matching the `desugarSignals` lift.
819
+ A whole-seed async call / a thunk seed yields no wrap (verbatim), exactly as the build. */
820
+ const seed = seedPeekWrap(fn, verbatim, span, prefix.length, classify)
733
821
  return withCalleeRef({
734
- text: `${prefix}${verbatim(fn)}${wrapSuffix});`,
735
- segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
822
+ text: `${prefix}${seed.text}${wrapSuffix});`,
823
+ segments: [span(declaration.name, keywordOffset), ...seed.segments],
736
824
  })
737
825
  }
738
826
 
827
+ /* The seed peek-wrap as a scope line contributes it: a bare CALL or IDENTIFIER seed (the whole-seed
828
+ / probe case — `getRates()`, `chat.done()`) stays verbatim so the `$$cellValue` wrapper unwraps it
829
+ and no probe receiver is lifted, mirroring `desugarSignals`'s bare-seed skip; every other seed
830
+ descends into `peekWrappedSeed`. */
831
+ function seedPeekWrap(
832
+ seed: ts.Expression,
833
+ verbatim: (node: ts.Node) => string,
834
+ span: (node: ts.Node, prefixLength: number) => ShadowMapping,
835
+ prefixLength: number,
836
+ classify: InterpolationClassifier | undefined,
837
+ ): { text: string; segments: ShadowMapping[] } {
838
+ if (ts.isCallExpression(seed) || ts.isIdentifier(seed)) {
839
+ return { text: verbatim(seed), segments: [span(seed, prefixLength)] }
840
+ }
841
+ return peekWrappedSeed(verbatim(seed), span(seed, 0).sourceStart, prefixLength, classify)
842
+ }
843
+
739
844
  /* True for a `computed`/`linked` seed that `wrapSeed` (desugarSignals) turns into an ASYNC thunk —
740
845
  a thunk the author wrote `async`, or a bare seed carrying a top-level `await` (`computed(await
741
846
  x)`). The shadow must agree so it unwraps the promise to the resolved value AND so the top-level
@@ -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
+ }
@@ -5,6 +5,7 @@ import { createAsyncCell } from './runtime/createAsyncCell.ts'
5
5
  import { createEffectNode } from './runtime/createEffectNode.ts'
6
6
  import { isAsyncFunction } from './runtime/isAsyncFunction.ts'
7
7
  import { routeBareSeed } from './runtime/routeBareSeed.ts'
8
+ import { SuspenseSignal } from './runtime/SuspenseSignal.ts'
8
9
  import type { State } from './runtime/types/State.ts'
9
10
  import { state } from './state.ts'
10
11
 
@@ -67,10 +68,26 @@ export function linked<T>(
67
68
  non-stream value falls through to the plain synchronous cell. */
68
69
  let probe: unknown
69
70
  let threw = false
71
+ let suspended = false
70
72
  try {
71
73
  probe = seed()
72
- } catch {
74
+ } catch (error) {
73
75
  threw = true
76
+ suspended = error instanceof SuspenseSignal
77
+ }
78
+ /* The seed SUSPENDED — it read a pending BLOCKING cell (`await`-marked). A sync `state`
79
+ here would hold `undefined` while the reseed effect re-threw the suspend, letting it
80
+ escape at construction. Route to the eager async cell instead: `createAsyncCell` already
81
+ treats a synchronously-suspending seed as "pending" — it stays in flight, the throwing
82
+ read subscribed its effect to the blocking dependency, and it re-runs on settle — and it
83
+ marks the cell BLOCKING so its own reads pause too, matching a lazy `computed(() =>
84
+ blockingCell)`. This makes a blocking read behave the same in a `linked` seed as in a
85
+ `computed` seed or a template. A real (non-suspense) throw keeps the sync fall-through. */
86
+ if (suspended) {
87
+ return createAsyncCell(seed as () => unknown, {
88
+ writable: true,
89
+ transform: coerce,
90
+ }) as AsyncState<T>
74
91
  }
75
92
  if (!threw && isAsyncIterable(probe)) {
76
93
  return createAsyncCell(seed as () => unknown, {
@@ -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)