@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
@@ -4,12 +4,13 @@ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
4
4
  import { ANCHOR } from '../runtime/RANGE_MARKER.ts'
5
5
  import { asOutlet } from './asOutlet.ts'
6
6
  import { awaitPlan } from './awaitPlan.ts'
7
+ import { awaitSubjectExpr } from './awaitSubjectExpr.ts'
7
8
  import { bindListenEvent } from './bindListenEvent.ts'
9
+ import { componentOrdinals } from './componentOrdinals.ts'
8
10
  import { composeProps } from './composeProps.ts'
9
11
  import { eachPlan } from './eachPlan.ts'
10
12
  import { elementPlan } from './elementPlan.ts'
11
13
  import { groupBindParts } from './groupBindParts.ts'
12
- import { hoistableChildRenders } from './hoistableChildRenders.ts'
13
14
  import { ifPlan } from './ifPlan.ts'
14
15
  import { interpolatedTemplateLiteral } from './interpolatedTemplateLiteral.ts'
15
16
  import { isControlFlow } from './isControlFlow.ts'
@@ -75,23 +76,12 @@ export function generateBuild(
75
76
  derivedNames: ReadonlySet<string>,
76
77
  computedNames: ReadonlySet<string>,
77
78
  isLayout = false,
78
- /* `linked` / async `computed` names, lowered to `$$readCell(name)` in template exprs. */
79
+ /* `linked` / async `computed` names, lowered to `$$readCell(name)` in template exprs — one
80
+ read form whose pause-vs-peek (a blocking `await` cell suspends the region; a streaming
81
+ cell peeks `undefined`) is decided at runtime by the cell's own `blocking` bit (ADR-0042). */
79
82
  cellReadNames: ReadonlySet<string> = new Set(),
80
- /* The subset that are BLOCKING `await` cells (ADR-0042), lowered to `$$readCellBlocking(name)`
81
- on the CLIENT so a pending read suspends its render region. Server keeps `$$readCell` (the
82
- SSR barrier resolves blocking cells before the template, so the read never suspends). */
83
- blockingCellNames: ReadonlySet<string> = new Set(),
84
83
  ): string {
85
84
  const nextVar = makeVarNamer()
86
- /* A per-body source-order ordinal for each `<Child/>` mount site, so two same-type siblings
87
- get distinct render-path segments. Combined with the ambient branch/row path a control-flow
88
- ancestor pushes, this makes every mounted instance's id unique and stable (ADR render-path
89
- identity). Drawn in document-order walk, matching the SSR back-end's identical walk. */
90
- let childOrdinal = 0
91
- /* ADR-0039: the SAME hoistable-child set the SSR back-end computes (identical inputs → identical
92
- node identities), so the client emits the dual-mode `$$mountStreamedChild` for exactly the
93
- children the server may stream. A non-hoistable child keeps the plain `$$mountChild`. */
94
- const streamCapable = hoistableChildRenders(nodes, cellReadNames)
95
85
 
96
86
  /* In a layout, `<slot/>` outlets are rewritten to `OUTLET_TAG` elements up front
97
87
  (`asOutlet`) so the static-clone path carries them as ordinary structure. `asOutlet`
@@ -105,6 +95,12 @@ export function generateBuild(
105
95
  position structurally (the drift the shared context exists to prevent). */
106
96
  const { markText, elIndex, anIndex } = skeletonContext(rootNodes)
107
97
 
98
+ /* Each `<Child/>` mount site's render-path ordinal, from the ONE shared document-order
99
+ walk the SSR back-end also reads (`componentOrdinals`) — so two same-type siblings get
100
+ distinct, SSR-congruent segments regardless of either back-end's emission order. The
101
+ returned lookup throws on a miss (a structural divergence from the shared walk). */
102
+ const childOrdinal = componentOrdinals(rootNodes)
103
+
108
104
  /* The hole's index, assigned by the shared skeletonContext walk — the sole numberer. A
109
105
  missing entry means this back-end reached a hole the shared walk didn't number: a
110
106
  structural divergence between the two, surfaced loudly at compile time rather than as a
@@ -121,11 +117,13 @@ export function generateBuild(
121
117
  const {
122
118
  expression: lowerExpression,
123
119
  statement: lowerStatement,
120
+ script: lowerScript,
124
121
  withNestedScripts,
125
122
  withShadow,
123
+ isShadowed,
126
124
  bindRead,
127
125
  bindWrite,
128
- } = lowerContext(stateNames, derivedNames, computedNames, cellReadNames, blockingCellNames)
126
+ } = lowerContext(stateNames, derivedNames, computedNames, cellReadNames)
129
127
 
130
128
  /* Maps a plan `Binding`'s classification to the client `ShadowKind`: a `reactive` value
131
129
  derefs as a `.value` cell (`derived`), a `plain` value as the bare local (`plain`). The
@@ -285,7 +283,7 @@ export function generateBuild(
285
283
  runs as a bind, in document order, so its signals are declared before the later
286
284
  siblings that deref them (the enclosing `withNestedScripts` puts those names in
287
285
  scope). */
288
- binds.push(`${lowerStatement(node.code)}\n`)
286
+ binds.push(`${lowerScript(node.code)}\n`)
289
287
  return ''
290
288
  }
291
289
  if (node.kind === 'snippet') {
@@ -419,7 +417,7 @@ export function generateBuild(
419
417
  /* Emits code appending `node` to `parentVar`. */
420
418
  function generateChild(node: TemplateNode, parentVar: string, before = 'null'): string {
421
419
  if (node.kind === 'script') {
422
- return `${lowerStatement(node.code)}\n`
420
+ return `${lowerScript(node.code)}\n`
423
421
  }
424
422
  /* A `<style>` emits no DOM — its CSS is bundled and its scope attribute is
425
423
  already stamped onto the elements it covers (see `staticHtml`/`skeletonMarkup`). */
@@ -616,11 +614,11 @@ export function generateBuild(
616
614
  cell, so `<Icon>` from `{#for {icon: Icon} of …}` mounts the component the cell
617
615
  holds, not the cell object (whose `.build` is undefined → `build is not a
618
616
  function`). SSR emits the same lowering for congruence. */
619
- /* A hoistable child may have STREAMED (server chose per-render): emit the dual-mode adopter,
620
- which probes the cursor to adopt an inlined `[ … ]` range OR a streamed `abide:await`
621
- boundary (ADR-0039). Same args as mountChild. */
622
- const mountFn = streamCapable.has(node) ? '$$mountStreamedChild' : '$$mountChild'
623
- return `${mountFn}(${parentVar}, ${lowerExpression(node.name)}, ${propsArg(node)}, ${before}, ${childOrdinal++});\n`
617
+ /* `mountChild` is dual-mode (ADR-0039): on hydrate it probes the cursor to adopt an
618
+ inlined `[ … ]` range OR a streamed `abide:await` boundary, so the client build
619
+ needs no hoistable-child knowledge — inline-vs-stream is decided server-side per
620
+ render, and the adopter reads which happened off the markup. */
621
+ return `$$mountChild(${parentVar}, ${lowerExpression(node.name)}, ${propsArg(node)}, ${before}, ${childOrdinal(node)});\n`
624
622
  }
625
623
 
626
624
  /* An await block: pending → resolved(value) / error branches. Each branch is a
@@ -649,8 +647,11 @@ export function generateBuild(
649
647
  const pendingThunk = hasRenderableContent(plan.pending)
650
648
  ? branchThunk(plan.pending)
651
649
  : 'undefined'
650
+ /* The subject lowers through the ONE shared ADR-0047 decision site (`awaitSubjectExpr`),
651
+ so the bare-cell classification can't drift from the SSR back-end's. */
652
+ const subject = awaitSubjectExpr(node.promise, cellReadNames, isShadowed, lowerExpression)
652
653
  return (
653
- `$$awaitBlock(${parentVar}, $$nextBlockId(), () => (${lowerExpression(node.promise)}), ` +
654
+ `$$awaitBlock(${parentVar}, $$nextBlockId(), () => (${subject}), ` +
654
655
  `${pendingThunk}, ` +
655
656
  `${thenThunk}, ` +
656
657
  `${catchThunk}, ${before});\n`
@@ -1,5 +1,6 @@
1
1
  import { assertExhaustive } from '../../shared/assertExhaustive.ts'
2
2
  import { escapeRegex } from '../../shared/escapeRegex.ts'
3
+ import { caseSegment, ELSE_SEGMENT, THEN_SEGMENT } from '../runtime/BRANCH_SEGMENT.ts'
3
4
  import { OUTLET_CLOSE, OUTLET_OPEN } from '../runtime/OUTLET_MARKER.ts'
4
5
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
5
6
  import {
@@ -9,6 +10,8 @@ import {
9
10
  } from '../runtime/RANGE_MARKER.ts'
10
11
  import { asOutlet } from './asOutlet.ts'
11
12
  import { awaitPlan } from './awaitPlan.ts'
13
+ import { awaitSubjectExpr } from './awaitSubjectExpr.ts'
14
+ import { componentOrdinals } from './componentOrdinals.ts'
12
15
  import { composeProps } from './composeProps.ts'
13
16
  import { eachPlan } from './eachPlan.ts'
14
17
  import { elementPlan } from './elementPlan.ts'
@@ -74,10 +77,6 @@ export function generateSSR(
74
77
  /* Unique temp var names (child render results); runtime block ids are
75
78
  allocated separately at runtime via `$ctx.next++`. */
76
79
  const nextVar = makeVarNamer()
77
- /* A per-body source-order ordinal for each `<Child/>` render site — the render-path segment the
78
- child roots under, matching the client `mountChild`'s `childOrdinal` (`generateBuild`) drawn
79
- in the identical document-order walk, so both compose the same cell scope ids. */
80
- let childOrdinal = 0
81
80
 
82
81
  /* The enclosing `<select bind:value>`s, innermost last: each carries the JS var holding
83
82
  its bound value and whether it's a `multiple` (array) select, so an `<option>` rendered
@@ -88,9 +87,10 @@ export function generateSSR(
88
87
  /* The shared signal→`model` lowering + branch-scoped nested-script deref scope. */
89
88
  const {
90
89
  expression: lowerExpression,
91
- statement,
90
+ script,
92
91
  withNestedScripts,
93
92
  withShadow,
93
+ isShadowed,
94
94
  bindRead,
95
95
  bindWrite,
96
96
  } = lowerContext(stateNames, derivedNames, computedNames, cellReadNames)
@@ -102,7 +102,7 @@ export function generateSSR(
102
102
 
103
103
  /* A scoped-script body for SSR: the shared lowering, then strip effects
104
104
  (client-only lifecycle that emits no HTML) — the one SSR-side asymmetry. */
105
- const lowerScript = (code: string): string => stripEffects(statement(code))
105
+ const lowerScript = (code: string): string => stripEffects(script(code))
106
106
 
107
107
  function push(target: string, literal: string): string {
108
108
  return `${target}.push(${JSON.stringify(literal)});\n`
@@ -159,6 +159,13 @@ export function generateSSR(
159
159
  through the generic element path exactly as the client clones it. */
160
160
  const rootNodes = isLayout ? nodes.map(asOutlet) : nodes
161
161
 
162
+ /* Each `<Child/>` render site's render-path ordinal, from the ONE shared document-order
163
+ walk the client back-end also reads (`componentOrdinals`) — so both compose the same
164
+ cell scope ids and streamed-boundary ids regardless of either back-end's emission
165
+ order. The returned lookup throws on a miss (a structural divergence from the shared
166
+ walk), surfaced loudly at compile time. */
167
+ const childOrdinal = componentOrdinals(rootNodes)
168
+
162
169
  /* ADR-0034: the await blocks whose promise-start hoists to the synchronous render prefix so
163
170
  independent flights overlap instead of serializing. Server-only — this rewires only the SSR
164
171
  promise SOURCE (a blocking `await $flightN`, a streaming `promise: () => $flightN`); the
@@ -362,10 +369,10 @@ export function generateSSR(
362
369
  then the `$$readCell` truthy test — so sync and async branches interleave and each
363
370
  resolves at render time, mirroring the client `switchBlock` cond-chain. */
364
371
  const plan = ifPlan(node)
365
- /* The client build lowers a simple `if`/`else` through `when` (branch keys `'then'` /
366
- `'else'`) and an `if` with any `elseif` through `switchBlock` over `[then, ...branches]`
367
- (branch key = the case's array index). Mirror whichever the client picks so a nested
368
- `<Child/>`'s scope id composes identically. */
372
+ /* The client build lowers a simple `if`/`else` through `when` and an `if` with any
373
+ `elseif` through `switchBlock` over `[then, ...branches]`. Segments come from the
374
+ SAME alphabet those runtimes key their branches with (`BRANCH_SEGMENT`), so a
375
+ nested `<Child/>`'s scope id composes identically — no literal to drift. */
369
376
  const clauses: string[] = []
370
377
  const conditional = (
371
378
  condition: string,
@@ -385,12 +392,12 @@ export function generateSSR(
385
392
  )
386
393
  }
387
394
  }
388
- /* `then` is the `when` `'then'` branch, or the `switchBlock` case at index 0. */
395
+ /* `then` is the `when` then-branch, or the `switchBlock` case at index 0. */
389
396
  conditional(
390
397
  node.condition,
391
398
  node.asyncSubject,
392
399
  plan.thenChildren,
393
- JSON.stringify(plan.hasElseif ? '0' : 'then'),
400
+ JSON.stringify(plan.hasElseif ? caseSegment(0) : THEN_SEGMENT),
394
401
  )
395
402
  for (let index = 0; index < plan.branches.length; index += 1) {
396
403
  const branch = plan.branches[index] as Extract<TemplateNode, { kind: 'case' }>
@@ -401,7 +408,7 @@ export function generateSSR(
401
408
  branch.condition,
402
409
  branch.asyncSubject,
403
410
  branch.children,
404
- JSON.stringify(String(index + 1)),
411
+ JSON.stringify(caseSegment(index + 1)),
405
412
  )
406
413
  }
407
414
  }
@@ -409,11 +416,11 @@ export function generateSSR(
409
416
  .map((clause, index) => `${index === 0 ? 'if' : ' else if'} ${clause}`)
410
417
  .join('')
411
418
  if (plan.elseBranch !== undefined) {
412
- /* `else` is the `when` `'else'` branch, or (for a cond-chain) the `switchBlock`
419
+ /* `else` is the `when` else-branch, or (for a cond-chain) the `switchBlock`
413
420
  default at its own `[then, ...branches]` index. */
414
421
  const elseSegment = plan.hasElseif
415
- ? String(plan.branches.indexOf(plan.elseBranch) + 1)
416
- : 'else'
422
+ ? caseSegment(plan.branches.indexOf(plan.elseBranch) + 1)
423
+ : ELSE_SEGMENT
417
424
  code += ` else {\n${withPathBranch(JSON.stringify(elseSegment), plan.elseBranch.children, target)}}`
418
425
  }
419
426
  return `${anchor}${openRange(target)}${code}\n${closeRange(target)}`
@@ -436,12 +443,12 @@ export function generateSSR(
436
443
  for (let index = 0; index < plan.cases.length; index += 1) {
437
444
  const branch = plan.cases[index] as Extract<TemplateNode, { kind: 'case' }>
438
445
  if (branch.match !== undefined) {
439
- code += `${started ? 'else ' : ''}if ($s === (${lowerExpression(branch.match)})) {\n${withPathBranch(JSON.stringify(String(index)), branch.children, target)}}\n`
446
+ code += `${started ? 'else ' : ''}if ($s === (${lowerExpression(branch.match)})) {\n${withPathBranch(JSON.stringify(caseSegment(index)), branch.children, target)}}\n`
440
447
  started = true
441
448
  }
442
449
  }
443
450
  if (plan.fallback !== undefined) {
444
- const fallbackSegment = String(plan.cases.indexOf(plan.fallback))
451
+ const fallbackSegment = caseSegment(plan.cases.indexOf(plan.fallback))
445
452
  code += `${started ? 'else ' : ''}{\n${withPathBranch(JSON.stringify(fallbackSegment), plan.fallback.children, target)}}\n`
446
453
  }
447
454
  if (node.asyncSubject === true) {
@@ -583,10 +590,10 @@ export function generateSSR(
583
590
  /* The tag lowers like any reference (see generateBuild): a static import is left bare, a
584
591
  reactive/loop/await binding derefs — SSR registers such a binding as `plain`, so it
585
592
  reads the bare local holding the resolved component, keeping SSR and client congruent.
586
- Root the child's render-path at this mount site's source-order ordinal — the same
593
+ Root the child's render-path at this mount site's shared-walk ordinal — the same
587
594
  segment the client's `mountChild` pushes — so the child's cells and block ids (ADR-0037)
588
595
  get an id matching the client's. `$$withPath` sets the path across the child's awaits. */
589
- const ordinal = childOrdinal++
596
+ const ordinal = childOrdinal(node)
590
597
  const renderExpr = `$$withPath(${ordinal}, () => ${lowerExpression(node.name)}.render(${propsExpr}, $ctx))`
591
598
  if (hoistableChildSet.has(node)) {
592
599
  /* Hoistable (ADR-0037/0039): start the render as an isolated flight in the prefix, and
@@ -595,7 +602,7 @@ export function generateSSR(
595
602
  flight already settled (byte-identical to the old inline await), or a streaming
596
603
  `abide:await:CHILDPATH` boundary + html-only await if it is still pending. The
597
604
  boundary id is the child's render-path (`$$renderPath(ordinal)`), congruent with the
598
- client's `mountStreamedChild`. */
605
+ client's dual-mode `mountChild`. */
599
606
  const flightName = `$cf${childFlightCounter++}`
600
607
  childFlightDecls.push(
601
608
  `const ${flightName} = $$flight(() => $$isolateCellBarrier(() => ${renderExpr}));`,
@@ -787,6 +794,12 @@ export function generateSSR(
787
794
  : generateStreamingAwait(node, target)
788
795
  }
789
796
 
797
+ /* The lowered `{#await X}` subject (ADR-0047) — the ONE shared decision site, so the
798
+ bare-cell classification can't drift from the client back-end's. Used by the
799
+ blocking, streaming, and flight-hoist emit sites. */
800
+ const subjectExprFor = (promiseCode: string): string =>
801
+ awaitSubjectExpr(promiseCode, cellReadNames, isShadowed, lowerExpression)
802
+
790
803
  /*
791
804
  A blocking await — `await`ed at its structural position in the async render pass and
792
805
  its resolved branch rendered INLINE into `target`, between the boundary markers. This
@@ -819,7 +832,7 @@ export function generateSSR(
819
832
  let code = `const ${id} = $$blockId($ctx);\n`
820
833
  code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
821
834
  code += `try {\n`
822
- code += `const ${resolved} = await (${hoistedPromise ?? lowerExpression(node.promise)});\n`
835
+ code += `const ${resolved} = await (${hoistedPromise ?? subjectExprFor(node.promise)});\n`
823
836
  code += `{\n`
824
837
  code += `const ${plan.resolvedAs} = ${resolved};\n`
825
838
  code += withBindings(withShadow, plan.resolvedBindings, ssrBindingKind, () =>
@@ -891,7 +904,7 @@ export function generateSSR(
891
904
  const hoistedPromise = flightNameByNode.get(node)
892
905
  code +=
893
906
  `$awaits.push({ id: ${id}, ` +
894
- `promise: () => (${hoistedPromise ?? lowerExpression(node.promise)}), ` +
907
+ `promise: () => (${hoistedPromise ?? subjectExprFor(node.promise)}), ` +
895
908
  `then: ${settled(plan.resolvedAs, plan.resolvedBindings, plan.resolvedChildren)}, ` +
896
909
  `${catchProp}});\n`
897
910
  return code
@@ -939,7 +952,7 @@ export function generateSSR(
939
952
  const body = generateInto(rootNodes, '$out')
940
953
  let flightDecls = ''
941
954
  for (const [node, name] of flightNameByNode) {
942
- flightDecls += `const ${name} = $$flight(() => (${lowerExpression(node.promise)}));\n`
955
+ flightDecls += `const ${name} = $$flight(() => (${subjectExprFor(node.promise)}));\n`
943
956
  }
944
957
  /* Child-render flights emitted during the body walk above (ADR-0037 Phase 2) — appended after
945
958
  the await flights; both are independent prefix promise-starts, so order between them is free. */
@@ -78,7 +78,7 @@ function walkNode(
78
78
  if (
79
79
  !conditional &&
80
80
  !multiRow &&
81
- promiseIsPrefixEvaluable(node.promise, binders, cellReadNames)
81
+ expressionIsPrefixEvaluable(node.promise, binders, cellReadNames)
82
82
  ) {
83
83
  flights.push({ node, name: `$flight${counter.next++}` })
84
84
  }
@@ -152,16 +152,6 @@ function walkNode(
152
152
  }
153
153
  }
154
154
 
155
- /* True when EVERY referenced identifier of `promise` is prefix-evaluable: none is a template-local
156
- binder and none is an async-cell name. Delegates to the shared `expressionIsPrefixEvaluable`. */
157
- function promiseIsPrefixEvaluable(
158
- promise: string,
159
- binders: ReadonlySet<string>,
160
- cellReadNames: ReadonlySet<string>,
161
- ): boolean {
162
- return expressionIsPrefixEvaluable(promise, binders, cellReadNames)
163
- }
164
-
165
155
  /* A single-element array literal like `[remountKey]` — the only `{#for}` source that renders its
166
156
  body exactly once, so a hoisted flight inside it maps to one row (no shared-flight hazard). */
167
157
  function isSingleElementLiteral(items: string): boolean {
@@ -1,12 +1,14 @@
1
1
  import ts from 'typescript'
2
2
  import { classifyInterpolationType } from './classifyInterpolationType.ts'
3
3
 
4
- /* The two TypeScript diagnostics a bare async read (ADR-0032) raises spuriously in a template.
4
+ /* The three TypeScript diagnostics a bare async read (ADR-0032) raises spuriously in a template.
5
5
  2339 — a property "missing" on the un-awaited `Promise`/`AsyncIterable`; 2801 — a condition the
6
- raw promise makes "always defined". Both are correct against the raw type but wrong against the
7
- runtime PEEK (the resolved value, `undefined` while pending), which the shadow can't see. */
6
+ raw promise makes "always defined"; 2488 — a promise source "not iterable" in a plain `{#for}`.
7
+ All are correct against the raw type but wrong against the runtime PEEK (the resolved value,
8
+ `undefined` while pending), which the shadow can't see. */
8
9
  const PROPERTY_DOES_NOT_EXIST = 2339
9
10
  const CONDITION_ALWAYS_DEFINED = 2801
11
+ const SOURCE_NOT_ITERABLE = 2488
10
12
 
11
13
  /*
12
14
  Whether a shadow diagnostic is one the ADR-0032 bare-async-read peek makes spurious. The runtime
@@ -20,11 +22,16 @@ caller can drop them:
20
22
  property EXISTS on the awaited (promise) or frame (iterable) type — the peek's real member.
21
23
  - 2801 (always-defined condition): the flagged subject itself classifies as a promise/iterable —
22
24
  the peek is genuinely nullable (pending → `undefined`), so it is not always-defined.
25
+ - 2488 (source not iterable): a plain `{#for}` source classifies as a promise whose AWAITED type
26
+ is itself iterable — the runtime lifts it to a peek-cell reading the resolved collection (empty
27
+ while pending), so iterating it is valid (matches `typeDirectedInterpolation`'s peek-cell lift).
23
28
 
24
- Deliberately narrow so it never hides a real mistake: only these two codes; 2339 only via `?.`
29
+ Deliberately narrow so it never hides a real mistake: only these three codes; 2339 only via `?.`
25
30
  (a bare `.name` on a promise stays an error, nudging toward the `?.` the peek needs) and only when
26
31
  the property resolves on the awaited/frame type (a typo `?.namex` stays); 2801 only when the
27
- subject is actually async (a bare `{#if someFn}` — a forgotten call — stays). Callers additionally
32
+ subject is actually async (a bare `{#if someFn}` — a forgotten call — stays); 2488 only for a
33
+ PROMISE whose awaited type is iterable (a raw `AsyncIterable` source stays the sanctioned D4a error,
34
+ and a `Promise<number>` source stays a real mistake the peek can't rescue). Callers additionally
28
35
  gate on the diagnostic mapping into the template region, so `<script>` code (where a forgotten
29
36
  `await` must surface) is never touched.
30
37
  */
@@ -41,6 +48,40 @@ export function isSpuriousAsyncReadDiagnostic(
41
48
  if (code === CONDITION_ALWAYS_DEFINED) {
42
49
  return isAsyncSubject(shadowFile, checker, start, length)
43
50
  }
51
+ if (code === SOURCE_NOT_ITERABLE) {
52
+ return isResolvedIterableAsyncSource(shadowFile, checker, start, length)
53
+ }
54
+ return false
55
+ }
56
+
57
+ /* A 2488 on a plain `{#for}` source whose expression classifies as a PROMISE and whose awaited
58
+ type is itself iterable — the ADR-0032 peek lifts it to the resolved collection, not an error.
59
+ Excludes a raw `AsyncIterable` (kind `asyncIterable`, the D4a error) and a promise of a
60
+ non-iterable (the peek would still not iterate). */
61
+ function isResolvedIterableAsyncSource(
62
+ shadowFile: ts.SourceFile,
63
+ checker: ts.TypeChecker,
64
+ start: number,
65
+ length: number,
66
+ ): boolean {
67
+ const source = deepestNodeAt(shadowFile, start, length)
68
+ const sourceType = checker.getTypeAtLocation(source)
69
+ if (classifyInterpolationType(sourceType, source, checker) !== 'promise') {
70
+ return false
71
+ }
72
+ const awaited = checker.getAwaitedType(sourceType)
73
+ return awaited !== undefined && hasSyncIterator(awaited, checker)
74
+ }
75
+
76
+ /* Whether a type is synchronously iterable — an array/tuple or anything carrying the well-known
77
+ `Symbol.iterator` member (mangled to `__@iterator@<id>`, matched by prefix like
78
+ `classifyInterpolationType`'s async twin). */
79
+ function hasSyncIterator(type: ts.Type, checker: ts.TypeChecker): boolean {
80
+ for (const property of checker.getPropertiesOfType(type)) {
81
+ if ((property.escapedName as string).startsWith('__@iterator')) {
82
+ return true
83
+ }
84
+ }
44
85
  return false
45
86
  }
46
87
 
@@ -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
+ }
@@ -6,6 +6,7 @@ import { signalRefsTransformer } from './renameSignalRefs.ts'
6
6
  import { TS_PRINTER } from './TS_PRINTER.ts'
7
7
  import type { TemplateNode } from './types/TemplateNode.ts'
8
8
  import { unwrapParens } from './unwrapParens.ts'
9
+ import { wrapReactiveSeedsTransformer } from './wrapReactiveSeeds.ts'
9
10
 
10
11
  /* Compiled once — strips the trailing `;` off every lowered expression. */
11
12
  const TRAILING_SEMICOLON = /;$/
@@ -25,11 +26,10 @@ export function lowerContext(
25
26
  stateNames: ReadonlySet<string>,
26
27
  derivedNames: ReadonlySet<string>,
27
28
  computedNames: ReadonlySet<string> = new Set(),
28
- /* `linked` / async `computed` names, read through `$$readCell(name)`. */
29
+ /* `linked` / async `computed` names, read through `$$readCell(name)` — one read form
30
+ whose pause-vs-peek is decided at runtime by the cell's own `blocking` bit (ADR-0042),
31
+ so both back-ends lower a cell read identically with nothing site-specific to thread. */
29
32
  cellReadNames: ReadonlySet<string> = new Set(),
30
- /* The subset that are BLOCKING `await` cells (ADR-0042), read through `$$readCellBlocking(name)`
31
- (suspend-on-pending). Passed only by the CLIENT back-end; SSR leaves it empty. */
32
- blockingCellNames: ReadonlySet<string> = new Set(),
33
33
  ) {
34
34
  /* The typed branch-local shadow stack: one auto-popping value owning both kinds.
35
35
  `derived` names deref to `.value` like a `computed` (block value params the client
@@ -44,9 +44,13 @@ export function lowerContext(
44
44
  one tree — the two string passes would each parse + reprint. `localDerived` is
45
45
  snapshotted per call (as the transformer's block-local shadow set) so a binding
46
46
  pushed mid-compile is honoured AND shadows a same-named component signal. */
47
- function lowerOnce(code: string): string {
47
+ function lowerOnce(
48
+ code: string,
49
+ leadingTransformers: ts.TransformerFactory<ts.SourceFile>[] = [],
50
+ ): string {
48
51
  const source = ts.createSourceFile('expr.ts', code, ts.ScriptTarget.Latest, true)
49
52
  const result = ts.transform(source, [
53
+ ...leadingTransformers,
50
54
  signalRefsTransformer(
51
55
  stateNames,
52
56
  derivedNames,
@@ -54,7 +58,6 @@ export function lowerContext(
54
58
  new Set(scope.names('derived')),
55
59
  new Set(scope.names('plain')),
56
60
  cellReadNames,
57
- blockingCellNames,
58
61
  ),
59
62
  docAccessTransformer('$$model'),
60
63
  ])
@@ -75,6 +78,16 @@ export function lowerContext(
75
78
  return lowerOnce(code)
76
79
  }
77
80
 
81
+ /* A nested branch `<script>` body. Same lowering as `statement`, but first normalises its
82
+ literal `state.computed`/`state.linked` seeds into thunks (`wrapReactiveSeeds`) so a bare-
83
+ value seed (`state.computed(a * 2)`) matches the top-level desugar rather than reaching the
84
+ runtime primitive un-thunked (which would call the value as the compute function and crash).
85
+ The wrap runs BEFORE the reference rename so it wraps the raw seed and the renamed refs land
86
+ inside the thunk. */
87
+ function script(code: string): string {
88
+ return lowerOnce(code, [wrapReactiveSeedsTransformer()])
89
+ }
90
+
78
91
  /* A two-way bind target is either an LVALUE (`count`, `model.lines[i]`) — reads as
79
92
  itself, writes by assignment — or an ACCESSOR object from `bind:value={{ get, set }}`
80
93
  — reads via `.get()`, writes via `.set(v)`. The accessor is the only way to bind a
@@ -136,11 +149,18 @@ export function lowerContext(
136
149
  return {
137
150
  expression,
138
151
  statement,
152
+ script,
139
153
  withNestedScripts,
140
154
  /* The raw branch-local shadow registration both back-ends drive `withBindings`
141
155
  through: a block's bindings flow to a `ShadowKind` only via that one shared loop,
142
156
  never through a per-block `scope.withShadow` call here. */
143
157
  withShadow: scope.withShadow,
158
+ /* Whether `name` is currently shadowed by a block binding (either kind) at this point
159
+ of the walk — the live query classification sites (e.g. `awaitSubjectExpr`) consult
160
+ so a binder that shadows a component cell name is lowered as the local it is, never
161
+ wrapped as the cell it hides. */
162
+ isShadowed: (name: string): boolean =>
163
+ scope.names('plain').has(name) || scope.names('derived').has(name),
144
164
  bindRead,
145
165
  bindWrite,
146
166
  }
@@ -91,19 +91,10 @@ export function lowerScript(
91
91
  derivedNames: Set<string>
92
92
  computedNames: Set<string>
93
93
  cellReadNames: Set<string>
94
- /* Template-injected + script-level `await` cells (ADR-0042), read via `$$readCellBlocking`. */
95
- blockingCellNames: Set<string>
96
94
  droppedReactiveImports: Set<string>
97
95
  } {
98
96
  const source = ts.createSourceFile('component.ts', scriptBody, ts.ScriptTarget.Latest, true)
99
- const {
100
- transformer,
101
- stateNames,
102
- derivedNames,
103
- computedNames,
104
- cellReadNames,
105
- blockingCellNames: allBlockingNames,
106
- } = desugarSignals(
97
+ const { transformer, stateNames, derivedNames, computedNames, cellReadNames } = desugarSignals(
107
98
  source,
108
99
  injectedCellNames,
109
100
  blockingCellNames,
@@ -194,7 +185,6 @@ export function lowerScript(
194
185
  derivedNames,
195
186
  computedNames,
196
187
  cellReadNames,
197
- blockingCellNames: allBlockingNames,
198
188
  droppedReactiveImports,
199
189
  }
200
190
  }
@@ -54,12 +54,10 @@ export function signalRefsTransformer(
54
54
  blockLocalPlain: ReadonlySet<string> = new Set(),
55
55
  /* Component signals read through `$$readCell(name)` — every `linked` and every async
56
56
  `computed` (see `desugarSignals`). A nearer lexical binding of the same name shadows
57
- them like any other signal. */
57
+ them like any other signal. Whether a read PAUSES (blocking `await` cell) or peeks
58
+ (streaming) is decided at runtime by the cell itself (`readCell` consults `cell.blocking`),
59
+ so there is one read form here for every cell and nothing site-specific to thread. */
58
60
  cellReadNames: ReadonlySet<string> = new Set(),
59
- /* The subset of `cellReadNames` that are BLOCKING `await` cells (ADR-0042), read through
60
- `$$readCellBlocking(name)` (suspend-on-pending) instead of `$$readCell`. Non-empty only on
61
- the CLIENT template lowering; the script pass and SSR keep it empty (`$$readCell`). */
62
- blockingCellNames: ReadonlySet<string> = new Set(),
63
61
  ): ts.TransformerFactory<ts.SourceFile> {
64
62
  /* The signal names that a nested binding can shadow — only these matter for
65
63
  scope tracking, so we ignore every other local binding. */
@@ -160,7 +158,6 @@ export function signalRefsTransformer(
160
158
  computedNames,
161
159
  blockLocal,
162
160
  cellReadNames,
163
- blockingCellNames,
164
161
  )
165
162
  if (replacement !== undefined) {
166
163
  return ts.factory.createPropertyAssignment(node.name.text, replacement)
@@ -174,7 +171,6 @@ export function signalRefsTransformer(
174
171
  computedNames,
175
172
  blockLocal,
176
173
  cellReadNames,
177
- blockingCellNames,
178
174
  )
179
175
  if (replacement !== undefined) {
180
176
  return replacement
@@ -393,7 +389,6 @@ function referenceFor(
393
389
  computedNames: ReadonlySet<string>,
394
390
  blockLocal: ReadonlySet<string> = new Set(),
395
391
  cellReadNames: ReadonlySet<string> = new Set(),
396
- blockingCellNames: ReadonlySet<string> = new Set(),
397
392
  ): ts.Expression | undefined {
398
393
  if (blockLocal.has(name)) {
399
394
  return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier(name), 'value')
@@ -403,19 +398,10 @@ function referenceFor(
403
398
  if (name === 'scope') {
404
399
  return ts.factory.createIdentifier('$$scope')
405
400
  }
406
- /* A BLOCKING `await` cell → `$$readCellBlocking(name)` (ADR-0042): the throwing peek that
407
- SUSPENDS the render region while pending, so an `await` binding reads as a resolved value
408
- and its region withholds until it settles. Client template lowering only — `blockingCellNames`
409
- is empty in the script pass and SSR, which keep the plain `$$readCell` peek below. */
410
- if (blockingCellNames.has(name)) {
411
- return ts.factory.createCallExpression(
412
- ts.factory.createIdentifier('$$readCellBlocking'),
413
- undefined,
414
- [ts.factory.createIdentifier(name)],
415
- )
416
- }
417
- /* A `linked` / async `computed` cell → `$$readCell(name)`: one read shape that peeks an
418
- async cell and reads `.value` off a sync one. */
401
+ /* A `linked` / async `computed` cell → `$$readCell(name)`: ONE read shape for every cell,
402
+ script and template. It peeks a sync cell's `.value`, and for an async cell decides at
403
+ runtime whether to pause (blocking `await`) or peek `undefined` (streaming) by the cell's
404
+ own `blocking` bit — so no blocking/streaming distinction is threaded through codegen. */
419
405
  if (cellReadNames.has(name)) {
420
406
  return ts.factory.createCallExpression(
421
407
  ts.factory.createIdentifier('$$readCell'),
@@ -41,9 +41,9 @@ export function seedTypeClassifierForRoot(
41
41
  return undefined
42
42
  }
43
43
  const checker = program.getTypeChecker()
44
+ const mappings = shadows.get(abidePath)?.mappings ?? []
44
45
  return (loc, code) => {
45
46
  try {
46
- const mappings = shadows.get(abidePath)?.mappings ?? []
47
47
  const offset = sourceToShadowOffset(mappings, loc)
48
48
  if (offset === undefined) {
49
49
  return undefined