@abide/abide 0.52.0 → 0.54.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 (80) hide show
  1. package/AGENTS.md +25 -12
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +8 -6
  4. package/package.json +3 -2
  5. package/src/checkAbide.ts +1 -1
  6. package/src/lib/server/runtime/amendBroadcaster.ts +53 -0
  7. package/src/lib/server/runtime/createUiPageRenderer.ts +26 -0
  8. package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
  9. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  10. package/src/lib/server/sockets/amendFamilyEntry.ts +39 -0
  11. package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
  12. package/src/lib/server/sockets/defineSocket.ts +11 -1
  13. package/src/lib/shared/AMEND_TOPIC_PREFIX.ts +9 -0
  14. package/src/lib/shared/SOCKET_SEED.ts +13 -0
  15. package/src/lib/shared/amend.ts +48 -0
  16. package/src/lib/shared/amendBroadcastSlot.ts +19 -0
  17. package/src/lib/shared/applyAmendLocally.ts +20 -0
  18. package/src/lib/shared/attachRpcSelectorMethods.ts +5 -5
  19. package/src/lib/shared/buildSocketOverChannel.ts +9 -2
  20. package/src/lib/shared/cache.ts +35 -10
  21. package/src/lib/shared/cacheReaderSocketSlot.ts +11 -0
  22. package/src/lib/shared/createCacheStore.ts +8 -0
  23. package/src/lib/shared/peek.ts +1 -1
  24. package/src/lib/shared/socketTailsSlot.ts +13 -0
  25. package/src/lib/shared/types/AmendApply.ts +15 -0
  26. package/src/lib/shared/types/CacheReaderHook.ts +11 -0
  27. package/src/lib/shared/types/RemoteFunction.ts +30 -19
  28. package/src/lib/shared/types/SocketTails.ts +13 -0
  29. package/src/lib/shared/types/SsrPayload.ts +6 -0
  30. package/src/lib/test/createTestApp.ts +6 -0
  31. package/src/lib/ui/amendReaderHook.ts +125 -0
  32. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  33. package/src/lib/ui/compile/analyzeComponent.ts +2 -0
  34. package/src/lib/ui/compile/cachedShadowProgram.ts +38 -0
  35. package/src/lib/ui/compile/compileComponent.ts +10 -1
  36. package/src/lib/ui/compile/compileSSR.ts +12 -0
  37. package/src/lib/ui/compile/compileShadow.ts +37 -34
  38. package/src/lib/ui/compile/desugarSignals.ts +127 -34
  39. package/src/lib/ui/compile/generateBuild.ts +5 -1
  40. package/src/lib/ui/compile/hasTopLevelAwait.ts +28 -0
  41. package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
  42. package/src/lib/ui/compile/isFunctionScopeBoundary.ts +20 -0
  43. package/src/lib/ui/compile/liftAsyncSubExpressions.ts +2 -2
  44. package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
  45. package/src/lib/ui/compile/lowerContext.ts +4 -0
  46. package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
  47. package/src/lib/ui/compile/lowerScript.ts +11 -1
  48. package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
  49. package/src/lib/ui/compile/renameSignalRefs.ts +36 -24
  50. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +9 -14
  51. package/src/lib/ui/compile/types/AnalyzedComponent.ts +4 -0
  52. package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
  53. package/src/lib/ui/dom/appendText.ts +29 -8
  54. package/src/lib/ui/dom/appendTextAt.ts +21 -4
  55. package/src/lib/ui/dom/attr.ts +16 -1
  56. package/src/lib/ui/dom/awaitBlock.ts +34 -13
  57. package/src/lib/ui/dom/each.ts +10 -2
  58. package/src/lib/ui/dom/eachAsync.ts +9 -1
  59. package/src/lib/ui/dom/readCellBlocking.ts +29 -0
  60. package/src/lib/ui/dom/readTextOrSuspend.ts +21 -0
  61. package/src/lib/ui/dom/spreadAttrs.ts +47 -1
  62. package/src/lib/ui/dom/switchBlock.ts +19 -2
  63. package/src/lib/ui/dom/tryBlock.ts +10 -0
  64. package/src/lib/ui/dom/when.ts +19 -1
  65. package/src/lib/ui/dom/withSuspense.ts +22 -0
  66. package/src/lib/ui/router.ts +8 -0
  67. package/src/lib/ui/runtime/SuspenseSignal.ts +24 -0
  68. package/src/lib/ui/runtime/createDoc.ts +5 -1
  69. package/src/lib/ui/runtime/flushEffects.ts +18 -10
  70. package/src/lib/ui/runtime/restoreWarmSeeds.ts +20 -0
  71. package/src/lib/ui/runtime/types/Cell.ts +4 -2
  72. package/src/lib/ui/runtime/types/Doc.ts +3 -1
  73. package/src/lib/ui/runtime/warmSeedBackup.ts +10 -0
  74. package/src/lib/ui/socketProxy.ts +17 -1
  75. package/src/lib/ui/startClient.ts +30 -0
  76. package/src/lib/ui/types/Scope.ts +1 -1
  77. package/src/lib/ui/watch.ts +25 -7
  78. package/src/serverEntry.ts +15 -0
  79. package/src/lib/shared/patch.ts +0 -41
  80. package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +0 -19
@@ -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'
@@ -102,6 +104,15 @@ export function compileShadow(
102
104
  builder.raw(
103
105
  'declare function $$cellValue<R>(seed: () => R): R extends AsyncIterable<infer F> ? F : Awaited<R>\n',
104
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
+ )
105
116
  /* The peek helpers the async-interpolation wrap targets (ADR-0032): `$$peek` unwraps a promise
106
117
  sub-expression to its resolved value (`undefined` while pending) and `$$peekStream` reads an
107
118
  async iterable's latest frame, so `getFoo()?.name`/`{#if getFoo()}` type-check against the
@@ -712,7 +723,13 @@ function scopeLineFor(
712
723
  const fnIsThunk = ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)
713
724
  const wrapPrefix = fnIsThunk ? '' : seedIsAsync(fn) ? 'async () => (' : '() => ('
714
725
  const wrapSuffix = fnIsThunk ? '' : ')'
715
- const prefix = `${keyword} ${name}${annotation} = $$cellValue(${wrapPrefix}`
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}`
716
733
  return withCalleeRef({
717
734
  text: `${prefix}${verbatim(fn)}${wrapSuffix});`,
718
735
  segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
@@ -732,40 +749,20 @@ function seedIsAsync(seed: ts.Expression): boolean {
732
749
  ?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) ?? false
733
750
  )
734
751
  }
735
- return expressionHasTopLevelAwait(seed)
752
+ return hasTopLevelAwait(seed)
736
753
  }
737
754
 
738
- /* True when `node` contains an `await` at its own top level not nested inside a function. The
739
- seed-classification counterpart to `collectTopLevelAwaitDiagnostics`' walk; kept local so the
740
- shadow doesn't reach into `desugarSignals`' private `hasTopLevelAwait`. */
741
- function expressionHasTopLevelAwait(node: ts.Node): boolean {
742
- let found = false
743
- const visit = (child: ts.Node): void => {
744
- if (found || isFunctionScopeBoundary(child)) {
745
- return
746
- }
747
- if (ts.isAwaitExpression(child)) {
748
- found = true
749
- return
750
- }
751
- ts.forEachChild(child, visit)
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)
752
764
  }
753
- visit(node)
754
- return found
755
- }
756
-
757
- /* A node that opens its own (possibly async) function scope — awaits inside it are the author's
758
- concern, so both the top-level-await walk and the seed classifier stop descending here. */
759
- function isFunctionScopeBoundary(node: ts.Node): boolean {
760
- return (
761
- ts.isFunctionDeclaration(node) ||
762
- ts.isFunctionExpression(node) ||
763
- ts.isArrowFunction(node) ||
764
- ts.isMethodDeclaration(node) ||
765
- ts.isGetAccessorDeclaration(node) ||
766
- ts.isSetAccessorDeclaration(node) ||
767
- ts.isConstructorDeclaration(node)
768
- )
765
+ return hasTopLevelAwait(seed)
769
766
  }
770
767
 
771
768
  /* Whether any ELEMENT in the tree carries an `attach` — gates emitting the DOM-typed
@@ -1047,7 +1044,11 @@ function emitNode(node: TemplateNode, builder: Builder): void {
1047
1044
  } else if (branch.branch === 'catch' && branch.as !== undefined) {
1048
1045
  builder.raw('const ')
1049
1046
  builder.mapped(branch.as, branch.asLoc)
1050
- 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')
1051
1052
  }
1052
1053
  emitNodes(branch.children, builder)
1053
1054
  builder.raw('}\n')
@@ -1093,7 +1094,9 @@ function emitNode(node: TemplateNode, builder: Builder): void {
1093
1094
  handlers consume their own branches inline and never route through this case. */
1094
1095
  builder.raw('{\n')
1095
1096
  if (node.as !== undefined) {
1096
- 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`)
1097
1100
  }
1098
1101
  emitNodes(node.children, builder)
1099
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
@@ -63,6 +36,24 @@ function wrapSeed(argument: ts.Expression): ts.Expression {
63
36
  )
64
37
  }
65
38
 
39
+ /* Wraps a seed expression as an async unwrapping thunk `async () => await (arg)` — for a
40
+ type-directed PROMISE seed (ADR-0023/0043) the author wrote WITHOUT `await`
41
+ (`state.computed(getFoo())`). The `async`+`await` makes createAsyncCell unwrap the resolved
42
+ value; a plain `() => getFoo()` thunk would fall to trackedComputed's lazy opaque path (its
43
+ probe self-identifies only a stream, never a promise). The parens keep a comma/ternary seed
44
+ a single await operand. Distinct from `wrapSeed`, which only makes the arrow async when the
45
+ arg already carries a top-level `await`. */
46
+ function wrapAwaitSeed(argument: ts.Expression): ts.Expression {
47
+ return factory.createArrowFunction(
48
+ [factory.createModifier(ts.SyntaxKind.AsyncKeyword)],
49
+ undefined,
50
+ [],
51
+ undefined,
52
+ factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
53
+ factory.createAwaitExpression(factory.createParenthesizedExpression(argument)),
54
+ )
55
+ }
56
+
66
57
  /* True for an async seed thunk — an arrow/function carrying the `async` modifier, whether
67
58
  from `wrapSeed`'s `await` lowering or a passthrough `async () => …` literal the author
68
59
  wrote. The routing signal: an async seed becomes an async cell (read via `$$readCell`),
@@ -106,6 +97,23 @@ function isBareCallComputed(declaration: ts.VariableDeclaration): boolean {
106
97
  return ts.isCallExpression(argument) || ts.isIdentifier(argument)
107
98
  }
108
99
 
100
+ /* True when a `computed`/`linked` seed is a BLOCKING async cell (ADR-0042 D6): its wrapped seed
101
+ is an async thunk whose BODY has a top-level `await` — `computed(await X)` (wrapSeed makes it
102
+ `async () => await X`) or `computed(async () => await X)`. An async thunk with NO await
103
+ (`computed(async () => getFoo())`) is STREAMING — `await` is the sole blocking marker. A
104
+ sync/stream seed is not blocking. The read of a blocking cell suspends its render region until
105
+ the value resolves; a streaming cell reads `undefined`-while-pending. Reuses `hasTopLevelAwait`
106
+ applied to the thunk BODY (not the arrow), so the walk descends exactly one function level. */
107
+ function isBlockingSeed(argument: ts.Expression): boolean {
108
+ const wrapped = wrapSeed(argument)
109
+ if (!isAsyncSeed(wrapped)) {
110
+ return false
111
+ }
112
+ const body =
113
+ ts.isArrowFunction(wrapped) || ts.isFunctionExpression(wrapped) ? wrapped.body : wrapped
114
+ return hasTopLevelAwait(body)
115
+ }
116
+
109
117
  /* Emits a compile WARNING (best-effort, never fatal) when a signal read appears AFTER the
110
118
  first top-level `await` in an async seed — a value read there is no longer tracked, so the
111
119
  cell won't reseed when it changes. Detection only: reads before the await (or with no
@@ -223,6 +231,10 @@ export function desugarSignals(
223
231
  derivedNames: Set<string>
224
232
  computedNames: Set<string>
225
233
  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>
226
238
  } {
227
239
  assertNoRemovedReaders(source)
228
240
  /* The full set of names written anywhere (this script + the template). A prop in this set
@@ -241,6 +253,10 @@ export function desugarSignals(
241
253
  One read shape covers both — `$$readCell` peeks an async cell and reads `.value` off a
242
254
  sync one — so `linked(getStream())` auto-tracks with no read-site branching. */
243
255
  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>()
244
260
  /* A `props()` destructure must be lowered even when it declares no reactive binding
245
261
  (a rest-only `const { ...rest } = props()`), so track its presence on its own. */
246
262
  let hasPropsDestructure = false
@@ -277,6 +293,21 @@ export function desugarSignals(
277
293
  }
278
294
  return isBareCallComputed(declaration)
279
295
  }
296
+ /* A type-directed PROMISE seed (ADR-0023/0043): a no-`await` `state.computed(getFoo())` whose
297
+ seed's checker type resolves to a promise. Routed to an eager STREAMING async cell that
298
+ unwraps the resolved value and reactively re-resolves when its tracked deps change — the
299
+ script-level twin of a bare async interpolation (ADR-0032). Only fires with a warm classifier
300
+ ('promise'); fail-open (no program) leaves it to `isEagerStreamComputed`'s bare-call probe,
301
+ and the `await`-marker seed is excluded (its wrapped thunk is already async). Kept separate
302
+ from `isEagerStreamComputed` so `computedStatements` can wrap it as `async () => await (seed)`
303
+ (a stream seed stays a bare probe thunk). */
304
+ const isPromiseComputed: EagerStreamPredicate = (declaration) => {
305
+ const argument = seedArgument(declaration)
306
+ if (argument === undefined || isAsyncSeed(wrapSeed(argument))) {
307
+ return false
308
+ }
309
+ return seedKind(argument) === 'promise'
310
+ }
280
311
  for (const statement of source.statements) {
281
312
  if (!ts.isVariableStatement(statement)) {
282
313
  continue
@@ -323,8 +354,19 @@ export function desugarSignals(
323
354
  `name()` (a sync/promise seed), or an eager cell read via `$$readCell(name)`
324
355
  when the wrapped seed is async (an `await`-lowered / passthrough-`async` thunk)
325
356
  or its seed type resolves to a stream (`isEagerStreamComputed`, ADR-0023). */
326
- if (isAsyncComputed(declaration) || isEagerStreamComputed(declaration)) {
357
+ if (
358
+ isAsyncComputed(declaration) ||
359
+ isEagerStreamComputed(declaration) ||
360
+ isPromiseComputed(declaration)
361
+ ) {
327
362
  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
+ }
328
370
  } else {
329
371
  computedNames.add(declaration.name.text)
330
372
  }
@@ -333,6 +375,11 @@ export function desugarSignals(
333
375
  is synchronous, an `AsyncState` when it tracks a promise/stream — one read
334
376
  shape auto-tracks whichever source the runtime primitive resolved to. */
335
377
  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
+ }
336
383
  } else if (callee === 'state') {
337
384
  /* `state(initial, transform)` → a `.value` cell (its write-coercion transform
338
385
  forces a local store); referenced as `name.value`, unchanged. */
@@ -381,13 +428,21 @@ export function desugarSignals(
381
428
  blockingCellNames,
382
429
  writtenNames,
383
430
  isEagerStreamComputed,
431
+ isPromiseComputed,
384
432
  ),
385
433
  )
386
434
  }
387
435
  return factory.updateSourceFile(root, statements)
388
436
  }
389
437
 
390
- return { transformer, stateNames, derivedNames, computedNames, cellReadNames }
438
+ return {
439
+ transformer,
440
+ stateNames,
441
+ derivedNames,
442
+ computedNames,
443
+ cellReadNames,
444
+ blockingCellNames: new Set([...blockingCellNames, ...scriptBlockingNames]),
445
+ }
391
446
  }
392
447
 
393
448
  /* The lowered form of a top-level statement: state slots → `model.x = init`
@@ -402,12 +457,19 @@ function loweredStatement(
402
457
  blockingCellNames: ReadonlySet<string>,
403
458
  writtenNames: ReadonlySet<string>,
404
459
  isEagerStreamComputed: EagerStreamPredicate,
460
+ isPromiseComputed: EagerStreamPredicate,
405
461
  ): ts.Statement[] {
406
462
  rejectMixedDeclaration(statement, bindings)
407
463
  return (
408
464
  injectedComputedStatements(statement, injectedCellNames, blockingCellNames) ??
409
465
  stateAssignmentStatements(statement, bindings) ??
410
- computedStatements(statement, bindings, signalNames, isEagerStreamComputed) ??
466
+ computedStatements(
467
+ statement,
468
+ bindings,
469
+ signalNames,
470
+ isEagerStreamComputed,
471
+ isPromiseComputed,
472
+ ) ??
411
473
  propsStatements(statement, bindings, writtenNames) ??
412
474
  cellStatements(statement, bindings, signalNames) ?? [statement]
413
475
  )
@@ -598,6 +660,7 @@ function computedStatements(
598
660
  bindings: ReactiveImportBindings,
599
661
  signalNames: ReadonlySet<string>,
600
662
  isEagerStreamComputed: EagerStreamPredicate,
663
+ isPromiseComputed: EagerStreamPredicate,
601
664
  ): ts.Statement[] | undefined {
602
665
  if (!ts.isVariableStatement(statement)) {
603
666
  return undefined
@@ -627,10 +690,40 @@ function computedStatements(
627
690
  )
628
691
  : wrapSeed(argument)
629
692
  if (isAsyncSeed(wrapped)) {
630
- /* Async seed → the eager `computed` primitive (an `AsyncComputed` cell, read via
631
- `$$readCell`); the runtime unwraps the promise. */
693
+ /* Async seed → the eager async cell (`AsyncComputed`, read via `$$readCell`), the
694
+ runtime unwraps the promise. `await` in the thunk body → BLOCKING (joins the SSR
695
+ barrier, resolves inline, client suspends its render region); async modifier with NO
696
+ await → STREAMING (ships pending, resolves on the client) — ADR-0042 D6. Routed
697
+ through `trackedComputed(thunk, streaming)` (same createAsyncCell for an async thunk)
698
+ to carry the flag, matching the template-injected path. */
632
699
  warnPostAwaitReads(wrapped, signalNames)
633
- statements.push(constDeclaration(name, scopeMethodCall('computed', [wrapped])))
700
+ const streaming = argument === undefined || !isBlockingSeed(argument)
701
+ statements.push(
702
+ constDeclaration(
703
+ name,
704
+ scopeMethodCall('trackedComputed', [
705
+ wrapped,
706
+ streaming ? factory.createTrue() : factory.createFalse(),
707
+ ]),
708
+ ),
709
+ )
710
+ } else if (argument !== undefined && isPromiseComputed(declaration)) {
711
+ /* Type-directed PROMISE seed (ADR-0023/0043): a bare `state.computed(getFoo())` whose
712
+ seed resolves to a promise. Wrap it as `async () => await (seed)` so createAsyncCell
713
+ unwraps the resolved value — a plain `() => getFoo()` thunk would fall to
714
+ trackedComputed's lazy opaque path (its probe self-identifies only a stream). Passed
715
+ `streaming: true` (the `true` arg): no author `await` → it does NOT join the SSR
716
+ blocking barrier — the shell ships pending and the client resolves + reactively
717
+ re-resolves, the ADR-0032 no-await tier. `await getFoo()` stays the BLOCKING form. */
718
+ statements.push(
719
+ constDeclaration(
720
+ name,
721
+ scopeMethodCall('trackedComputed', [
722
+ wrapAwaitSeed(argument),
723
+ factory.createTrue(),
724
+ ]),
725
+ ),
726
+ )
634
727
  } else if (isEagerStreamComputed(declaration)) {
635
728
  /* Stream seed → the eager `trackedComputed`, which probes the seed and auto-tracks a
636
729
  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
+ }
@@ -1,4 +1,5 @@
1
- import { createShadowProgram, type ShadowProgram } from './createShadowProgram.ts'
1
+ import { cachedShadowProgram } from './cachedShadowProgram.ts'
2
+ import type { ShadowProgram } from './createShadowProgram.ts'
2
3
  import { shadowInterpolationClassifier } from './shadowInterpolationClassifier.ts'
3
4
  import type { InterpolationClassifier } from './types/InterpolationClassifier.ts'
4
5
 
@@ -12,24 +13,18 @@ The incremental `createShadowLanguageService` does not expose the checker, shado
12
13
  `ts.SourceFile`, or per-file mappings publicly, so v1 uses the one-shot
13
14
  `createShadowProgram` (reused per root) rather than the LS overlay path.
14
15
 
15
- FAIL-OPEN throughout: if the program can't be built, or the file has no shadow, the
16
- classifier is absent (→ today's plain-value binding); and the returned closure wraps
17
- its whole body so ANY throw (resolution failure, missing node, checker hiccup) returns
18
- `'sync'`. A type-resolution problem degrades to today's behavior, never breaks the build.
16
+ FAIL-OPEN throughout: if the program can't be built (warned once per root by
17
+ `cachedShadowProgram`), or the file has no shadow, the classifier is absent (→ today's
18
+ plain-value binding); and the returned closure wraps its whole body so ANY throw
19
+ (resolution failure, missing node, checker hiccup) returns `'sync'`. A type-resolution
20
+ problem degrades to today's behavior, never breaks the build.
19
21
  */
20
22
  export function interpolationClassifierForRoot(
21
23
  cache: Map<string, ShadowProgram | undefined>,
22
24
  root: string,
23
25
  abidePath: string,
24
26
  ): InterpolationClassifier | undefined {
25
- if (!cache.has(root)) {
26
- try {
27
- cache.set(root, createShadowProgram(root))
28
- } catch {
29
- cache.set(root, undefined)
30
- }
31
- }
32
- const shadowProgram = cache.get(root)
27
+ const shadowProgram = cachedShadowProgram(cache, root)
33
28
  if (shadowProgram === undefined) {
34
29
  return undefined
35
30
  }
@@ -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) ||
@@ -0,0 +1,49 @@
1
+ import ts from 'typescript'
2
+
3
+ /* Arithmetic compound assignments → their plain binary counterpart, lowered to a
4
+ read-combine-write (`x += y` → `write(read + y)`). */
5
+ const ARITHMETIC_COMPOUND_OPERATORS = new Map<ts.SyntaxKind, ts.BinaryOperator>([
6
+ [ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.PlusToken],
7
+ [ts.SyntaxKind.MinusEqualsToken, ts.SyntaxKind.MinusToken],
8
+ [ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.AsteriskToken],
9
+ [ts.SyntaxKind.SlashEqualsToken, ts.SyntaxKind.SlashToken],
10
+ ])
11
+
12
+ /* Logical compound assignments SHORT-CIRCUIT: the write fires only when the guard
13
+ passes (`x ??= v` is `x ?? (x = v)`), so they lower to `read <op> write(right)`, NOT
14
+ an unconditional write — matching JS, so a non-nullish/truthy/falsy guard does no
15
+ needless patch or cell reseed. */
16
+ const LOGICAL_ASSIGNMENT_OPERATORS = new Map<ts.SyntaxKind, ts.BinaryOperator>([
17
+ [ts.SyntaxKind.QuestionQuestionEqualsToken, ts.SyntaxKind.QuestionQuestionToken],
18
+ [ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.BarBarToken],
19
+ [ts.SyntaxKind.AmpersandAmpersandEqualsToken, ts.SyntaxKind.AmpersandAmpersandToken],
20
+ ])
21
+
22
+ /*
23
+ Lowers an assignment (`=`, arithmetic `+=`, or logical `??=`/`||=`/`&&=`) to a reactive
24
+ write, shared by the `$$model` doc path (lowerDocAccess) and the `$$writeCell` linked-cell
25
+ path (renameSignalRefs) so the two lowerings can't drift. `makeRead` builds the target's
26
+ read expression (only called for compound operators); `makeWrite` wraps a value in the
27
+ write call. Returns undefined for an operator this doesn't handle so the caller can leave
28
+ the node untouched. The write must evaluate to the written value (see `Cell.set` /
29
+ `Doc.replace`) for the logical short-circuit to yield the right result.
30
+ */
31
+ export function lowerCompoundAssignment(
32
+ operator: ts.SyntaxKind,
33
+ makeRead: () => ts.Expression,
34
+ right: ts.Expression,
35
+ makeWrite: (value: ts.Expression) => ts.Expression,
36
+ ): ts.Expression | undefined {
37
+ if (operator === ts.SyntaxKind.EqualsToken) {
38
+ return makeWrite(right)
39
+ }
40
+ const arithmetic = ARITHMETIC_COMPOUND_OPERATORS.get(operator)
41
+ if (arithmetic !== undefined) {
42
+ return makeWrite(ts.factory.createBinaryExpression(makeRead(), arithmetic, right))
43
+ }
44
+ const logical = LOGICAL_ASSIGNMENT_OPERATORS.get(operator)
45
+ if (logical !== undefined) {
46
+ return ts.factory.createBinaryExpression(makeRead(), logical, makeWrite(right))
47
+ }
48
+ return undefined
49
+ }
@@ -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,6 +1,7 @@
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
+ import { lowerCompoundAssignment } from './lowerCompoundAssignment.ts'
4
+ import { lowerUpdateExpression } from './lowerUpdateExpression.ts'
4
5
 
5
6
  /*
6
7
  The linchpin compiler pass. Rewrites idiomatic data access on a reactive document
@@ -91,33 +92,24 @@ export function docAccessTransformer(docName: string): ts.TransformerFactory<ts.
91
92
  }
92
93
 
93
94
  function visit(node: ts.Node): ts.Node {
94
- /* Assignment (plain or compound) to a doc path → replace patch. */
95
+ /* Assignment (plain, compound, or logical) to a doc path → replace patch. */
95
96
  if (ts.isBinaryExpression(node)) {
96
97
  const segments = pathSegments(node.left as ts.Expression)
97
98
  if (segments) {
98
- const operator = node.operatorToken.kind
99
- if (operator === ts.SyntaxKind.EqualsToken) {
100
- return docCall(docName, 'replace', [
101
- buildPath(segments),
102
- ts.visitNode(node.right, visit) as ts.Expression,
103
- ])
104
- }
105
- const binary = COMPOUND_ASSIGNMENT_OPERATORS.get(operator)
106
- if (binary) {
107
- const next = ts.factory.createBinaryExpression(
108
- docCall(docName, 'read', [buildPath(segments)]),
109
- binary,
110
- ts.visitNode(node.right, visit) as ts.Expression,
111
- )
112
- return docCall(docName, 'replace', [buildPath(segments), next])
99
+ const lowered = lowerCompoundAssignment(
100
+ node.operatorToken.kind,
101
+ () => docCall(docName, 'read', [buildPath(segments)]),
102
+ ts.visitNode(node.right, visit) as ts.Expression,
103
+ (value) => docCall(docName, 'replace', [buildPath(segments), value]),
104
+ )
105
+ if (lowered !== undefined) {
106
+ return lowered
113
107
  }
114
108
  }
115
109
  }
116
- /* `path++` / `++path` / `path--` on a doc path → a replace patch (the same
117
- shape as `+= 1`). A bare `++` would otherwise survive onto the lowered read
118
- (`model.read("n")++` / `cell.get()++`) — invalid, since a call result is not
119
- an lvalue. abide handlers are statements, so the postfix/prefix value is
120
- discarded and both lower identically. Only `++`/`--` are handled here; other
110
+ /* `path++` / `++path` / `path--` on a doc path → a replace patch. A bare `++`
111
+ would otherwise survive onto the lowered read (`model.read("n")++`) — invalid,
112
+ since a call result is not an lvalue. Only `++`/`--` are handled here; other
121
113
  unary operators (`!path`, `-path`) fall through so their operand lowers as a
122
114
  normal read. */
123
115
  if (
@@ -127,16 +119,12 @@ export function docAccessTransformer(docName: string): ts.TransformerFactory<ts.
127
119
  ) {
128
120
  const segments = pathSegments(node.operand)
129
121
  if (segments) {
130
- const step =
131
- node.operator === ts.SyntaxKind.PlusPlusToken
132
- ? ts.SyntaxKind.PlusToken
133
- : ts.SyntaxKind.MinusToken
134
- const next = ts.factory.createBinaryExpression(
135
- docCall(docName, 'read', [buildPath(segments)]),
136
- step,
137
- ts.factory.createNumericLiteral(1),
122
+ return lowerUpdateExpression(
123
+ ts.isPostfixUnaryExpression(node),
124
+ node.operator === ts.SyntaxKind.PlusPlusToken,
125
+ () => docCall(docName, 'read', [buildPath(segments)]),
126
+ (value) => docCall(docName, 'replace', [buildPath(segments), value]),
138
127
  )
139
- return docCall(docName, 'replace', [buildPath(segments), next])
140
128
  }
141
129
  }
142
130
  /* doc array `.push(a, b, …)` → one `add` patch per argument at the array's `-`