@abide/abide 0.53.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 (129) hide show
  1. package/AGENTS.md +330 -494
  2. package/CHANGELOG.md +106 -0
  3. package/README.md +166 -146
  4. package/package.json +3 -4
  5. package/src/checkAbide.ts +1 -1
  6. package/src/lib/server/rpc/defineRpc.ts +2 -1
  7. package/src/lib/server/rpc/types/RpcHelper.ts +29 -22
  8. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +3 -2
  9. package/src/lib/server/runtime/amendBroadcaster.ts +53 -0
  10. package/src/lib/server/runtime/createUiPageRenderer.ts +54 -20
  11. package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
  12. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  13. package/src/lib/server/sockets/amendFamilyEntry.ts +39 -0
  14. package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
  15. package/src/lib/server/sockets/defineSocket.ts +11 -1
  16. package/src/lib/shared/AMEND_TOPIC_PREFIX.ts +9 -0
  17. package/src/lib/shared/SEEDS.ts +33 -0
  18. package/src/lib/shared/SOCKET_SEED.ts +12 -0
  19. package/src/lib/shared/amend.ts +48 -0
  20. package/src/lib/shared/amendBroadcastSlot.ts +19 -0
  21. package/src/lib/shared/applyAmendLocally.ts +20 -0
  22. package/src/lib/shared/attachRpcSelectorMethods.ts +5 -5
  23. package/src/lib/shared/buildSocketOverChannel.ts +9 -2
  24. package/src/lib/shared/cache.ts +203 -84
  25. package/src/lib/shared/cacheReaderSocketSlot.ts +11 -0
  26. package/src/lib/shared/createCacheStore.ts +8 -0
  27. package/src/lib/shared/createRemoteFunction.ts +4 -1
  28. package/src/lib/shared/encodeSeedValue.ts +24 -0
  29. package/src/lib/shared/jsonSchemaForType.ts +3 -16
  30. package/src/lib/shared/peek.ts +4 -7
  31. package/src/lib/shared/socketTailsSlot.ts +13 -0
  32. package/src/lib/shared/types/AmendApply.ts +15 -0
  33. package/src/lib/shared/types/CacheReaderHook.ts +11 -0
  34. package/src/lib/shared/types/RemoteFunction.ts +30 -19
  35. package/src/lib/shared/types/SocketTails.ts +13 -0
  36. package/src/lib/shared/types/SsrPayload.ts +6 -0
  37. package/src/lib/test/createTestApp.ts +6 -0
  38. package/src/lib/ui/amendReaderHook.ts +125 -0
  39. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -6
  40. package/src/lib/ui/compile/analyzeComponent.ts +0 -2
  41. package/src/lib/ui/compile/awaitSubjectExpr.ts +23 -0
  42. package/src/lib/ui/compile/cachedShadowProgram.ts +39 -0
  43. package/src/lib/ui/compile/compileComponent.ts +1 -3
  44. package/src/lib/ui/compile/componentOrdinals.ts +39 -0
  45. package/src/lib/ui/compile/desugarSignals.ts +65 -36
  46. package/src/lib/ui/compile/generateBuild.ts +25 -24
  47. package/src/lib/ui/compile/generateSSR.ts +37 -24
  48. package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
  49. package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
  50. package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
  51. package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
  52. package/src/lib/ui/compile/lowerContext.ts +26 -6
  53. package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
  54. package/src/lib/ui/compile/lowerScript.ts +1 -11
  55. package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
  56. package/src/lib/ui/compile/renameSignalRefs.ts +25 -45
  57. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +10 -15
  58. package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
  59. package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
  60. package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
  61. package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
  62. package/src/lib/ui/compile/wrapSeed.ts +28 -0
  63. package/src/lib/ui/computed.ts +16 -0
  64. package/src/lib/ui/createScope.ts +4 -3
  65. package/src/lib/ui/dom/anchorCursor.ts +2 -1
  66. package/src/lib/ui/dom/anchoredBranch.ts +14 -3
  67. package/src/lib/ui/dom/appendStatic.ts +11 -20
  68. package/src/lib/ui/dom/appendText.ts +41 -43
  69. package/src/lib/ui/dom/appendTextAt.ts +10 -57
  70. package/src/lib/ui/dom/awaitBlock.ts +31 -45
  71. package/src/lib/ui/dom/awaitSubject.ts +118 -0
  72. package/src/lib/ui/dom/claimText.ts +38 -0
  73. package/src/lib/ui/dom/cloneStatic.ts +2 -8
  74. package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
  75. package/src/lib/ui/dom/discardBoundary.ts +5 -4
  76. package/src/lib/ui/dom/each.ts +3 -5
  77. package/src/lib/ui/dom/fillBoundary.ts +2 -1
  78. package/src/lib/ui/dom/hydrate.ts +5 -12
  79. package/src/lib/ui/dom/mountChild.ts +73 -5
  80. package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
  81. package/src/lib/ui/dom/openMarker.ts +2 -4
  82. package/src/lib/ui/dom/outlet.ts +4 -3
  83. package/src/lib/ui/dom/readCell.ts +26 -11
  84. package/src/lib/ui/dom/skeleton.ts +2 -7
  85. package/src/lib/ui/dom/switchBlock.ts +2 -1
  86. package/src/lib/ui/dom/tryBlock.ts +27 -39
  87. package/src/lib/ui/dom/when.ts +6 -2
  88. package/src/lib/ui/linked.ts +14 -0
  89. package/src/lib/ui/resumeSeedScript.ts +1 -1
  90. package/src/lib/ui/router.ts +12 -16
  91. package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
  92. package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
  93. package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
  94. package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
  95. package/src/lib/ui/runtime/RENDER.ts +7 -5
  96. package/src/lib/ui/runtime/RESUME.ts +5 -7
  97. package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
  98. package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
  99. package/src/lib/ui/runtime/claimChild.ts +5 -7
  100. package/src/lib/ui/runtime/claimExpected.ts +2 -6
  101. package/src/lib/ui/runtime/claimMarker.ts +13 -0
  102. package/src/lib/ui/runtime/claimRun.ts +22 -0
  103. package/src/lib/ui/runtime/consumeSeed.ts +38 -0
  104. package/src/lib/ui/runtime/createAsyncCell.ts +56 -8
  105. package/src/lib/ui/runtime/createComputedNode.ts +1 -0
  106. package/src/lib/ui/runtime/createDoc.ts +5 -1
  107. package/src/lib/ui/runtime/createEffectNode.ts +1 -0
  108. package/src/lib/ui/runtime/createSignalNode.ts +1 -0
  109. package/src/lib/ui/runtime/parkCursor.ts +10 -0
  110. package/src/lib/ui/runtime/readNode.ts +21 -1
  111. package/src/lib/ui/runtime/renderPath.ts +2 -2
  112. package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
  113. package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
  114. package/src/lib/ui/runtime/runNode.ts +14 -0
  115. package/src/lib/ui/runtime/types/Cell.ts +4 -2
  116. package/src/lib/ui/runtime/types/Doc.ts +3 -1
  117. package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
  118. package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
  119. package/src/lib/ui/settleAsyncCells.ts +30 -3
  120. package/src/lib/ui/socketProxy.ts +17 -1
  121. package/src/lib/ui/startClient.ts +23 -0
  122. package/src/lib/ui/tryEncodeResume.ts +6 -15
  123. package/src/lib/ui/types/Scope.ts +1 -1
  124. package/src/lib/ui/watch.ts +21 -16
  125. package/src/serverEntry.ts +15 -0
  126. package/src/lib/shared/patch.ts +0 -41
  127. package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +0 -19
  128. package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
  129. package/src/lib/ui/dom/readCellBlocking.ts +0 -29
@@ -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 `-`
@@ -91,19 +91,10 @@ export function lowerScript(
91
91
  derivedNames: Set<string>
92
92
  computedNames: Set<string>
93
93
  cellReadNames: Set<string>
94
- /* Template-injected + script-level `await` cells (ADR-0042), read via `$$readCellBlocking`. */
95
- blockingCellNames: Set<string>
96
94
  droppedReactiveImports: Set<string>
97
95
  } {
98
96
  const source = ts.createSourceFile('component.ts', scriptBody, ts.ScriptTarget.Latest, true)
99
- const {
100
- transformer,
101
- stateNames,
102
- derivedNames,
103
- computedNames,
104
- cellReadNames,
105
- blockingCellNames: allBlockingNames,
106
- } = desugarSignals(
97
+ const { transformer, stateNames, derivedNames, computedNames, cellReadNames } = desugarSignals(
107
98
  source,
108
99
  injectedCellNames,
109
100
  blockingCellNames,
@@ -194,7 +185,6 @@ export function lowerScript(
194
185
  derivedNames,
195
186
  computedNames,
196
187
  cellReadNames,
197
- blockingCellNames: allBlockingNames,
198
188
  droppedReactiveImports,
199
189
  }
200
190
  }
@@ -0,0 +1,32 @@
1
+ import ts from 'typescript'
2
+
3
+ /*
4
+ Lowers `x++` / `++x` / `x--` / `--x` on reactive state to a write of the stepped value,
5
+ shared by the `$$model` doc path (lowerDocAccess) and the `$$writeCell` linked-cell path
6
+ (renameSignalRefs). `makeRead`/`makeWrite` mirror `lowerCompoundAssignment`. The bare `++`
7
+ can't survive onto the read form (`$$readCell(x)` / `$$model.read(...)` are calls, not
8
+ lvalues), so it becomes an explicit write of `read ± 1`.
9
+
10
+ Prefix evaluates to the NEW value, which is exactly what the write returns, so
11
+ `write(read ± 1)` is correct as-is. POSTFIX must evaluate to the PREVIOUS value, so its
12
+ result is corrected back by the opposite step (`x++` → `write(read + 1) - 1`) — no temp or
13
+ closure, since the write returns the written value (`Cell.set` / `Doc.replace`). The
14
+ correction is dead when the value is discarded (a statement `x++;`), so it costs nothing
15
+ there.
16
+ */
17
+ export function lowerUpdateExpression(
18
+ isPostfix: boolean,
19
+ isIncrement: boolean,
20
+ makeRead: () => ts.Expression,
21
+ makeWrite: (value: ts.Expression) => ts.Expression,
22
+ ): ts.Expression {
23
+ const step = isIncrement ? ts.SyntaxKind.PlusToken : ts.SyntaxKind.MinusToken
24
+ const write = makeWrite(
25
+ ts.factory.createBinaryExpression(makeRead(), step, ts.factory.createNumericLiteral(1)),
26
+ )
27
+ if (!isPostfix) {
28
+ return write
29
+ }
30
+ const correction = isIncrement ? ts.SyntaxKind.MinusToken : ts.SyntaxKind.PlusToken
31
+ return ts.factory.createBinaryExpression(write, correction, ts.factory.createNumericLiteral(1))
32
+ }
@@ -1,5 +1,6 @@
1
1
  import ts from 'typescript'
2
- import { COMPOUND_ASSIGNMENT_OPERATORS } from './COMPOUND_ASSIGNMENT_OPERATORS.ts'
2
+ import { lowerCompoundAssignment } from './lowerCompoundAssignment.ts'
3
+ import { lowerUpdateExpression } from './lowerUpdateExpression.ts'
3
4
 
4
5
  /*
5
6
  Rewrites references to a component's signal bindings into the document form the
@@ -53,12 +54,10 @@ export function signalRefsTransformer(
53
54
  blockLocalPlain: ReadonlySet<string> = new Set(),
54
55
  /* Component signals read through `$$readCell(name)` — every `linked` and every async
55
56
  `computed` (see `desugarSignals`). A nearer lexical binding of the same name shadows
56
- them like any other signal. */
57
+ them like any other signal. Whether a read PAUSES (blocking `await` cell) or peeks
58
+ (streaming) is decided at runtime by the cell itself (`readCell` consults `cell.blocking`),
59
+ so there is one read form here for every cell and nothing site-specific to thread. */
57
60
  cellReadNames: ReadonlySet<string> = new Set(),
58
- /* The subset of `cellReadNames` that are BLOCKING `await` cells (ADR-0042), read through
59
- `$$readCellBlocking(name)` (suspend-on-pending) instead of `$$readCell`. Non-empty only on
60
- the CLIENT template lowering; the script pass and SSR keep it empty (`$$readCell`). */
61
- blockingCellNames: ReadonlySet<string> = new Set(),
62
61
  ): ts.TransformerFactory<ts.SourceFile> {
63
62
  /* The signal names that a nested binding can shadow — only these matter for
64
63
  scope tracking, so we ignore every other local binding. */
@@ -120,21 +119,19 @@ export function signalRefsTransformer(
120
119
  !shadowed.has(node.left.text)
121
120
  ) {
122
121
  const target = node.left.text
123
- const right = ts.visitNode(node.right, visit) as ts.Expression
124
- if (node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
125
- return writeCellCall(target, right)
126
- }
127
- const binary = COMPOUND_ASSIGNMENT_OPERATORS.get(node.operatorToken.kind)
128
- if (binary !== undefined) {
129
- return writeCellCall(
130
- target,
131
- ts.factory.createBinaryExpression(cellReadCall(target), binary, right),
132
- )
122
+ const lowered = lowerCompoundAssignment(
123
+ node.operatorToken.kind,
124
+ () => cellReadCall(target),
125
+ ts.visitNode(node.right, visit) as ts.Expression,
126
+ (value) => writeCellCall(target, value),
127
+ )
128
+ if (lowered !== undefined) {
129
+ return lowered
133
130
  }
134
131
  }
135
132
  /* `draft++` / `++draft` / `draft--` on a `linked` cell → a `$$writeCell` of the
136
- stepped value, mirroring the `+= 1` shape (the bare `++` would otherwise land
137
- on `$$readCell(draft)`, an invalid lvalue). */
133
+ stepped value (the bare `++` would otherwise land on `$$readCell(draft)`, an
134
+ invalid lvalue). */
138
135
  if (
139
136
  (ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node)) &&
140
137
  (node.operator === ts.SyntaxKind.PlusPlusToken ||
@@ -143,17 +140,12 @@ export function signalRefsTransformer(
143
140
  cellReadNames.has(node.operand.text) &&
144
141
  !shadowed.has(node.operand.text)
145
142
  ) {
146
- const step =
147
- node.operator === ts.SyntaxKind.PlusPlusToken
148
- ? ts.SyntaxKind.PlusToken
149
- : ts.SyntaxKind.MinusToken
150
- return writeCellCall(
151
- node.operand.text,
152
- ts.factory.createBinaryExpression(
153
- cellReadCall(node.operand.text),
154
- step,
155
- ts.factory.createNumericLiteral(1),
156
- ),
143
+ const target = node.operand.text
144
+ return lowerUpdateExpression(
145
+ ts.isPostfixUnaryExpression(node),
146
+ node.operator === ts.SyntaxKind.PlusPlusToken,
147
+ () => cellReadCall(target),
148
+ (value) => writeCellCall(target, value),
157
149
  )
158
150
  }
159
151
  /* Shorthand `{ count }` → `{ count: $$model.count }` / `{ total: total.value }`,
@@ -166,7 +158,6 @@ export function signalRefsTransformer(
166
158
  computedNames,
167
159
  blockLocal,
168
160
  cellReadNames,
169
- blockingCellNames,
170
161
  )
171
162
  if (replacement !== undefined) {
172
163
  return ts.factory.createPropertyAssignment(node.name.text, replacement)
@@ -180,7 +171,6 @@ export function signalRefsTransformer(
180
171
  computedNames,
181
172
  blockLocal,
182
173
  cellReadNames,
183
- blockingCellNames,
184
174
  )
185
175
  if (replacement !== undefined) {
186
176
  return replacement
@@ -399,7 +389,6 @@ function referenceFor(
399
389
  computedNames: ReadonlySet<string>,
400
390
  blockLocal: ReadonlySet<string> = new Set(),
401
391
  cellReadNames: ReadonlySet<string> = new Set(),
402
- blockingCellNames: ReadonlySet<string> = new Set(),
403
392
  ): ts.Expression | undefined {
404
393
  if (blockLocal.has(name)) {
405
394
  return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier(name), 'value')
@@ -409,19 +398,10 @@ function referenceFor(
409
398
  if (name === 'scope') {
410
399
  return ts.factory.createIdentifier('$$scope')
411
400
  }
412
- /* A BLOCKING `await` cell → `$$readCellBlocking(name)` (ADR-0042): the throwing peek that
413
- SUSPENDS the render region while pending, so an `await` binding reads as a resolved value
414
- and its region withholds until it settles. Client template lowering only — `blockingCellNames`
415
- is empty in the script pass and SSR, which keep the plain `$$readCell` peek below. */
416
- if (blockingCellNames.has(name)) {
417
- return ts.factory.createCallExpression(
418
- ts.factory.createIdentifier('$$readCellBlocking'),
419
- undefined,
420
- [ts.factory.createIdentifier(name)],
421
- )
422
- }
423
- /* A `linked` / async `computed` cell → `$$readCell(name)`: one read shape that peeks an
424
- async cell and reads `.value` off a sync one. */
401
+ /* A `linked` / async `computed` cell → `$$readCell(name)`: ONE read shape for every cell,
402
+ script and template. It peeks a sync cell's `.value`, and for an async cell decides at
403
+ runtime whether to pause (blocking `await`) or peek `undefined` (streaming) by the cell's
404
+ own `blocking` bit — so no blocking/streaming distinction is threaded through codegen. */
425
405
  if (cellReadNames.has(name)) {
426
406
  return ts.factory.createCallExpression(
427
407
  ts.factory.createIdentifier('$$readCell'),
@@ -1,5 +1,6 @@
1
+ import { cachedShadowProgram } from './cachedShadowProgram.ts'
1
2
  import { classifyInterpolationType } from './classifyInterpolationType.ts'
2
- import { createShadowProgram, type ShadowProgram } from './createShadowProgram.ts'
3
+ import type { ShadowProgram } from './createShadowProgram.ts'
3
4
  import { nodeAtShadowOffset } from './nodeAtShadowOffset.ts'
4
5
  import { shadowNaming } from './shadowNaming.ts'
5
6
  import { sourceToShadowOffset } from './sourceToShadowOffset.ts'
@@ -18,25 +19,19 @@ as `state.computed` — either way the seed sub-expression maps back to source a
18
19
  own type), so the identical `sourceToShadowOffset → nodeAtShadowOffset → classifyInterpolation
19
20
  Type` pipeline the interpolation half uses resolves a seed with no node-finder variant.
20
21
 
21
- FAIL-OPEN, but NOT to `'sync'`: if the program can't build, the file has no shadow, the seed's
22
- location doesn't map, no node is found, or the checker throws, the classifier is absent /
23
- returns `undefined` — the signal the caller reads as "degrade to `isBareCallComputed`", NOT as
24
- "the seed is sync". Collapsing failure to `'sync'` (as the interpolation classifier does) would
25
- mis-route a failed stream seed to the lazy `derive` slot instead of today's syntax heuristic.
22
+ FAIL-OPEN, but NOT to `'sync'`: if the program can't build (warned once per root by
23
+ `cachedShadowProgram`), the file has no shadow, the seed's location doesn't map, no node is
24
+ found, or the checker throws, the classifier is absent / returns `undefined` — the signal the
25
+ caller reads as "degrade to `isBareCallComputed`", NOT as "the seed is sync". Collapsing failure
26
+ to `'sync'` (as the interpolation classifier does) would mis-route a failed stream seed to the
27
+ lazy `derive` slot instead of today's syntax heuristic.
26
28
  */
27
29
  export function seedTypeClassifierForRoot(
28
30
  cache: Map<string, ShadowProgram | undefined>,
29
31
  root: string,
30
32
  abidePath: string,
31
33
  ): SeedTypeClassifier | undefined {
32
- if (!cache.has(root)) {
33
- try {
34
- cache.set(root, createShadowProgram(root))
35
- } catch {
36
- cache.set(root, undefined)
37
- }
38
- }
39
- const shadowProgram = cache.get(root)
34
+ const shadowProgram = cachedShadowProgram(cache, root)
40
35
  if (shadowProgram === undefined) {
41
36
  return undefined
42
37
  }
@@ -46,9 +41,9 @@ export function seedTypeClassifierForRoot(
46
41
  return undefined
47
42
  }
48
43
  const checker = program.getTypeChecker()
44
+ const mappings = shadows.get(abidePath)?.mappings ?? []
49
45
  return (loc, code) => {
50
46
  try {
51
- const mappings = shadows.get(abidePath)?.mappings ?? []
52
47
  const offset = sourceToShadowOffset(mappings, loc)
53
48
  if (offset === undefined) {
54
49
  return undefined
@@ -28,9 +28,9 @@ export function shadowInterpolationClassifier(
28
28
  return undefined
29
29
  }
30
30
  const checker = program.getTypeChecker()
31
+ const mappings = shadows.get(abidePath)?.mappings ?? []
31
32
  return (loc, code) => {
32
33
  try {
33
- const mappings = shadows.get(abidePath)?.mappings ?? []
34
34
  const offset = sourceToShadowOffset(mappings, loc)
35
35
  if (offset === undefined) {
36
36
  return 'sync'
@@ -26,12 +26,10 @@ export type AnalyzedComponent = {
26
26
  computedNames: Set<string>
27
27
  /* Component signals read through `$$readCell(name)`: every `linked` and every async
28
28
  `computed`. Threaded to both back-ends so a template reference (`{draft}`) lowers to
29
- the unified cell read consistently on client and server. */
29
+ the unified cell read consistently on client and server. Whether a pending read pauses
30
+ (blocking `await`) or peeks (streaming) is decided at runtime by the cell's own
31
+ `blocking` bit (ADR-0042), so no blocking subset is threaded through codegen. */
30
32
  cellReadNames: Set<string>
31
- /* The subset of `cellReadNames` that are BLOCKING `await` cells (ADR-0042): a template-injected
32
- `{await X}` cell or a script `computed(await …)`. The CLIENT back-end reads these via
33
- `$$readCellBlocking` (suspend-on-pending) rather than `$$readCell`. */
34
- blockingCellNames: Set<string>
35
33
  nodes: TemplateNode[]
36
34
  /* One entry per non-empty `<style>` in the template (in source order): the scope
37
35
  attribute its covered elements carry (annotated onto `nodes`) and the scoped
@@ -1,4 +1,5 @@
1
1
  import ts from 'typescript'
2
+ import { AbideCompileError } from './AbideCompileError.ts'
2
3
 
3
4
  /*
4
5
  Folds a cell-source `watch(source, handler)` into the auto-tracked thunk form
@@ -51,7 +52,7 @@ export function wrapReactionCellSources(
51
52
  expression is read ONCE as a plain value (outside any tracking scope) and handed to the
52
53
  runtime as a dead value: nothing subscribes, the reaction never fires. It can't be folded
53
54
  like a bare cell (a member access isn't a whole-cell subscription), so return the base
54
- name to warn on and leave the source for the author to rewrite. */
55
+ name to REJECT on and point the author at the corrective forms. */
55
56
  function cellMemberBase(source: ts.Expression): string | undefined {
56
57
  if (
57
58
  (ts.isPropertyAccessExpression(source) || ts.isElementAccessExpression(source)) &&
@@ -95,19 +96,22 @@ export function wrapReactionCellSources(
95
96
  )
96
97
  return ts.factory.createCallExpression(node.expression, undefined, [thunk])
97
98
  }
98
- /* Warn on the inert member-expression form `watch(s.foo, handler)` — it looks valid but
99
- subscribes to nothing (see `cellMemberBase`). Not foldable; the fix is the author's. */
99
+ /* Reject the inert member-expression form `watch(s.foo, handler)` — it looks valid but
100
+ subscribes to nothing (see `cellMemberBase`), so the handler silently never fires. Not
101
+ foldable; a compile error turns the silent no-op into a caught mistake. A member of a
102
+ cell is never itself a cell (no per-property cells), so this can't reject a valid watch. */
103
+ const memberBase = sourceArg !== undefined ? cellMemberBase(sourceArg) : undefined
100
104
  if (
101
105
  ts.isCallExpression(node) &&
102
106
  ts.isIdentifier(node.expression) &&
103
107
  watchLocalNames.has(node.expression.text) &&
104
108
  node.arguments.length === 2 &&
105
109
  sourceArg !== undefined &&
106
- cellMemberBase(sourceArg) !== undefined
110
+ memberBase !== undefined
107
111
  ) {
108
112
  const sourceText = sourceArg.getText(root)
109
- console.warn(
110
- `[abide] \`watch(${sourceText}, …)\` does not track \`${sourceText}\` — a member access on a cell is read once as a plain value (abide has no per-property cells), so the reaction is inert. Wrap it in a thunk (\`watch(() => …(${sourceText}))\`) or watch the whole cell (\`watch(${cellMemberBase(sourceArg)}, v => …)\`).`,
113
+ throw new AbideCompileError(
114
+ `[abide] \`watch(${sourceText}, …)\` does not track \`${sourceText}\` — a member access on a cell is read once as a plain value (abide has no per-property cells), so the reaction never fires. Wrap it in a thunk (\`watch(() => …(${sourceText}))\`) or watch the whole cell (\`watch(${memberBase}, v => …)\`).`,
111
115
  )
112
116
  }
113
117
  return ts.visitEachChild(node, visit, context)
@@ -0,0 +1,44 @@
1
+ import ts from 'typescript'
2
+ import { NESTED_REACTIVE_BINDINGS, resolveReactiveExport } from './resolveReactiveExport.ts'
3
+ import { wrapSeed } from './wrapSeed.ts'
4
+
5
+ /*
6
+ Normalises every `state.computed(seed)` / `state.linked(seed)` in a nested branch
7
+ `<script>` so a BARE-VALUE seed becomes a thunk (`state.computed(a * 2)` →
8
+ `state.computed(() => a * 2)`), exactly as the top-level desugar (`desugarSignals` via
9
+ `wrapSeed`) does. A nested script keeps its reactive calls literal — it is not desugared to
10
+ the doc — so absent this the bare value reaches the runtime primitive, which stores it as
11
+ the compute function and calls it on read (`a * 2 is not a function`, a runtime crash). A
12
+ literal `() => …` / `function` thunk passes through unchanged, so the wrap is a no-op on the
13
+ already-thunked form the author usually writes.
14
+
15
+ Recognition is name-based (`NESTED_REACTIVE_BINDINGS`) because a nested script carries no
16
+ import of its own — it inherits the surface by the canonical `state` name, the same way
17
+ `nestedBindingNames` resolves it. Only the seed argument (`arguments[0]`) is wrapped; a
18
+ `state.linked(seed, ...)`'s trailing args pass through. Exposed as a `ts.TransformerFactory`
19
+ so the nested-script lowering can chain it ahead of the reference rename over ONE parsed tree.
20
+ */
21
+ export function wrapReactiveSeedsTransformer(): ts.TransformerFactory<ts.SourceFile> {
22
+ return (context) => (root) => {
23
+ const visit = (node: ts.Node): ts.Node => {
24
+ if (ts.isCallExpression(node) && node.arguments.length > 0) {
25
+ const primitive = resolveReactiveExport(node.expression, NESTED_REACTIVE_BINDINGS)
26
+ if (primitive === 'computed' || primitive === 'linked') {
27
+ const seed = node.arguments[0] as ts.Expression
28
+ const wrapped = wrapSeed(ts.visitNode(seed, visit) as ts.Expression)
29
+ const rest = node.arguments
30
+ .slice(1)
31
+ .map((argument) => ts.visitNode(argument, visit) as ts.Expression)
32
+ return ts.factory.updateCallExpression(
33
+ node,
34
+ node.expression,
35
+ node.typeArguments,
36
+ [wrapped, ...rest],
37
+ )
38
+ }
39
+ }
40
+ return ts.visitEachChild(node, visit, context)
41
+ }
42
+ return ts.visitNode(root, visit) as ts.SourceFile
43
+ }
44
+ }
@@ -0,0 +1,28 @@
1
+ import ts from 'typescript'
2
+ import { hasTopLevelAwait } from './hasTopLevelAwait.ts'
3
+
4
+ const factory = ts.factory
5
+
6
+ /* Normalises a `computed`/`linked` argument into a seed THUNK: a literal `() => …` /
7
+ `function` argument passes through unchanged (the author already wrote the thunk), any
8
+ other expression is wrapped as `() => arg`. The wrapper arrow is made ASYNC when the
9
+ expression contains a top-level `await` (`computed(await load())` → `async () => await
10
+ load()`), which is exactly the marker the runtime primitive uses to route to an async cell.
11
+ Shared by the top-level desugar (`desugarSignals`) and the nested-`<script>` lowering
12
+ (`wrapReactiveSeeds`) so a bare-value seed is normalised identically on both surfaces. */
13
+ export function wrapSeed(argument: ts.Expression): ts.Expression {
14
+ if (ts.isArrowFunction(argument) || ts.isFunctionExpression(argument)) {
15
+ return argument
16
+ }
17
+ const modifiers = hasTopLevelAwait(argument)
18
+ ? [factory.createModifier(ts.SyntaxKind.AsyncKeyword)]
19
+ : undefined
20
+ return factory.createArrowFunction(
21
+ modifiers,
22
+ undefined,
23
+ [],
24
+ undefined,
25
+ factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
26
+ argument,
27
+ )
28
+ }
@@ -5,6 +5,7 @@ import { createComputedNode } from './runtime/createComputedNode.ts'
5
5
  import { isAsyncFunction } from './runtime/isAsyncFunction.ts'
6
6
  import { OWNER } from './runtime/OWNER.ts'
7
7
  import { readNode } from './runtime/readNode.ts'
8
+ import { routeBareSeed } from './runtime/routeBareSeed.ts'
8
9
  import type { Computed } from './runtime/types/Computed.ts'
9
10
  import { unlinkDeps } from './runtime/unlinkDeps.ts'
10
11
 
@@ -32,6 +33,21 @@ export function computed<T>(compute: () => T): Computed<T>
32
33
  export function computed<T>(
33
34
  compute: () => T | Promise<T> | NamedAsyncIterable<T>,
34
35
  ): Computed<T> | AsyncComputed<T> {
36
+ /* A BARE seed that reached the runtime literally — a branch-nested `<script>` (which
37
+ keeps its `state.computed(...)` calls literal; see lowerScript) or a direct JS caller.
38
+ The compile-time `wrapSeed` normalization only covers the leading script, so honor the
39
+ same authored contract (ADR-0045: a bare seed behaves like its thunk form) by VALUE via
40
+ the shared `routeBareSeed`; a non-async value becomes a constant computed over it.
41
+ Without this a raw value lands as `node.compute` and the first read calls it
42
+ (`node.compute is 3` — a dead render mid-stream). */
43
+ if (typeof compute !== 'function') {
44
+ const seed: unknown = compute
45
+ const cell = routeBareSeed(seed, false)
46
+ if (cell !== undefined) {
47
+ return cell as AsyncComputed<T>
48
+ }
49
+ compute = () => seed as T
50
+ }
35
51
  /* `await` marker: an async-function thunk unwraps its promise into a tracked async cell.
36
52
  This is the only classification the read-only primitive can make without running the
37
53
  seed — a sync `computed` MUST stay lazy (it does not compute until first read). A stream
@@ -4,6 +4,7 @@ import { computed } from './computed.ts'
4
4
  import { effect } from './effect.ts'
5
5
  import { linked } from './linked.ts'
6
6
  import { CURRENT_PATH } from './runtime/CURRENT_PATH.ts'
7
+ import { consumeSeed } from './runtime/consumeSeed.ts'
7
8
  import { createDoc } from './runtime/createDoc.ts'
8
9
  import { DOC_SEED } from './runtime/DOC_SEED.ts'
9
10
  import type { Cell } from './runtime/types/Cell.ts'
@@ -50,13 +51,13 @@ export function createScope(
50
51
  on the client, so a uuid/timestamp/random would diverge from the SSR HTML. The server snapshot
51
52
  for this scope's render-path id is decoded here and consumed by `replace` on each slot's FIRST
52
53
  write (the eager init), keeping the server value while the throwaway init is discarded. One-shot
53
- — deleted on read so an SPA re-nav to the same path re-inits fresh; empty on the server. */
54
+ via `consumeSeed` (two-phase, ADR-0048) — an SPA re-nav to the same path re-inits fresh, and a
55
+ hydration-desync throw hands the seed to the cold rebuild; empty on the server. */
54
56
  let pendingSeed: Map<string, unknown> | undefined
55
57
  /* `DOC_SEED` is populated only on the client by `startClient` (empty on the server), so the read
56
58
  needs no window guard — it mirrors the async-cell warm read. */
57
- const encodedSeed = DOC_SEED[id]
59
+ const encodedSeed = consumeSeed(DOC_SEED, id)
58
60
  if (encodedSeed !== undefined) {
59
- delete DOC_SEED[id]
60
61
  try {
61
62
  pendingSeed = new Map(
62
63
  Object.entries(decodeRefJson(encodedSeed) as Record<string, unknown>),
@@ -1,3 +1,4 @@
1
+ import { parkCursor } from '../runtime/parkCursor.ts'
1
2
  import { RENDER } from '../runtime/RENDER.ts'
2
3
 
3
4
  /*
@@ -18,7 +19,7 @@ export function anchorCursor(anchor: Node): Node | null {
18
19
  const reference = anchor.nextSibling
19
20
  const hydration = RENDER.hydration
20
21
  if (hydration !== undefined) {
21
- hydration.next.set(anchor.parentNode as Node, reference)
22
+ parkCursor(hydration, anchor.parentNode as Node, reference)
22
23
  }
23
24
  return reference
24
25
  }
@@ -19,9 +19,10 @@ the accidental copy-paste, notably its two subtle guards:
19
19
  rethrowing, so the caller's cold rebuild starts clean).
20
20
 
21
21
  `anchor` is a mutable field: each block parks its own anchor text node at create /
22
- hydrate / rebuild time (placement is bespoke), and `place` reads it. The block owns its
23
- `scopeGroup`; each built branch's disposer is tracked in it so it disposes on owner
24
- teardown, not only on a swap.
22
+ hydrate / rebuild time via `parkAnchor` (WHERE it parks is bespoke — before the boundary
23
+ close, after a discarded boundary, at the create-mode `before`), and `place` reads it. The
24
+ block owns its `scopeGroup`; each built branch's disposer is tracked in it so it disposes
25
+ on owner teardown, not only on a swap.
25
26
  */
26
27
  export function anchoredBranch(
27
28
  parent: Node,
@@ -29,6 +30,7 @@ export function anchoredBranch(
29
30
  ): {
30
31
  anchor: Node | undefined
31
32
  detach: () => void
33
+ parkAnchor: (before: Node | null) => void
32
34
  place: (build: (host: Node) => void, wrapBuild?: (run: () => void) => void) => void
33
35
  adoptStrand: (
34
36
  build: (host: Node) => void,
@@ -128,6 +130,14 @@ export function anchoredBranch(
128
130
  }
129
131
  }
130
132
 
133
+ /* Park a fresh empty text node as the branch's swap anchor at `before` (null appends) —
134
+ the create/hydrate/rebuild triple every block repeated by hand. */
135
+ const parkAnchor = (before: Node | null): void => {
136
+ const node = document.createTextNode('')
137
+ anchor = node
138
+ parent.insertBefore(node, before)
139
+ }
140
+
131
141
  return {
132
142
  get anchor(): Node | undefined {
133
143
  return anchor
@@ -136,6 +146,7 @@ export function anchoredBranch(
136
146
  anchor = node
137
147
  },
138
148
  detach,
149
+ parkAnchor,
139
150
  place,
140
151
  adoptStrand,
141
152
  }
@@ -1,34 +1,25 @@
1
1
  import { claimChild } from '../runtime/claimChild.ts'
2
+ import { parkCursor } from '../runtime/parkCursor.ts'
2
3
  import { RENDER } from '../runtime/RENDER.ts'
3
- import { assertClaimedText } from './assertClaimedText.ts'
4
+ import { claimText } from './claimText.ts'
4
5
 
5
6
  /*
6
7
  A static (non-reactive) text node under `parent`: created and appended (create
7
- mode), or claimed from the server-rendered text (hydrate mode). As with reactive
8
- text, a merged SSR text node is split at this literal's length so the next claim
9
- lines up; nothing is bound since the text never changes.
8
+ mode), or claimed from the server-rendered text (hydrate mode) — the shared
9
+ `claimText` asserts, splits the merged SSR node at this literal's length so the
10
+ next claim lines up, and advances. Nothing is bound since the text never changes.
11
+ A static always renders on the server, so a non-text node at the cursor is a
12
+ tolerated desync: advance past it (assertClaimedText's domain is a text mismatch;
13
+ a structural one is claimExpected's).
10
14
  */
11
15
  // @documentation plumbing
12
16
  export function appendStatic(parent: Node, value: string, splitAlways = false): void {
13
17
  const hydration = RENDER.hydration
14
18
  if (hydration !== undefined) {
15
- const node = claimChild(hydration, parent) as unknown as Text
16
- const isText = node !== null && typeof node.splitText === 'function'
17
- /* The claimed SSR node must begin with this literal, or the split misaligns — throw
18
- legibly at the divergence (only when a text node is actually here; a structural
19
- mismatch is claimExpected's domain). */
20
- if (isText) {
21
- assertClaimedText(node, value)
19
+ if (claimText(hydration, parent, value, splitAlways) === undefined) {
20
+ const node = claimChild(hydration, parent)
21
+ parkCursor(hydration, parent, node === null ? null : node.nextSibling)
22
22
  }
23
- /* Split even on an exact-length consume when a sibling text binding follows
24
- (`splitAlways`), so it gets its own node; see appendText for the rationale. */
25
- if (
26
- isText &&
27
- (splitAlways ? value.length <= node.data.length : value.length < node.data.length)
28
- ) {
29
- node.splitText(value.length)
30
- }
31
- hydration.next.set(parent, node === null ? null : node.nextSibling)
32
23
  return
33
24
  }
34
25
  parent.appendChild(document.createTextNode(value))