@abide/abide 0.54.0 → 0.55.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 (96) hide show
  1. package/AGENTS.md +330 -498
  2. package/CHANGELOG.md +49 -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/componentOrdinals.ts +39 -0
  23. package/src/lib/ui/compile/desugarSignals.ts +8 -44
  24. package/src/lib/ui/compile/generateBuild.ts +25 -24
  25. package/src/lib/ui/compile/generateSSR.ts +37 -24
  26. package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
  27. package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
  28. package/src/lib/ui/compile/lowerContext.ts +26 -6
  29. package/src/lib/ui/compile/lowerScript.ts +1 -11
  30. package/src/lib/ui/compile/renameSignalRefs.ts +7 -21
  31. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +1 -1
  32. package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
  33. package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
  34. package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
  35. package/src/lib/ui/compile/wrapSeed.ts +28 -0
  36. package/src/lib/ui/computed.ts +16 -0
  37. package/src/lib/ui/createScope.ts +4 -3
  38. package/src/lib/ui/dom/anchorCursor.ts +2 -1
  39. package/src/lib/ui/dom/anchoredBranch.ts +14 -3
  40. package/src/lib/ui/dom/appendStatic.ts +11 -20
  41. package/src/lib/ui/dom/appendText.ts +41 -43
  42. package/src/lib/ui/dom/appendTextAt.ts +10 -57
  43. package/src/lib/ui/dom/awaitBlock.ts +30 -44
  44. package/src/lib/ui/dom/awaitSubject.ts +118 -0
  45. package/src/lib/ui/dom/claimText.ts +38 -0
  46. package/src/lib/ui/dom/cloneStatic.ts +2 -8
  47. package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
  48. package/src/lib/ui/dom/discardBoundary.ts +5 -4
  49. package/src/lib/ui/dom/each.ts +3 -5
  50. package/src/lib/ui/dom/fillBoundary.ts +2 -1
  51. package/src/lib/ui/dom/hydrate.ts +5 -12
  52. package/src/lib/ui/dom/mountChild.ts +73 -5
  53. package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
  54. package/src/lib/ui/dom/openMarker.ts +2 -4
  55. package/src/lib/ui/dom/outlet.ts +4 -3
  56. package/src/lib/ui/dom/readCell.ts +26 -11
  57. package/src/lib/ui/dom/skeleton.ts +2 -7
  58. package/src/lib/ui/dom/switchBlock.ts +2 -1
  59. package/src/lib/ui/dom/tryBlock.ts +26 -38
  60. package/src/lib/ui/dom/when.ts +6 -2
  61. package/src/lib/ui/linked.ts +14 -0
  62. package/src/lib/ui/resumeSeedScript.ts +1 -1
  63. package/src/lib/ui/router.ts +12 -24
  64. package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
  65. package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
  66. package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
  67. package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
  68. package/src/lib/ui/runtime/RENDER.ts +7 -5
  69. package/src/lib/ui/runtime/RESUME.ts +5 -7
  70. package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
  71. package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
  72. package/src/lib/ui/runtime/claimChild.ts +5 -7
  73. package/src/lib/ui/runtime/claimExpected.ts +2 -6
  74. package/src/lib/ui/runtime/claimMarker.ts +13 -0
  75. package/src/lib/ui/runtime/claimRun.ts +22 -0
  76. package/src/lib/ui/runtime/consumeSeed.ts +38 -0
  77. package/src/lib/ui/runtime/createAsyncCell.ts +56 -8
  78. package/src/lib/ui/runtime/createComputedNode.ts +1 -0
  79. package/src/lib/ui/runtime/createEffectNode.ts +1 -0
  80. package/src/lib/ui/runtime/createSignalNode.ts +1 -0
  81. package/src/lib/ui/runtime/parkCursor.ts +10 -0
  82. package/src/lib/ui/runtime/readNode.ts +21 -1
  83. package/src/lib/ui/runtime/renderPath.ts +2 -2
  84. package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
  85. package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
  86. package/src/lib/ui/runtime/runNode.ts +14 -0
  87. package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
  88. package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
  89. package/src/lib/ui/settleAsyncCells.ts +30 -3
  90. package/src/lib/ui/startClient.ts +0 -7
  91. package/src/lib/ui/tryEncodeResume.ts +6 -15
  92. package/src/lib/ui/watch.ts +3 -11
  93. package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
  94. package/src/lib/ui/dom/readCellBlocking.ts +0 -29
  95. package/src/lib/ui/runtime/restoreWarmSeeds.ts +0 -20
  96. 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
@@ -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
+ }
@@ -5,6 +5,7 @@ import { type ReactiveImportBindings, reactiveImportBindings } from './resolveRe
5
5
  import { signalCallee } from './signalCallee.ts'
6
6
  import type { InterpolationKind } from './types/InterpolationKind.ts'
7
7
  import type { SeedTypeClassifier } from './types/SeedTypeClassifier.ts'
8
+ import { wrapSeed } from './wrapSeed.ts'
8
9
 
9
10
  /* The routing decision for a no-marker `computed(seed)`: `true` routes to the eager
10
11
  `trackedComputed` stream cell (read via `$$readCell`, the `cellReadNames` bucket), `false`
@@ -14,28 +15,6 @@ type EagerStreamPredicate = (declaration: ts.VariableDeclaration) => boolean
14
15
 
15
16
  const factory = ts.factory
16
17
 
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
18
  /* Wraps a seed expression as an async unwrapping thunk `async () => await (arg)` — for a
40
19
  type-directed PROMISE seed (ADR-0023/0043) the author wrote WITHOUT `await`
41
20
  (`state.computed(getFoo())`). The `async`+`await` makes createAsyncCell unwrap the resolved
@@ -231,10 +210,6 @@ export function desugarSignals(
231
210
  derivedNames: Set<string>
232
211
  computedNames: Set<string>
233
212
  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
213
  } {
239
214
  assertNoRemovedReaders(source)
240
215
  /* The full set of names written anywhere (this script + the template). A prop in this set
@@ -253,10 +228,6 @@ export function desugarSignals(
253
228
  One read shape covers both — `$$readCell` peeks an async cell and reads `.value` off a
254
229
  sync one — so `linked(getStream())` auto-tracks with no read-site branching. */
255
230
  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
231
  /* A `props()` destructure must be lowered even when it declares no reactive binding
261
232
  (a rest-only `const { ...rest } = props()`), so track its presence on its own. */
262
233
  let hasPropsDestructure = false
@@ -359,27 +330,21 @@ export function desugarSignals(
359
330
  isEagerStreamComputed(declaration) ||
360
331
  isPromiseComputed(declaration)
361
332
  ) {
333
+ /* Async `computed` (author `await`), an eager stream, or a promise seed — all
334
+ read via `$$readCell(name)`. Whether a pending read PAUSES (author `await`,
335
+ a blocking cell) or peeks `undefined` (streaming) is carried by the cell
336
+ itself at construction (the `trackedComputed(thunk, streaming)` flag), so the
337
+ read form is uniform and nothing blocking-specific is collected here. */
362
338
  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
339
  } else {
371
340
  computedNames.add(declaration.name.text)
372
341
  }
373
342
  } else if (callee === 'linked') {
374
343
  /* `linked` → a cell read via `$$readCell(name)`: a plain `State` when the seed
375
344
  is synchronous, an `AsyncState` when it tracks a promise/stream — one read
376
- shape auto-tracks whichever source the runtime primitive resolved to. */
345
+ shape auto-tracks whichever source the runtime primitive resolved to. Its
346
+ blocking-ness (author `await`) likewise rides on the constructed cell. */
377
347
  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
348
  } else if (callee === 'state') {
384
349
  /* `state(initial, transform)` → a `.value` cell (its write-coercion transform
385
350
  forces a local store); referenced as `name.value`, unchanged. */
@@ -441,7 +406,6 @@ export function desugarSignals(
441
406
  derivedNames,
442
407
  computedNames,
443
408
  cellReadNames,
444
- blockingCellNames: new Set([...blockingCellNames, ...scriptBlockingNames]),
445
409
  }
446
410
  }
447
411
 
@@ -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