@abide/abide 0.54.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.
Files changed (98) hide show
  1. package/AGENTS.md +330 -498
  2. package/CHANGELOG.md +63 -0
  3. package/README.md +166 -146
  4. package/package.json +2 -3
  5. package/src/lib/server/rpc/defineRpc.ts +2 -1
  6. package/src/lib/server/rpc/types/RpcHelper.ts +29 -22
  7. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +3 -2
  8. package/src/lib/server/runtime/createUiPageRenderer.ts +34 -26
  9. package/src/lib/shared/SEEDS.ts +33 -0
  10. package/src/lib/shared/SOCKET_SEED.ts +4 -5
  11. package/src/lib/shared/cache.ts +169 -75
  12. package/src/lib/shared/createRemoteFunction.ts +4 -1
  13. package/src/lib/shared/encodeSeedValue.ts +24 -0
  14. package/src/lib/shared/jsonSchemaForType.ts +3 -16
  15. package/src/lib/shared/peek.ts +3 -6
  16. package/src/lib/ui/amendReaderHook.ts +1 -1
  17. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -6
  18. package/src/lib/ui/compile/analyzeComponent.ts +0 -2
  19. package/src/lib/ui/compile/awaitSubjectExpr.ts +23 -0
  20. package/src/lib/ui/compile/cachedShadowProgram.ts +2 -1
  21. package/src/lib/ui/compile/compileComponent.ts +1 -10
  22. package/src/lib/ui/compile/compileShadow.ts +113 -8
  23. package/src/lib/ui/compile/componentOrdinals.ts +39 -0
  24. package/src/lib/ui/compile/desugarSignals.ts +195 -44
  25. package/src/lib/ui/compile/generateBuild.ts +25 -24
  26. package/src/lib/ui/compile/generateSSR.ts +37 -24
  27. package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
  28. package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
  29. package/src/lib/ui/compile/liftScriptSeed.ts +47 -0
  30. package/src/lib/ui/compile/lowerContext.ts +26 -6
  31. package/src/lib/ui/compile/lowerScript.ts +1 -11
  32. package/src/lib/ui/compile/renameSignalRefs.ts +7 -21
  33. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +1 -1
  34. package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
  35. package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
  36. package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
  37. package/src/lib/ui/compile/wrapSeed.ts +28 -0
  38. package/src/lib/ui/computed.ts +16 -0
  39. package/src/lib/ui/createScope.ts +4 -3
  40. package/src/lib/ui/dom/anchorCursor.ts +2 -1
  41. package/src/lib/ui/dom/anchoredBranch.ts +14 -3
  42. package/src/lib/ui/dom/appendStatic.ts +11 -20
  43. package/src/lib/ui/dom/appendText.ts +41 -43
  44. package/src/lib/ui/dom/appendTextAt.ts +10 -57
  45. package/src/lib/ui/dom/awaitBlock.ts +30 -44
  46. package/src/lib/ui/dom/awaitSubject.ts +118 -0
  47. package/src/lib/ui/dom/claimText.ts +38 -0
  48. package/src/lib/ui/dom/cloneStatic.ts +2 -8
  49. package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
  50. package/src/lib/ui/dom/discardBoundary.ts +5 -4
  51. package/src/lib/ui/dom/each.ts +3 -5
  52. package/src/lib/ui/dom/fillBoundary.ts +2 -1
  53. package/src/lib/ui/dom/hydrate.ts +5 -12
  54. package/src/lib/ui/dom/mountChild.ts +73 -5
  55. package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
  56. package/src/lib/ui/dom/openMarker.ts +2 -4
  57. package/src/lib/ui/dom/outlet.ts +4 -3
  58. package/src/lib/ui/dom/readCell.ts +26 -11
  59. package/src/lib/ui/dom/skeleton.ts +2 -7
  60. package/src/lib/ui/dom/switchBlock.ts +2 -1
  61. package/src/lib/ui/dom/tryBlock.ts +26 -38
  62. package/src/lib/ui/dom/when.ts +6 -2
  63. package/src/lib/ui/linked.ts +32 -1
  64. package/src/lib/ui/resumeSeedScript.ts +1 -1
  65. package/src/lib/ui/router.ts +12 -24
  66. package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
  67. package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
  68. package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
  69. package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
  70. package/src/lib/ui/runtime/RENDER.ts +7 -5
  71. package/src/lib/ui/runtime/RESUME.ts +5 -7
  72. package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
  73. package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
  74. package/src/lib/ui/runtime/claimChild.ts +5 -7
  75. package/src/lib/ui/runtime/claimExpected.ts +2 -6
  76. package/src/lib/ui/runtime/claimMarker.ts +13 -0
  77. package/src/lib/ui/runtime/claimRun.ts +22 -0
  78. package/src/lib/ui/runtime/consumeSeed.ts +38 -0
  79. package/src/lib/ui/runtime/createAsyncCell.ts +66 -8
  80. package/src/lib/ui/runtime/createComputedNode.ts +1 -0
  81. package/src/lib/ui/runtime/createEffectNode.ts +1 -0
  82. package/src/lib/ui/runtime/createSignalNode.ts +1 -0
  83. package/src/lib/ui/runtime/parkCursor.ts +10 -0
  84. package/src/lib/ui/runtime/readNode.ts +21 -1
  85. package/src/lib/ui/runtime/renderPath.ts +2 -2
  86. package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
  87. package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
  88. package/src/lib/ui/runtime/runNode.ts +14 -0
  89. package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
  90. package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
  91. package/src/lib/ui/settleAsyncCells.ts +30 -3
  92. package/src/lib/ui/startClient.ts +0 -7
  93. package/src/lib/ui/tryEncodeResume.ts +6 -15
  94. package/src/lib/ui/watch.ts +3 -11
  95. package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
  96. package/src/lib/ui/dom/readCellBlocking.ts +0 -29
  97. package/src/lib/ui/runtime/restoreWarmSeeds.ts +0 -20
  98. package/src/lib/ui/runtime/warmSeedBackup.ts +0 -10
@@ -120,7 +120,6 @@ export function analyzeComponent(
120
120
  derivedNames,
121
121
  computedNames,
122
122
  cellReadNames,
123
- blockingCellNames: allBlockingCellNames,
124
123
  droppedReactiveImports,
125
124
  } = lowerScript(
126
125
  fullScriptBody,
@@ -143,7 +142,6 @@ export function analyzeComponent(
143
142
  derivedNames,
144
143
  computedNames,
145
144
  cellReadNames,
146
- blockingCellNames: allBlockingCellNames,
147
145
  nodes,
148
146
  styles,
149
147
  /* Hydration adopts every block in place — including `await`, which resumes
@@ -0,0 +1,23 @@
1
+ /*
2
+ The lowered `{#await X}` subject, normalised so a cell subject AWAITS its resolution
3
+ (ADR-0047): a BARE async-cell reference is passed RAW (not peeked to `$$readCell(cell)`)
4
+ and wrapped in `$$awaitSubject(...)`, which resolves a cell to a promise-of-its-value —
5
+ so `{#await rates}` (a cell) shows pending then the value; any other subject lowers
6
+ unchanged, so `{#await getFoo()}` (a plain promise) is byte-identical. A name currently
7
+ SHADOWED by a block binding (`{#for rates of …}` / `{:then rates}` over a component cell
8
+ `rates`) is the binder's local, not the cell — it lowers through `lowerExpression` like any
9
+ other subject, matching how every other reference site honours the shadow stack. ONE
10
+ decision site shared by both back-ends (the client `awaitBlock` emit and SSR's blocking /
11
+ streaming / flight-hoist emits), so the bare-cell classification can't drift between them.
12
+ */
13
+ export function awaitSubjectExpr(
14
+ promiseCode: string,
15
+ cellReadNames: ReadonlySet<string>,
16
+ isShadowed: (name: string) => boolean,
17
+ lowerExpression: (code: string) => string,
18
+ ): string {
19
+ const bare = promiseCode.trim()
20
+ return cellReadNames.has(bare) && !isShadowed(bare)
21
+ ? `$$awaitSubject(${bare})`
22
+ : lowerExpression(promiseCode)
23
+ }
@@ -1,3 +1,4 @@
1
+ import { messageFromError } from '../../shared/messageFromError.ts'
1
2
  import { createShadowProgram, type ShadowProgram } from './createShadowProgram.ts'
2
3
 
3
4
  /*
@@ -26,7 +27,7 @@ export function cachedShadowProgram(
26
27
  cache.set(root, build(root))
27
28
  } catch (error) {
28
29
  cache.set(root, undefined)
29
- const detail = error instanceof Error ? error.message : String(error)
30
+ const detail = messageFromError(error)
30
31
  console.warn(
31
32
  `[abide] couldn't build the type program for ${root}: ${detail}\n` +
32
33
  ' type-directed async detection is disabled for this project — a bare promise/stream\n' +
@@ -29,15 +29,7 @@ export function compileComponent(
29
29
  a direct caller (tests) omits it and the front-end runs here — threading `classify` and
30
30
  `seedClassify` so type-directed interpolation + cell lowering happen on this path too. */
31
31
  const resolved = analyzed ?? analyzeComponent(source, scopeSeed, classify, seedClassify)
32
- const {
33
- script,
34
- stateNames,
35
- derivedNames,
36
- computedNames,
37
- cellReadNames,
38
- blockingCellNames,
39
- nodes,
40
- } = resolved
32
+ const { script, stateNames, derivedNames, computedNames, cellReadNames, nodes } = resolved
41
33
  const build = generateBuild(
42
34
  nodes,
43
35
  'host',
@@ -46,7 +38,6 @@ export function compileComponent(
46
38
  computedNames,
47
39
  isLayout,
48
40
  cellReadNames,
49
- blockingCellNames,
50
41
  )
51
42
  /* The scoped CSS is bundled into the entry stylesheet (see `abideUiPlugin`), not
52
43
  injected at runtime; the build only needs the `data-a-…` scope attributes on
@@ -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
@@ -0,0 +1,39 @@
1
+ import type { TemplateNode } from './types/TemplateNode.ts'
2
+
3
+ /*
4
+ Document-order (pre-order) numbering of every `<Child/>` mount site in a template — the
5
+ ordinal a child's render-path roots under (`mountChild` on the client, `$$withPath(ordinal)`
6
+ / `$$renderPath(ordinal)` on the server). ONE walk both back-ends read (the `skeletonContext`
7
+ pattern), replacing the per-back-end `childOrdinal++` counters that numbered components in
8
+ each generator's own EMISSION order — an order that could silently diverge (build emits a
9
+ component's slot content before taking its own ordinal; SSR emits a switch's default case
10
+ after later cases). Numbering from the tree, not the emission, makes SSR↔client ordinal
11
+ congruence hold by construction.
12
+
13
+ Returns the lookup, not the map, so the missing-entry policy lives here too: a miss means a
14
+ back-end reached a mount site the shared walk didn't number — a structural divergence,
15
+ surfaced loudly at compile time rather than as a runtime warm-seed/boundary desync.
16
+ */
17
+ export function componentOrdinals(nodes: TemplateNode[]): (node: TemplateNode) => number {
18
+ const ordinals = new WeakMap<TemplateNode, number>()
19
+ let next = 0
20
+ const walk = (children: TemplateNode[]): void => {
21
+ for (const child of children) {
22
+ if (child.kind === 'component') {
23
+ ordinals.set(child, next)
24
+ next += 1
25
+ }
26
+ if ('children' in child) {
27
+ walk(child.children)
28
+ }
29
+ }
30
+ }
31
+ walk(nodes)
32
+ return (node: TemplateNode): number => {
33
+ const ordinal = ordinals.get(node)
34
+ if (ordinal === undefined) {
35
+ throw new Error('[abide] component mount site not numbered by the shared ordinal walk')
36
+ }
37
+ return ordinal
38
+ }
39
+ }
@@ -1,10 +1,14 @@
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'
11
+ import { wrapSeed } from './wrapSeed.ts'
8
12
 
9
13
  /* The routing decision for a no-marker `computed(seed)`: `true` routes to the eager
10
14
  `trackedComputed` stream cell (read via `$$readCell`, the `cellReadNames` bucket), `false`
@@ -14,28 +18,6 @@ type EagerStreamPredicate = (declaration: ts.VariableDeclaration) => boolean
14
18
 
15
19
  const factory = ts.factory
16
20
 
17
- /* Normalises a `computed`/`linked` argument into a seed THUNK: a literal `() => …` /
18
- `function` argument passes through unchanged (the author already wrote the thunk), any
19
- other expression is wrapped as `() => arg`. The wrapper arrow is made ASYNC when the
20
- expression contains a top-level `await` (`computed(await load())` → `async () => await
21
- load()`), which is exactly the marker the runtime primitive uses to route to an async cell. */
22
- function wrapSeed(argument: ts.Expression): ts.Expression {
23
- if (ts.isArrowFunction(argument) || ts.isFunctionExpression(argument)) {
24
- return argument
25
- }
26
- const modifiers = hasTopLevelAwait(argument)
27
- ? [factory.createModifier(ts.SyntaxKind.AsyncKeyword)]
28
- : undefined
29
- return factory.createArrowFunction(
30
- modifiers,
31
- undefined,
32
- [],
33
- undefined,
34
- factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
35
- argument,
36
- )
37
- }
38
-
39
21
  /* Wraps a seed expression as an async unwrapping thunk `async () => await (arg)` — for a
40
22
  type-directed PROMISE seed (ADR-0023/0043) the author wrote WITHOUT `await`
41
23
  (`state.computed(getFoo())`). The `async`+`await` makes createAsyncCell unwrap the resolved
@@ -231,10 +213,6 @@ export function desugarSignals(
231
213
  derivedNames: Set<string>
232
214
  computedNames: Set<string>
233
215
  cellReadNames: Set<string>
234
- /* The full BLOCKING cell set (ADR-0042): the template-injected `await` cells passed in, unioned
235
- with the script-level `await` computeds/linked collected here. The client template lowering
236
- reads these via `$$readCellBlocking` (suspend-on-pending). */
237
- blockingCellNames: Set<string>
238
216
  } {
239
217
  assertNoRemovedReaders(source)
240
218
  /* The full set of names written anywhere (this script + the template). A prop in this set
@@ -253,10 +231,6 @@ export function desugarSignals(
253
231
  One read shape covers both — `$$readCell` peeks an async cell and reads `.value` off a
254
232
  sync one — so `linked(getStream())` auto-tracks with no read-site branching. */
255
233
  const cellReadNames = new Set<string>()
256
- /* Script-level BLOCKING cell names (ADR-0042 D6): a `computed`/`linked` whose seed carries a
257
- top-level `await`. Unioned with the template-injected `blockingCellNames` and returned so the
258
- CLIENT template lowering reads them via `$$readCellBlocking` (suspend-on-pending). */
259
- const scriptBlockingNames = new Set<string>()
260
234
  /* A `props()` destructure must be lowered even when it declares no reactive binding
261
235
  (a rest-only `const { ...rest } = props()`), so track its presence on its own. */
262
236
  let hasPropsDestructure = false
@@ -308,6 +282,77 @@ export function desugarSignals(
308
282
  }
309
283
  return seedKind(argument) === 'promise'
310
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
+ }
311
356
  for (const statement of source.statements) {
312
357
  if (!ts.isVariableStatement(statement)) {
313
358
  continue
@@ -346,6 +391,16 @@ export function desugarSignals(
346
391
  cellReadNames.add(declaration.name.text)
347
392
  continue
348
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
+ }
349
404
  if (isPlainStateSlot(declaration, bindings)) {
350
405
  /* Plain `state(initial)` → a serializable `model` doc slot. */
351
406
  stateNames.add(declaration.name.text)
@@ -359,27 +414,21 @@ export function desugarSignals(
359
414
  isEagerStreamComputed(declaration) ||
360
415
  isPromiseComputed(declaration)
361
416
  ) {
417
+ /* Async `computed` (author `await`), an eager stream, or a promise seed — all
418
+ read via `$$readCell(name)`. Whether a pending read PAUSES (author `await`,
419
+ a blocking cell) or peeks `undefined` (streaming) is carried by the cell
420
+ itself at construction (the `trackedComputed(thunk, streaming)` flag), so the
421
+ read form is uniform and nothing blocking-specific is collected here. */
362
422
  cellReadNames.add(declaration.name.text)
363
- /* A blocking async computed (author `await`) reads suspend-on-pending; a bare
364
- promise (isPromiseComputed) carries no `await` → streaming, so isBlockingSeed
365
- is false and it never joins this set. */
366
- const argument = seedArgument(declaration)
367
- if (argument !== undefined && isBlockingSeed(argument)) {
368
- scriptBlockingNames.add(declaration.name.text)
369
- }
370
423
  } else {
371
424
  computedNames.add(declaration.name.text)
372
425
  }
373
426
  } else if (callee === 'linked') {
374
427
  /* `linked` → a cell read via `$$readCell(name)`: a plain `State` when the seed
375
428
  is synchronous, an `AsyncState` when it tracks a promise/stream — one read
376
- shape auto-tracks whichever source the runtime primitive resolved to. */
429
+ shape auto-tracks whichever source the runtime primitive resolved to. Its
430
+ blocking-ness (author `await`) likewise rides on the constructed cell. */
377
431
  cellReadNames.add(declaration.name.text)
378
- /* A blocking async linked (author `await`) reads suspend-on-pending, like computed. */
379
- const argument = seedArgument(declaration)
380
- if (argument !== undefined && isBlockingSeed(argument)) {
381
- scriptBlockingNames.add(declaration.name.text)
382
- }
383
432
  } else if (callee === 'state') {
384
433
  /* `state(initial, transform)` → a `.value` cell (its write-coercion transform
385
434
  forces a local store); referenced as `name.value`, unchanged. */
@@ -429,6 +478,7 @@ export function desugarSignals(
429
478
  writtenNames,
430
479
  isEagerStreamComputed,
431
480
  isPromiseComputed,
481
+ seedLiftFor,
432
482
  ),
433
483
  )
434
484
  }
@@ -441,7 +491,6 @@ export function desugarSignals(
441
491
  derivedNames,
442
492
  computedNames,
443
493
  cellReadNames,
444
- blockingCellNames: new Set([...blockingCellNames, ...scriptBlockingNames]),
445
494
  }
446
495
  }
447
496
 
@@ -458,9 +507,11 @@ function loweredStatement(
458
507
  writtenNames: ReadonlySet<string>,
459
508
  isEagerStreamComputed: EagerStreamPredicate,
460
509
  isPromiseComputed: EagerStreamPredicate,
510
+ seedLiftFor: (declaration: ts.VariableDeclaration) => ScriptSeedLift | undefined,
461
511
  ): ts.Statement[] {
462
512
  rejectMixedDeclaration(statement, bindings)
463
513
  return (
514
+ liftedSeedStatements(statement, bindings, seedLiftFor) ??
464
515
  injectedComputedStatements(statement, injectedCellNames, blockingCellNames) ??
465
516
  stateAssignmentStatements(statement, bindings) ??
466
517
  computedStatements(
@@ -475,6 +526,106 @@ function loweredStatement(
475
526
  )
476
527
  }
477
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
+
478
629
  /* Lowers a synthetic `const __vN = computed(<seed>)` (an async (sub)expression peek-cell
479
630
  `analyzeComponent` injected, ADR-0032) to a `scope().trackedComputed(...)` cell read via
480
631
  `$$readCell`. Two shapes, by the seed: