@abide/abide 0.51.0 → 0.53.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 (83) hide show
  1. package/AGENTS.md +518 -525
  2. package/CHANGELOG.md +54 -0
  3. package/README.md +130 -157
  4. package/package.json +5 -1
  5. package/src/abideResolverPlugin.ts +12 -0
  6. package/src/checkAbide.ts +60 -7
  7. package/src/lib/cli/completeCli.ts +38 -0
  8. package/src/lib/cli/printTopLevelHelp.ts +1 -0
  9. package/src/lib/cli/renderCliCompletions.ts +56 -0
  10. package/src/lib/cli/runCli.ts +27 -0
  11. package/src/lib/server/render.ts +70 -0
  12. package/src/lib/server/runtime/cacheStalenessBroadcaster.ts +33 -0
  13. package/src/lib/server/runtime/createServer.ts +43 -0
  14. package/src/lib/server/runtime/createUiPageRenderer.ts +37 -0
  15. package/src/lib/server/runtime/pageRenderSlot.ts +15 -0
  16. package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
  17. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  18. package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
  19. package/src/lib/server/sockets/registerSocket.ts +12 -0
  20. package/src/lib/shared/CACHE_STALENESS_SOCKET.ts +8 -0
  21. package/src/lib/shared/RESERVED_SOCKET_PREFIX.ts +8 -0
  22. package/src/lib/shared/applyCacheStalenessLocally.ts +18 -0
  23. package/src/lib/shared/attachRpcSelectorMethods.ts +3 -1
  24. package/src/lib/shared/cache.ts +32 -4
  25. package/src/lib/shared/cacheStalenessSlot.ts +21 -0
  26. package/src/lib/shared/createRpcServerProgram.ts +187 -0
  27. package/src/lib/shared/docSnapshotsSlot.ts +13 -0
  28. package/src/lib/shared/invalidate.ts +39 -0
  29. package/src/lib/shared/matcherFromEnvelope.ts +31 -0
  30. package/src/lib/shared/prepareRpcModule.ts +22 -3
  31. package/src/lib/shared/refresh.ts +9 -2
  32. package/src/lib/shared/selectorMatcher.ts +2 -15
  33. package/src/lib/shared/serializeSelector.ts +69 -0
  34. package/src/lib/shared/setsIntersect.ts +15 -0
  35. package/src/lib/shared/types/CacheStalenessApply.ts +13 -0
  36. package/src/lib/shared/types/CacheStalenessFrame.ts +24 -0
  37. package/src/lib/shared/types/DocSnapshots.ts +12 -0
  38. package/src/lib/shared/types/RemoteFunction.ts +11 -8
  39. package/src/lib/shared/types/RpcBuildStamps.ts +9 -0
  40. package/src/lib/shared/types/SsrPayload.ts +5 -0
  41. package/src/lib/test/createTestApp.ts +15 -0
  42. package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +19 -0
  43. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +2 -0
  44. package/src/lib/ui/compile/analyzeComponent.ts +2 -0
  45. package/src/lib/ui/compile/compileComponent.ts +3 -1
  46. package/src/lib/ui/compile/compileSSR.ts +12 -0
  47. package/src/lib/ui/compile/compileShadow.ts +97 -16
  48. package/src/lib/ui/compile/desugarSignals.ts +60 -32
  49. package/src/lib/ui/compile/generateBuild.ts +5 -1
  50. package/src/lib/ui/compile/hasTopLevelAwait.ts +28 -0
  51. package/src/lib/ui/compile/isFunctionScopeBoundary.ts +20 -0
  52. package/src/lib/ui/compile/liftAsyncSubExpressions.ts +2 -2
  53. package/src/lib/ui/compile/lowerContext.ts +4 -0
  54. package/src/lib/ui/compile/lowerDocAccess.ts +2 -14
  55. package/src/lib/ui/compile/lowerScript.ts +17 -2
  56. package/src/lib/ui/compile/renameSignalRefs.ts +83 -0
  57. package/src/lib/ui/compile/types/AnalyzedComponent.ts +4 -0
  58. package/src/lib/ui/compile/wrapReactionCellSources.ts +32 -0
  59. package/src/lib/ui/createScope.ts +42 -1
  60. package/src/lib/ui/dom/appendText.ts +29 -8
  61. package/src/lib/ui/dom/appendTextAt.ts +21 -4
  62. package/src/lib/ui/dom/assertClaimedText.ts +14 -4
  63. package/src/lib/ui/dom/attr.ts +41 -3
  64. package/src/lib/ui/dom/awaitBlock.ts +34 -13
  65. package/src/lib/ui/dom/each.ts +10 -2
  66. package/src/lib/ui/dom/eachAsync.ts +9 -1
  67. package/src/lib/ui/dom/readCellBlocking.ts +29 -0
  68. package/src/lib/ui/dom/readTextOrSuspend.ts +21 -0
  69. package/src/lib/ui/dom/spreadAttrs.ts +47 -1
  70. package/src/lib/ui/dom/switchBlock.ts +19 -2
  71. package/src/lib/ui/dom/tryBlock.ts +10 -0
  72. package/src/lib/ui/dom/when.ts +19 -1
  73. package/src/lib/ui/dom/withSuspense.ts +22 -0
  74. package/src/lib/ui/dom/writeCell.ts +22 -0
  75. package/src/lib/ui/runtime/DOC_SEED.ts +11 -0
  76. package/src/lib/ui/runtime/SuspenseSignal.ts +24 -0
  77. package/src/lib/ui/runtime/claimExpected.ts +6 -1
  78. package/src/lib/ui/runtime/flushEffects.ts +18 -10
  79. package/src/lib/ui/runtime/reportHydrationDivergence.ts +30 -0
  80. package/src/lib/ui/startClient.ts +22 -1
  81. package/src/lib/ui/subscribeCacheStaleness.ts +85 -0
  82. package/src/lib/ui/watch.ts +7 -2
  83. package/src/serverEntry.ts +12 -0
@@ -0,0 +1,19 @@
1
+ import ts from 'typescript'
2
+
3
+ /*
4
+ Maps a compound-assignment operator to its plain binary counterpart, for lowering
5
+ `x += y` into an unconditional read-combine-write. Logical assignments
6
+ (`||=`/`&&=`/`??=`) lower the same way — the patch/cell write always fires,
7
+ consistent with how `+=` lowers. Shared by lowerDocAccess (the `$$model` write path)
8
+ and renameSignalRefs (the `$$writeCell` linked-cell path) so the two lowerings can't
9
+ drift.
10
+ */
11
+ export const COMPOUND_ASSIGNMENT_OPERATORS = new Map<ts.SyntaxKind, ts.BinaryOperator>([
12
+ [ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.PlusToken],
13
+ [ts.SyntaxKind.MinusEqualsToken, ts.SyntaxKind.MinusToken],
14
+ [ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.AsteriskToken],
15
+ [ts.SyntaxKind.SlashEqualsToken, ts.SyntaxKind.SlashToken],
16
+ [ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.BarBarToken],
17
+ [ts.SyntaxKind.AmpersandAmpersandEqualsToken, ts.SyntaxKind.AmpersandAmpersandToken],
18
+ [ts.SyntaxKind.QuestionQuestionEqualsToken, ts.SyntaxKind.QuestionQuestionToken],
19
+ ])
@@ -56,6 +56,8 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: stri
56
56
  { name: 'spreadAttrs', specifier: 'ui/dom/spreadAttrs', alias: '$$spreadAttrs' },
57
57
  { name: 'readCall', specifier: 'ui/dom/readCall', alias: '$$readCall' },
58
58
  { name: 'readCell', specifier: 'ui/dom/readCell', alias: '$$readCell' },
59
+ { name: 'readCellBlocking', specifier: 'ui/dom/readCellBlocking', alias: '$$readCellBlocking' },
60
+ { name: 'writeCell', specifier: 'ui/dom/writeCell', alias: '$$writeCell' },
59
61
  { name: 'cellPending', specifier: 'ui/dom/cellPending', alias: '$$cellPending' },
60
62
  { name: 'withPath', specifier: 'ui/runtime/withPath', alias: '$$withPath' },
61
63
  { name: 'settleAsyncCells', specifier: 'ui/settleAsyncCells', alias: '$$settleAsyncCells' },
@@ -120,6 +120,7 @@ export function analyzeComponent(
120
120
  derivedNames,
121
121
  computedNames,
122
122
  cellReadNames,
123
+ blockingCellNames: allBlockingCellNames,
123
124
  droppedReactiveImports,
124
125
  } = lowerScript(
125
126
  fullScriptBody,
@@ -142,6 +143,7 @@ export function analyzeComponent(
142
143
  derivedNames,
143
144
  computedNames,
144
145
  cellReadNames,
146
+ blockingCellNames: allBlockingCellNames,
145
147
  nodes,
146
148
  styles,
147
149
  /* Hydration adopts every block in place — including `await`, which resumes
@@ -29,7 +29,8 @@ 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 { script, stateNames, derivedNames, computedNames, cellReadNames, nodes } = resolved
32
+ const { script, stateNames, derivedNames, computedNames, cellReadNames, blockingCellNames, nodes } =
33
+ resolved
33
34
  const build = generateBuild(
34
35
  nodes,
35
36
  'host',
@@ -38,6 +39,7 @@ export function compileComponent(
38
39
  computedNames,
39
40
  isLayout,
40
41
  cellReadNames,
42
+ blockingCellNames,
41
43
  )
42
44
  /* The scoped CSS is bundled into the entry stylesheet (see `abideUiPlugin`), not
43
45
  injected at runtime; the build only needs the `data-a-…` scope attributes on
@@ -66,6 +66,18 @@ export function compileSSR(
66
66
  flightDecls,
67
67
  hasStagedChildren,
68
68
  } = generateSSR(nodes, stateNames, derivedNames, computedNames, isLayout, cellReadNames)
69
+ /* ADR-0042 D7 (barrier completeness): every async cell — and so every BLOCKING (`await`) cell —
70
+ is declared in the lowered script (`lowered`), which the body below emits BEFORE the
71
+ `$$settleAsyncCells` barrier; the template back-end (`ssr`) constructs no cells. A blocking
72
+ cell built AFTER its barrier would ship an unresolved value the client then SUSPENDS against,
73
+ desyncing hydration. The invariant holds by construction today (cells are a script-block
74
+ construct); assert it as a tripwire so a future template lowering that emits a cell
75
+ construction fails the build LOUDLY here instead of as a runtime hydration desync. */
76
+ if (/\$\$scope\(\)\.(trackedComputed|computed|linked)\(/.test(ssr)) {
77
+ throw new Error(
78
+ '[abide] a reactive cell is constructed in template output, after the async-cell barrier (ADR-0042 D7) — cells must be declared in the component script so they settle before the SSR flush.',
79
+ )
80
+ }
69
81
  /* ADR-0039: a component with hoistable children declares `$childSlots` (the body walk reserves an
70
82
  output slot per child) and, after the walk, awaits `$$finalizeStreamedChildren` — which fills
71
83
  each slot inline (settled child, byte-identical to the old inline await) or with a streaming
@@ -1,6 +1,8 @@
1
1
  import ts from 'typescript'
2
2
  import { ABIDE_PACKAGE_NAME } from '../../shared/ABIDE_PACKAGE_NAME.ts'
3
3
  import { attrLiftPosition } from './attrLiftPosition.ts'
4
+ import { hasTopLevelAwait } from './hasTopLevelAwait.ts'
5
+ import { isFunctionScopeBoundary } from './isFunctionScopeBoundary.ts'
4
6
  import { isWhitespaceText } from './isWhitespaceText.ts'
5
7
  import { type LiftPosition, liftAsyncSubExpressions } from './liftAsyncSubExpressions.ts'
6
8
  import { parseTemplate } from './parseTemplate.ts'
@@ -94,6 +96,23 @@ export function compileShadow(
94
96
  watchLocalName,
95
97
  } = analyzeScript(scriptBody, scriptStart)
96
98
  builder.raw(shadowPreamble(importedReactives))
99
+ /* Every `computed`/`linked` scope line reads through this: it invokes the seed thunk and
100
+ unwraps a promise/stream to the value a BARE cell read peeks (ADR-0019/0032) — so an async
101
+ `computed(await …)` / stream `computed(src)` type-checks as its resolved value, and a sync
102
+ seed keeps its return type (`Awaited` is identity on a non-thenable). Reserved `$$` name so
103
+ it can never collide with an author binding; `declare`-only, so it carries no runtime. */
104
+ builder.raw(
105
+ 'declare function $$cellValue<R>(seed: () => R): R extends AsyncIterable<infer F> ? F : Awaited<R>\n',
106
+ )
107
+ /* The STREAMING sibling (ADR-0042): a no-`await` async `computed`/`linked` cell reads
108
+ `undefined`-while-pending, so its shadow type is the resolved value UNIONED with `undefined` —
109
+ the author must guard it (`?.`/`??`), exactly the runtime. A BLOCKING (`await`) cell uses
110
+ `$$cellValue` above (resolved `T`, never pending — its render region suspends). Selected in
111
+ `scopeLineFor` by the syntactic blocking bit. Unconditional (a named cell can occur with no
112
+ classifier), matching `$$cellValue`. */
113
+ builder.raw(
114
+ 'declare function $$cellValuePending<R>(seed: () => R): (R extends AsyncIterable<infer F> ? F : Awaited<R>) | undefined\n',
115
+ )
97
116
  /* The peek helpers the async-interpolation wrap targets (ADR-0032): `$$peek` unwraps a promise
98
117
  sub-expression to its resolved value (`undefined` while pending) and `$$peekStream` reads an
99
118
  async iterable's latest frame, so `getFoo()?.name`/`{#if getFoo()}` type-check against the
@@ -395,27 +414,38 @@ function collectReservedNameDiagnostics(
395
414
  top-level await transpiles to `await` in a non-async function and breaks the bundle; the
396
415
  shadow's render fn is async, so `tsc` alone never flags it (a check/runtime parity gap).
397
416
  Stops descending at function boundaries — their own async-ness is the author's concern —
398
- and catches both `await expr` and `for await (… of …)`, flagging the `await` keyword. */
417
+ and catches both `await expr` and `for await (… of …)`, flagging the `await` keyword. A
418
+ `computed`/`linked` SEED argument is exempt: `wrapSeed` (desugarSignals) lowers a top-level
419
+ `await` there into an async thunk (`computed(await load())` → `async () => await load()`), so
420
+ its await runs off the sync build — flagging it would be a false positive. */
399
421
  function collectTopLevelAwaitDiagnostics(
400
422
  file: ts.SourceFile,
401
423
  scriptStart: number,
402
424
  diagnostics: ShadowDiagnostic[],
425
+ bindings: ReactiveImportBindings,
403
426
  ): void {
404
427
  const message =
405
428
  'top-level `await` is not allowed in a `<script>` — the component build runs synchronously. Use `{#await expr}…{:then value}…{/await}` markup for blocking data, or `await` inside an async event handler.'
406
429
  const visit = (node: ts.Node): void => {
407
430
  /* A nested function introduces its own (possibly async) scope; its awaits are legal. */
408
- if (
409
- ts.isFunctionDeclaration(node) ||
410
- ts.isFunctionExpression(node) ||
411
- ts.isArrowFunction(node) ||
412
- ts.isMethodDeclaration(node) ||
413
- ts.isGetAccessorDeclaration(node) ||
414
- ts.isSetAccessorDeclaration(node) ||
415
- ts.isConstructorDeclaration(node)
416
- ) {
431
+ if (isFunctionScopeBoundary(node)) {
417
432
  return
418
433
  }
434
+ /* A `computed`/`linked` seed becomes an async thunk (`wrapSeed`), so an await in it is
435
+ legal — visit the callee and any trailing args, but skip the seed (`arguments[0]`). */
436
+ if (ts.isCallExpression(node) && node.arguments.length > 0) {
437
+ const callee = resolveReactiveExport(node.expression, bindings)
438
+ if (callee === 'computed' || callee === 'linked') {
439
+ visit(node.expression)
440
+ for (let index = 1; index < node.arguments.length; index++) {
441
+ const argument = node.arguments[index]
442
+ if (argument !== undefined) {
443
+ visit(argument)
444
+ }
445
+ }
446
+ return
447
+ }
448
+ }
419
449
  /* The `await` keyword token: the AwaitExpression's first child, or a `for await`'s
420
450
  await modifier. */
421
451
  const keyword = ts.isAwaitExpression(node)
@@ -449,7 +479,8 @@ function collectNestedScriptAwaitDiagnostics(
449
479
  }
450
480
  if (node.kind === 'script' && node.loc !== undefined) {
451
481
  const file = ts.createSourceFile('nested.ts', node.code, ts.ScriptTarget.Latest, true)
452
- collectTopLevelAwaitDiagnostics(file, node.loc, diagnostics)
482
+ /* A nested script inherits the surface by canonical name (no imports of its own). */
483
+ collectTopLevelAwaitDiagnostics(file, node.loc, diagnostics, NESTED_REACTIVE_BINDINGS)
453
484
  }
454
485
  if ('children' in node) {
455
486
  collectNestedScriptAwaitDiagnostics(node.children, diagnostics)
@@ -503,7 +534,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
503
534
  collectReservedNameDiagnostics(file, scriptStart, diagnostics)
504
535
  /* The leading script runs in the synchronous `build()`, so a top-level await breaks the
505
536
  bundle — catch it here with a legible message instead of an opaque transpile error. */
506
- collectTopLevelAwaitDiagnostics(file, scriptStart, diagnostics)
537
+ collectTopLevelAwaitDiagnostics(file, scriptStart, diagnostics, bindings)
507
538
  /* A verbatim span: original text + the segment mapping it back, relative to the
508
539
  line start (the caller rebases shadowStart onto the running shadow length). */
509
540
  const span = (node: ts.Node, prefixLength: number): ScopeLine['segments'][number] => ({
@@ -683,13 +714,57 @@ function scopeLineFor(
683
714
  segments: [span(declaration.name, keywordOffset)],
684
715
  })
685
716
  }
686
- const prefix = `${keyword} ${name}${annotation} = (`
717
+ /* Mirror `wrapSeed` (desugarSignals): a bare (non-thunk) seed is normalised to `() => (seed)`,
718
+ made ASYNC when it carries a top-level `await` — so `computed(await load())` projects as a
719
+ legal async thunk instead of a raw top-level await (`(await load())()` — a build-breaker that
720
+ also mis-called the resolved value). A literal thunk passes through unchanged. `$$cellValue`
721
+ then invokes it and unwraps a promise/stream to the value a bare read peeks, so an async or
722
+ stream seed types as its RESOLVED value (`Promise<T>` → `T`) rather than the raw promise. */
723
+ const fnIsThunk = ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)
724
+ const wrapPrefix = fnIsThunk ? '' : seedIsAsync(fn) ? 'async () => (' : '() => ('
725
+ const wrapSuffix = fnIsThunk ? '' : ')'
726
+ /* ADR-0042: a STREAMING promise cell (async seed, no top-level `await`) reads
727
+ `undefined`-while-pending → `$$cellValuePending` (resolved `T | undefined`). A BLOCKING
728
+ (`await`) cell suspends its region so it is always resolved, and a sync cell is never pending
729
+ → `$$cellValue` (resolved `T`). (A bare stream seed keeps `$$cellValue`'s frame type — the
730
+ pre-existing type-directed case, unchanged here.) */
731
+ const helper = seedIsAsync(fn) && !seedIsBlocking(fn) ? '$$cellValuePending' : '$$cellValue'
732
+ const prefix = `${keyword} ${name}${annotation} = ${helper}(${wrapPrefix}`
687
733
  return withCalleeRef({
688
- text: `${prefix}${verbatim(fn)})();`,
734
+ text: `${prefix}${verbatim(fn)}${wrapSuffix});`,
689
735
  segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
690
736
  })
691
737
  }
692
738
 
739
+ /* True for a `computed`/`linked` seed that `wrapSeed` (desugarSignals) turns into an ASYNC thunk —
740
+ a thunk the author wrote `async`, or a bare seed carrying a top-level `await` (`computed(await
741
+ x)`). The shadow must agree so it unwraps the promise to the resolved value AND so the top-level
742
+ await scan spares the seed. Stops at function boundaries — an inner `async` callback is its own
743
+ scope, mirroring `hasTopLevelAwait`. */
744
+ function seedIsAsync(seed: ts.Expression): boolean {
745
+ if (ts.isArrowFunction(seed) || ts.isFunctionExpression(seed)) {
746
+ return (
747
+ ts
748
+ .getModifiers(seed)
749
+ ?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) ?? false
750
+ )
751
+ }
752
+ return hasTopLevelAwait(seed)
753
+ }
754
+
755
+ /* True for a BLOCKING async cell (ADR-0042 D6): an async thunk whose BODY has a top-level `await`
756
+ (`computed(async () => await X)`), or a bare seed carrying a top-level `await` (`computed(await
757
+ X)` — wrapSeed makes it an async thunk over that await). An async thunk with NO await
758
+ (`computed(async () => getFoo())`) is STREAMING, not blocking. Shares `hasTopLevelAwait` with
759
+ desugarSignals' `isBlockingSeed` so the shadow type (`$$cellValue` vs `$$cellValuePending`) agrees
760
+ with the runtime's blocking flag — the "one predicate" ADR-0042 D5 requires. */
761
+ function seedIsBlocking(seed: ts.Expression): boolean {
762
+ if (ts.isArrowFunction(seed) || ts.isFunctionExpression(seed)) {
763
+ return seedIsAsync(seed) && hasTopLevelAwait(seed.body)
764
+ }
765
+ return hasTopLevelAwait(seed)
766
+ }
767
+
693
768
  /* Whether any ELEMENT in the tree carries an `attach` — gates emitting the DOM-typed
694
769
  attachment aliases. All nested content (block bodies, await/switch branches, snippet
695
770
  bodies) routes through `children`, so a recursive `children` walk is complete. */
@@ -969,7 +1044,11 @@ function emitNode(node: TemplateNode, builder: Builder): void {
969
1044
  } else if (branch.branch === 'catch' && branch.as !== undefined) {
970
1045
  builder.raw('const ')
971
1046
  builder.mapped(branch.as, branch.asLoc)
972
- builder.raw(' = undefined as any;\n')
1047
+ /* `unknown`, not `any` — a `catch` error is statically unknowable, and this
1048
+ matches what strict-mode plain TS gives a real `catch (err)`
1049
+ (`useUnknownInCatchVariables`), so the author must narrow before dereferencing
1050
+ (`err instanceof Error ? … : …`) exactly as outside a template (ADR-0042 fidelity). */
1051
+ builder.raw(' = undefined as unknown;\n')
973
1052
  }
974
1053
  emitNodes(branch.children, builder)
975
1054
  builder.raw('}\n')
@@ -1015,7 +1094,9 @@ function emitNode(node: TemplateNode, builder: Builder): void {
1015
1094
  handlers consume their own branches inline and never route through this case. */
1016
1095
  builder.raw('{\n')
1017
1096
  if (node.as !== undefined) {
1018
- builder.raw(`const ${node.as} = undefined as any;\n`)
1097
+ /* `unknown`, not `any` see the await-`catch` binding above (matches strict TS's
1098
+ `catch (err)`, forcing the author to narrow). */
1099
+ builder.raw(`const ${node.as} = undefined as unknown;\n`)
1019
1100
  }
1020
1101
  emitNodes(node.children, builder)
1021
1102
  builder.raw('}\n')
@@ -1,5 +1,6 @@
1
1
  import ts from 'typescript'
2
2
  import { assignmentTargetNames } from './assignmentTargetNames.ts'
3
+ import { hasTopLevelAwait } from './hasTopLevelAwait.ts'
3
4
  import { type ReactiveImportBindings, reactiveImportBindings } from './resolveReactiveExport.ts'
4
5
  import { signalCallee } from './signalCallee.ts'
5
6
  import type { InterpolationKind } from './types/InterpolationKind.ts'
@@ -13,34 +14,6 @@ type EagerStreamPredicate = (declaration: ts.VariableDeclaration) => boolean
13
14
 
14
15
  const factory = ts.factory
15
16
 
16
- /* True when `node` holds an `await` that runs at the seed's own top level — one NOT nested
17
- inside a further function. The walk stops at every nested function boundary, so an
18
- `await` in an inner callback (`items.map(async (x) => await f(x))`) does not count: only
19
- a top-level `await` marks the seed itself as an async thunk the async-cell path unwraps. */
20
- function hasTopLevelAwait(node: ts.Node): boolean {
21
- let found = false
22
- const visit = (child: ts.Node): void => {
23
- if (found) {
24
- return
25
- }
26
- /* A nested function is its own await scope — don't descend into it. */
27
- if (
28
- ts.isFunctionDeclaration(child) ||
29
- ts.isFunctionExpression(child) ||
30
- ts.isArrowFunction(child)
31
- ) {
32
- return
33
- }
34
- if (ts.isAwaitExpression(child)) {
35
- found = true
36
- return
37
- }
38
- ts.forEachChild(child, visit)
39
- }
40
- visit(node)
41
- return found
42
- }
43
-
44
17
  /* Normalises a `computed`/`linked` argument into a seed THUNK: a literal `() => …` /
45
18
  `function` argument passes through unchanged (the author already wrote the thunk), any
46
19
  other expression is wrapped as `() => arg`. The wrapper arrow is made ASYNC when the
@@ -106,6 +79,23 @@ function isBareCallComputed(declaration: ts.VariableDeclaration): boolean {
106
79
  return ts.isCallExpression(argument) || ts.isIdentifier(argument)
107
80
  }
108
81
 
82
+ /* True when a `computed`/`linked` seed is a BLOCKING async cell (ADR-0042 D6): its wrapped seed
83
+ is an async thunk whose BODY has a top-level `await` — `computed(await X)` (wrapSeed makes it
84
+ `async () => await X`) or `computed(async () => await X)`. An async thunk with NO await
85
+ (`computed(async () => getFoo())`) is STREAMING — `await` is the sole blocking marker. A
86
+ sync/stream seed is not blocking. The read of a blocking cell suspends its render region until
87
+ the value resolves; a streaming cell reads `undefined`-while-pending. Reuses `hasTopLevelAwait`
88
+ applied to the thunk BODY (not the arrow), so the walk descends exactly one function level. */
89
+ function isBlockingSeed(argument: ts.Expression): boolean {
90
+ const wrapped = wrapSeed(argument)
91
+ if (!isAsyncSeed(wrapped)) {
92
+ return false
93
+ }
94
+ const body =
95
+ ts.isArrowFunction(wrapped) || ts.isFunctionExpression(wrapped) ? wrapped.body : wrapped
96
+ return hasTopLevelAwait(body)
97
+ }
98
+
109
99
  /* Emits a compile WARNING (best-effort, never fatal) when a signal read appears AFTER the
110
100
  first top-level `await` in an async seed — a value read there is no longer tracked, so the
111
101
  cell won't reseed when it changes. Detection only: reads before the await (or with no
@@ -223,6 +213,10 @@ export function desugarSignals(
223
213
  derivedNames: Set<string>
224
214
  computedNames: Set<string>
225
215
  cellReadNames: Set<string>
216
+ /* The full BLOCKING cell set (ADR-0042): the template-injected `await` cells passed in, unioned
217
+ with the script-level `await` computeds/linked collected here. The client template lowering
218
+ reads these via `$$readCellBlocking` (suspend-on-pending). */
219
+ blockingCellNames: Set<string>
226
220
  } {
227
221
  assertNoRemovedReaders(source)
228
222
  /* The full set of names written anywhere (this script + the template). A prop in this set
@@ -241,6 +235,10 @@ export function desugarSignals(
241
235
  One read shape covers both — `$$readCell` peeks an async cell and reads `.value` off a
242
236
  sync one — so `linked(getStream())` auto-tracks with no read-site branching. */
243
237
  const cellReadNames = new Set<string>()
238
+ /* Script-level BLOCKING cell names (ADR-0042 D6): a `computed`/`linked` whose seed carries a
239
+ top-level `await`. Unioned with the template-injected `blockingCellNames` and returned so the
240
+ CLIENT template lowering reads them via `$$readCellBlocking` (suspend-on-pending). */
241
+ const scriptBlockingNames = new Set<string>()
244
242
  /* A `props()` destructure must be lowered even when it declares no reactive binding
245
243
  (a rest-only `const { ...rest } = props()`), so track its presence on its own. */
246
244
  let hasPropsDestructure = false
@@ -325,6 +323,11 @@ export function desugarSignals(
325
323
  or its seed type resolves to a stream (`isEagerStreamComputed`, ADR-0023). */
326
324
  if (isAsyncComputed(declaration) || isEagerStreamComputed(declaration)) {
327
325
  cellReadNames.add(declaration.name.text)
326
+ /* A blocking async computed (author `await`) reads suspend-on-pending. */
327
+ const argument = seedArgument(declaration)
328
+ if (argument !== undefined && isBlockingSeed(argument)) {
329
+ scriptBlockingNames.add(declaration.name.text)
330
+ }
328
331
  } else {
329
332
  computedNames.add(declaration.name.text)
330
333
  }
@@ -333,6 +336,11 @@ export function desugarSignals(
333
336
  is synchronous, an `AsyncState` when it tracks a promise/stream — one read
334
337
  shape auto-tracks whichever source the runtime primitive resolved to. */
335
338
  cellReadNames.add(declaration.name.text)
339
+ /* A blocking async linked (author `await`) reads suspend-on-pending, like computed. */
340
+ const argument = seedArgument(declaration)
341
+ if (argument !== undefined && isBlockingSeed(argument)) {
342
+ scriptBlockingNames.add(declaration.name.text)
343
+ }
336
344
  } else if (callee === 'state') {
337
345
  /* `state(initial, transform)` → a `.value` cell (its write-coercion transform
338
346
  forces a local store); referenced as `name.value`, unchanged. */
@@ -387,7 +395,14 @@ export function desugarSignals(
387
395
  return factory.updateSourceFile(root, statements)
388
396
  }
389
397
 
390
- return { transformer, stateNames, derivedNames, computedNames, cellReadNames }
398
+ return {
399
+ transformer,
400
+ stateNames,
401
+ derivedNames,
402
+ computedNames,
403
+ cellReadNames,
404
+ blockingCellNames: new Set([...blockingCellNames, ...scriptBlockingNames]),
405
+ }
391
406
  }
392
407
 
393
408
  /* The lowered form of a top-level statement: state slots → `model.x = init`
@@ -627,10 +642,23 @@ function computedStatements(
627
642
  )
628
643
  : wrapSeed(argument)
629
644
  if (isAsyncSeed(wrapped)) {
630
- /* Async seed → the eager `computed` primitive (an `AsyncComputed` cell, read via
631
- `$$readCell`); the runtime unwraps the promise. */
645
+ /* Async seed → the eager async cell (`AsyncComputed`, read via `$$readCell`), the
646
+ runtime unwraps the promise. `await` in the thunk body → BLOCKING (joins the SSR
647
+ barrier, resolves inline, client suspends its render region); async modifier with NO
648
+ await → STREAMING (ships pending, resolves on the client) — ADR-0042 D6. Routed
649
+ through `trackedComputed(thunk, streaming)` (same createAsyncCell for an async thunk)
650
+ to carry the flag, matching the template-injected path. */
632
651
  warnPostAwaitReads(wrapped, signalNames)
633
- statements.push(constDeclaration(name, scopeMethodCall('computed', [wrapped])))
652
+ const streaming = argument === undefined || !isBlockingSeed(argument)
653
+ statements.push(
654
+ constDeclaration(
655
+ name,
656
+ scopeMethodCall('trackedComputed', [
657
+ wrapped,
658
+ streaming ? factory.createTrue() : factory.createFalse(),
659
+ ]),
660
+ ),
661
+ )
634
662
  } else if (isEagerStreamComputed(declaration)) {
635
663
  /* Stream seed → the eager `trackedComputed`, which probes the seed and auto-tracks a
636
664
  stream (`AsyncComputed`) or falls back to a lazy computed; read via `$$readCell`.
@@ -77,6 +77,10 @@ export function generateBuild(
77
77
  isLayout = false,
78
78
  /* `linked` / async `computed` names, lowered to `$$readCell(name)` in template exprs. */
79
79
  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(),
80
84
  ): string {
81
85
  const nextVar = makeVarNamer()
82
86
  /* A per-body source-order ordinal for each `<Child/>` mount site, so two same-type siblings
@@ -121,7 +125,7 @@ export function generateBuild(
121
125
  withShadow,
122
126
  bindRead,
123
127
  bindWrite,
124
- } = lowerContext(stateNames, derivedNames, computedNames, cellReadNames)
128
+ } = lowerContext(stateNames, derivedNames, computedNames, cellReadNames, blockingCellNames)
125
129
 
126
130
  /* Maps a plan `Binding`'s classification to the client `ShadowKind`: a `reactive` value
127
131
  derefs as a `.value` cell (`derived`), a `plain` value as the bare local (`plain`). The
@@ -0,0 +1,28 @@
1
+ import ts from 'typescript'
2
+ import { isFunctionScopeBoundary } from './isFunctionScopeBoundary.ts'
3
+
4
+ /*
5
+ True when `node` contains an `await` at its OWN top level — not nested inside any function scope
6
+ (`isFunctionScopeBoundary`: function/arrow/method/accessor/constructor). The SINGLE seed classifier
7
+ both the build lowering (`desugarSignals`: `await` present → a BLOCKING cell that suspends its render
8
+ region) and the type shadow (`compileShadow`: `$$cellValue` `T` vs `$$cellValuePending` `T | undefined`)
9
+ share, so the runtime blocking flag and the shadow type can never disagree on whether a seed is
10
+ `await`-marked (ADR-0042 D5's "one predicate"). An `await` in an inner callback / method
11
+ (`items.map(async (x) => await f(x))`, `{ async m() { await x } }`) does NOT count — only a
12
+ top-level `await` marks the seed itself.
13
+ */
14
+ export function hasTopLevelAwait(node: ts.Node): boolean {
15
+ let found = false
16
+ const visit = (child: ts.Node): void => {
17
+ if (found || isFunctionScopeBoundary(child)) {
18
+ return
19
+ }
20
+ if (ts.isAwaitExpression(child)) {
21
+ found = true
22
+ return
23
+ }
24
+ ts.forEachChild(child, visit)
25
+ }
26
+ visit(node)
27
+ return found
28
+ }
@@ -0,0 +1,20 @@
1
+ import ts from 'typescript'
2
+
3
+ /*
4
+ A node that opens its own (possibly async) function scope — awaits inside it are that scope's
5
+ concern, not the enclosing seed's. Both the top-level-await walk (`hasTopLevelAwait`) and the
6
+ top-level-await diagnostics stop descending here, so a seed classifies the same way a value read
7
+ inside such a scope would behave. One predicate shared by the build lowering and the type shadow so
8
+ they can never disagree on a boundary (ADR-0042 D5's "one predicate").
9
+ */
10
+ export function isFunctionScopeBoundary(node: ts.Node): boolean {
11
+ return (
12
+ ts.isFunctionDeclaration(node) ||
13
+ ts.isFunctionExpression(node) ||
14
+ ts.isArrowFunction(node) ||
15
+ ts.isMethodDeclaration(node) ||
16
+ ts.isGetAccessorDeclaration(node) ||
17
+ ts.isSetAccessorDeclaration(node) ||
18
+ ts.isConstructorDeclaration(node)
19
+ )
20
+ }
@@ -101,8 +101,8 @@ export function liftAsyncSubExpressions(
101
101
  /* A nested function is its own evaluation scope — an async (sub)expression inside a callback
102
102
  (`items.map(x => fetchName(x))`, `items.map(async x => await load(x))`) must NOT be hoisted
103
103
  to a top-level cell: its parameters/closure vars would become free identifiers, and it
104
- would run once instead of per row. Stop at the boundary, mirroring
105
- `desugarSignals.hasTopLevelAwait`. */
104
+ would run once instead of per row. Stop at the boundary, mirroring the shared
105
+ `hasTopLevelAwait` walk. */
106
106
  if (
107
107
  ts.isArrowFunction(node) ||
108
108
  ts.isFunctionExpression(node) ||
@@ -27,6 +27,9 @@ export function lowerContext(
27
27
  computedNames: ReadonlySet<string> = new Set(),
28
28
  /* `linked` / async `computed` names, read through `$$readCell(name)`. */
29
29
  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(),
30
33
  ) {
31
34
  /* The typed branch-local shadow stack: one auto-popping value owning both kinds.
32
35
  `derived` names deref to `.value` like a `computed` (block value params the client
@@ -51,6 +54,7 @@ export function lowerContext(
51
54
  new Set(scope.names('derived')),
52
55
  new Set(scope.names('plain')),
53
56
  cellReadNames,
57
+ blockingCellNames,
54
58
  ),
55
59
  docAccessTransformer('$$model'),
56
60
  ])
@@ -1,5 +1,6 @@
1
1
  import ts from 'typescript'
2
2
  import { escapeKey } from '../runtime/escapeKey.ts'
3
+ import { COMPOUND_ASSIGNMENT_OPERATORS } from './COMPOUND_ASSIGNMENT_OPERATORS.ts'
3
4
 
4
5
  /*
5
6
  The linchpin compiler pass. Rewrites idiomatic data access on a reactive document
@@ -30,9 +31,6 @@ used as an index lowers too. Exposed as `docAccessTransformer` (a
30
31
  /* A path segment is either a literal key or a runtime expression (a dynamic index). */
31
32
  type Segment = { kind: 'literal'; value: string } | { kind: 'expression'; node: ts.Expression }
32
33
 
33
- /* Maps a compound-assignment operator to its plain binary counterpart. Logical
34
- assignments (`||=`/`&&=`/`??=`) lower to an unconditional replace of the
35
- combined value — consistent with how `+=` lowers (the patch always writes). */
36
34
  /* Methods that mutate a doc-rooted container in place — Array, Map and Set (the three
37
35
  mutable containers the doc codec serializes). Called on a doc value these can't lower to
38
36
  a bare `readCall` (which would mutate the live tree by reference and never emit a patch —
@@ -57,16 +55,6 @@ const MUTATING_CONTAINER_METHODS = new Set([
57
55
  'clear',
58
56
  ])
59
57
 
60
- const COMPOUND_OPERATORS = new Map<ts.SyntaxKind, ts.BinaryOperator>([
61
- [ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.PlusToken],
62
- [ts.SyntaxKind.MinusEqualsToken, ts.SyntaxKind.MinusToken],
63
- [ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.AsteriskToken],
64
- [ts.SyntaxKind.SlashEqualsToken, ts.SyntaxKind.SlashToken],
65
- [ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.BarBarToken],
66
- [ts.SyntaxKind.AmpersandAmpersandEqualsToken, ts.SyntaxKind.AmpersandAmpersandToken],
67
- [ts.SyntaxKind.QuestionQuestionEqualsToken, ts.SyntaxKind.QuestionQuestionToken],
68
- ])
69
-
70
58
  export function docAccessTransformer(docName: string): ts.TransformerFactory<ts.SourceFile> {
71
59
  return (context) => (root) => {
72
60
  /* Collects the path of an access chain rooted at `docName`, visiting any
@@ -114,7 +102,7 @@ export function docAccessTransformer(docName: string): ts.TransformerFactory<ts.
114
102
  ts.visitNode(node.right, visit) as ts.Expression,
115
103
  ])
116
104
  }
117
- const binary = COMPOUND_OPERATORS.get(operator)
105
+ const binary = COMPOUND_ASSIGNMENT_OPERATORS.get(operator)
118
106
  if (binary) {
119
107
  const next = ts.factory.createBinaryExpression(
120
108
  docCall(docName, 'read', [buildPath(segments)]),
@@ -91,10 +91,19 @@ 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>
94
96
  droppedReactiveImports: Set<string>
95
97
  } {
96
98
  const source = ts.createSourceFile('component.ts', scriptBody, ts.ScriptTarget.Latest, true)
97
- const { transformer, stateNames, derivedNames, computedNames, cellReadNames } = desugarSignals(
99
+ const {
100
+ transformer,
101
+ stateNames,
102
+ derivedNames,
103
+ computedNames,
104
+ cellReadNames,
105
+ blockingCellNames: allBlockingNames,
106
+ } = desugarSignals(
98
107
  source,
99
108
  injectedCellNames,
100
109
  blockingCellNames,
@@ -117,9 +126,14 @@ export function lowerScript(
117
126
  ...computedNames,
118
127
  ...cellReadNames,
119
128
  ])
129
+ /* `wrapReactionCellSources` only folds `watch(cell, …)` calls, so it's a pure
130
+ identity walk when the script imports no `watch` (the common case) — skip the
131
+ extra full-tree pass entirely then. */
132
+ const reactionTransforms =
133
+ watchLocalNames.size > 0 ? [wrapReactionCellSources(cellNames, watchLocalNames)] : []
120
134
  const result = ts.transform(source, [
121
135
  transformer,
122
- wrapReactionCellSources(cellNames, watchLocalNames),
136
+ ...reactionTransforms,
123
137
  signalRefsTransformer(
124
138
  stateNames,
125
139
  derivedNames,
@@ -180,6 +194,7 @@ export function lowerScript(
180
194
  derivedNames,
181
195
  computedNames,
182
196
  cellReadNames,
197
+ blockingCellNames: allBlockingNames,
183
198
  droppedReactiveImports,
184
199
  }
185
200
  }