@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
@@ -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,
@@ -185,6 +194,7 @@ export function lowerScript(
185
194
  derivedNames,
186
195
  computedNames,
187
196
  cellReadNames,
197
+ blockingCellNames: allBlockingNames,
188
198
  droppedReactiveImports,
189
199
  }
190
200
  }
@@ -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
@@ -55,6 +56,10 @@ export function signalRefsTransformer(
55
56
  `computed` (see `desugarSignals`). A nearer lexical binding of the same name shadows
56
57
  them like any other signal. */
57
58
  cellReadNames: ReadonlySet<string> = new Set(),
59
+ /* The subset of `cellReadNames` that are BLOCKING `await` cells (ADR-0042), read through
60
+ `$$readCellBlocking(name)` (suspend-on-pending) instead of `$$readCell`. Non-empty only on
61
+ the CLIENT template lowering; the script pass and SSR keep it empty (`$$readCell`). */
62
+ blockingCellNames: ReadonlySet<string> = new Set(),
58
63
  ): ts.TransformerFactory<ts.SourceFile> {
59
64
  /* The signal names that a nested binding can shadow — only these matter for
60
65
  scope tracking, so we ignore every other local binding. */
@@ -116,21 +121,19 @@ export function signalRefsTransformer(
116
121
  !shadowed.has(node.left.text)
117
122
  ) {
118
123
  const target = node.left.text
119
- const right = ts.visitNode(node.right, visit) as ts.Expression
120
- if (node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
121
- return writeCellCall(target, right)
122
- }
123
- const binary = COMPOUND_ASSIGNMENT_OPERATORS.get(node.operatorToken.kind)
124
- if (binary !== undefined) {
125
- return writeCellCall(
126
- target,
127
- ts.factory.createBinaryExpression(cellReadCall(target), binary, right),
128
- )
124
+ const lowered = lowerCompoundAssignment(
125
+ node.operatorToken.kind,
126
+ () => cellReadCall(target),
127
+ ts.visitNode(node.right, visit) as ts.Expression,
128
+ (value) => writeCellCall(target, value),
129
+ )
130
+ if (lowered !== undefined) {
131
+ return lowered
129
132
  }
130
133
  }
131
134
  /* `draft++` / `++draft` / `draft--` on a `linked` cell → a `$$writeCell` of the
132
- stepped value, mirroring the `+= 1` shape (the bare `++` would otherwise land
133
- on `$$readCell(draft)`, an invalid lvalue). */
135
+ stepped value (the bare `++` would otherwise land on `$$readCell(draft)`, an
136
+ invalid lvalue). */
134
137
  if (
135
138
  (ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node)) &&
136
139
  (node.operator === ts.SyntaxKind.PlusPlusToken ||
@@ -139,17 +142,12 @@ export function signalRefsTransformer(
139
142
  cellReadNames.has(node.operand.text) &&
140
143
  !shadowed.has(node.operand.text)
141
144
  ) {
142
- const step =
143
- node.operator === ts.SyntaxKind.PlusPlusToken
144
- ? ts.SyntaxKind.PlusToken
145
- : ts.SyntaxKind.MinusToken
146
- return writeCellCall(
147
- node.operand.text,
148
- ts.factory.createBinaryExpression(
149
- cellReadCall(node.operand.text),
150
- step,
151
- ts.factory.createNumericLiteral(1),
152
- ),
145
+ const target = node.operand.text
146
+ return lowerUpdateExpression(
147
+ ts.isPostfixUnaryExpression(node),
148
+ node.operator === ts.SyntaxKind.PlusPlusToken,
149
+ () => cellReadCall(target),
150
+ (value) => writeCellCall(target, value),
153
151
  )
154
152
  }
155
153
  /* Shorthand `{ count }` → `{ count: $$model.count }` / `{ total: total.value }`,
@@ -162,6 +160,7 @@ export function signalRefsTransformer(
162
160
  computedNames,
163
161
  blockLocal,
164
162
  cellReadNames,
163
+ blockingCellNames,
165
164
  )
166
165
  if (replacement !== undefined) {
167
166
  return ts.factory.createPropertyAssignment(node.name.text, replacement)
@@ -175,6 +174,7 @@ export function signalRefsTransformer(
175
174
  computedNames,
176
175
  blockLocal,
177
176
  cellReadNames,
177
+ blockingCellNames,
178
178
  )
179
179
  if (replacement !== undefined) {
180
180
  return replacement
@@ -393,6 +393,7 @@ function referenceFor(
393
393
  computedNames: ReadonlySet<string>,
394
394
  blockLocal: ReadonlySet<string> = new Set(),
395
395
  cellReadNames: ReadonlySet<string> = new Set(),
396
+ blockingCellNames: ReadonlySet<string> = new Set(),
396
397
  ): ts.Expression | undefined {
397
398
  if (blockLocal.has(name)) {
398
399
  return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier(name), 'value')
@@ -402,6 +403,17 @@ function referenceFor(
402
403
  if (name === 'scope') {
403
404
  return ts.factory.createIdentifier('$$scope')
404
405
  }
406
+ /* A BLOCKING `await` cell → `$$readCellBlocking(name)` (ADR-0042): the throwing peek that
407
+ SUSPENDS the render region while pending, so an `await` binding reads as a resolved value
408
+ and its region withholds until it settles. Client template lowering only — `blockingCellNames`
409
+ is empty in the script pass and SSR, which keep the plain `$$readCell` peek below. */
410
+ if (blockingCellNames.has(name)) {
411
+ return ts.factory.createCallExpression(
412
+ ts.factory.createIdentifier('$$readCellBlocking'),
413
+ undefined,
414
+ [ts.factory.createIdentifier(name)],
415
+ )
416
+ }
405
417
  /* A `linked` / async `computed` cell → `$$readCell(name)`: one read shape that peeks an
406
418
  async cell and reads `.value` off a sync one. */
407
419
  if (cellReadNames.has(name)) {
@@ -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
  }
@@ -28,6 +28,10 @@ export type AnalyzedComponent = {
28
28
  `computed`. Threaded to both back-ends so a template reference (`{draft}`) lowers to
29
29
  the unified cell read consistently on client and server. */
30
30
  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>
31
35
  nodes: TemplateNode[]
32
36
  /* One entry per non-empty `<style>` in the template (in source order): the scope
33
37
  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)
@@ -3,10 +3,12 @@ import { snippetPayload } from '../../shared/snippet.ts'
3
3
  import { effect } from '../effect.ts'
4
4
  import { claimChild } from '../runtime/claimChild.ts'
5
5
  import { RENDER } from '../runtime/RENDER.ts'
6
+ import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
6
7
  import { appendSnippet } from './appendSnippet.ts'
7
8
  import { assertClaimedText } from './assertClaimedText.ts'
8
9
  import { isComment } from './isComment.ts'
9
10
  import { parseRawNodes } from './parseRawNodes.ts'
11
+ import { readTextOrSuspend } from './readTextOrSuspend.ts'
10
12
 
11
13
  const CLOSE = '/abide:html'
12
14
 
@@ -22,16 +24,36 @@ its parsed nodes go between an anchor (create), or the server-rendered nodes
22
24
  between `<!--abide:html-->`/`<!--/abide:html-->` markers are adopted (hydrate), and
23
25
  a change re-parses and swaps. A binding is text or raw for its lifetime (decided by
24
26
  its first value), so plain text — the common case — stays a cheap single node.
27
+
28
+ A blocking `await` read that is pending throws a `SuspenseSignal` (ADR-0042): the
29
+ interpolation SUSPENDS — it renders empty and withholds until the value resolves,
30
+ never evaluating the surrounding expression against a pending `undefined`. The read
31
+ tracked the cell, so the bind effect re-runs on settle. A suspend can only occur on a
32
+ cold client render (on hydrate the warm-seed makes the cell `refreshing()`, not
33
+ `pending()`, D4), and a suspended value is treated as text — its snippet/html shape is
34
+ decided from the first resolved value.
25
35
  */
26
36
  // @documentation plumbing
27
37
  export function appendText(parent: Node, read: () => unknown, splitAlways = false): void {
38
+ /* Probe the first value once, tolerating a suspend (a pending blocking read) — a suspended
39
+ interpolation skips snippet/html detection and takes the text path, starting empty. */
40
+ let probe: unknown
41
+ let suspended = false
42
+ try {
43
+ probe = read()
44
+ } catch (signal) {
45
+ if (!(signal instanceof SuspenseSignal)) {
46
+ throw signal
47
+ }
48
+ suspended = true
49
+ }
28
50
  /* A snippet call (`{row(args)}`) mounts its builder; a `html\`\`` value inserts
29
51
  raw markup; everything else is escaped text — decided by the first value. */
30
- if (typeof snippetPayload(read()) === 'function') {
52
+ if (!suspended && typeof snippetPayload(probe) === 'function') {
31
53
  appendSnippet(parent, read)
32
54
  return
33
55
  }
34
- if (rawHtmlString(read()) !== undefined) {
56
+ if (!suspended && rawHtmlString(probe) !== undefined) {
35
57
  appendRawHtml(parent, read)
36
58
  return
37
59
  }
@@ -39,8 +61,9 @@ export function appendText(parent: Node, read: () => unknown, splitAlways = fals
39
61
  if (hydration !== undefined) {
40
62
  const claimed = claimChild(hydration, parent)
41
63
  /* Nullish reads render as empty text, never the literal `"undefined"` — so a
42
- pending async read (undefined-while-pending, ADR-0032 D3) shows nothing. */
43
- const firstValue = read()
64
+ pending async read (undefined-while-pending, ADR-0032 D3) shows nothing. A blocking
65
+ read never suspends here (warm-seeded → `refreshing()`), so `probe` holds its value. */
66
+ const firstValue = probe
44
67
  const value = firstValue == null ? '' : String(firstValue)
45
68
  /* A value that first rendered empty produced NO server text node, so the cursor
46
69
  points at the following node (an element/comment) or past the end (null) — not a
@@ -77,16 +100,14 @@ export function appendText(parent: Node, read: () => unknown, splitAlways = fals
77
100
  at the end). */
78
101
  hydration.next.set(parent, isText ? node.nextSibling : claimed)
79
102
  effect(() => {
80
- const next = read()
81
- node.data = next == null ? '' : String(next)
103
+ node.data = readTextOrSuspend(read)
82
104
  })
83
105
  return
84
106
  }
85
107
  const node = document.createTextNode('')
86
108
  parent.appendChild(node)
87
109
  effect(() => {
88
- const next = read()
89
- node.data = next == null ? '' : String(next)
110
+ node.data = readTextOrSuspend(read)
90
111
  })
91
112
  }
92
113
 
@@ -2,9 +2,11 @@ import { rawHtmlString } from '../../shared/html.ts'
2
2
  import { snippetPayload } from '../../shared/snippet.ts'
3
3
  import { effect } from '../effect.ts'
4
4
  import { RENDER } from '../runtime/RENDER.ts'
5
+ import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
5
6
  import { appendSnippet } from './appendSnippet.ts'
6
7
  import { appendText } from './appendText.ts'
7
8
  import { parseRawNodes } from './parseRawNodes.ts'
9
+ import { readTextOrSuspend } from './readTextOrSuspend.ts'
8
10
 
9
11
  /*
10
12
  A reactive `{expr}` interpolation mounted at a skeleton anchor comment (`<!--a-->`), used
@@ -39,14 +41,25 @@ export function appendTextAt(anchor: Node, read: () => unknown): void {
39
41
  return
40
42
  }
41
43
 
42
- const first = read()
43
- if (typeof snippetPayload(first) === 'function') {
44
+ /* Probe once, tolerating a suspend (a pending blocking read): a suspended interpolation skips
45
+ snippet/html detection and takes the text path, starting empty until the value resolves. */
46
+ let first: unknown
47
+ let suspended = false
48
+ try {
49
+ first = read()
50
+ } catch (signal) {
51
+ if (!(signal instanceof SuspenseSignal)) {
52
+ throw signal
53
+ }
54
+ suspended = true
55
+ }
56
+ if (!suspended && typeof snippetPayload(first) === 'function') {
44
57
  const fragment = document.createDocumentFragment()
45
58
  appendSnippet(fragment, read)
46
59
  parent.insertBefore(fragment, anchor.nextSibling)
47
60
  return
48
61
  }
49
- if (rawHtmlString(first) !== undefined) {
62
+ if (!suspended && rawHtmlString(first) !== undefined) {
50
63
  let nodes: Node[] = []
51
64
  effect(() => {
52
65
  for (const node of nodes) {
@@ -63,7 +76,11 @@ export function appendTextAt(anchor: Node, read: () => unknown): void {
63
76
  }
64
77
  const node = document.createTextNode('')
65
78
  parent.insertBefore(node, anchor.nextSibling)
79
+ /* Text/suspend handling shared with `appendText`'s bind via `readTextOrSuspend`: stringify the
80
+ value, show '' while a blocking read is pending (re-running on settle, since the read tracked
81
+ its cell), and coerce nullish to '' (never the literal "undefined") — keeping this create path
82
+ congruent with the hydrate path above, which delegates to `appendText`. */
66
83
  effect(() => {
67
- node.data = String(read())
84
+ node.data = readTextOrSuspend(read)
68
85
  })
69
86
  }
@@ -1,6 +1,7 @@
1
1
  import { effect } from '../effect.ts'
2
2
  import { RENDER } from '../runtime/RENDER.ts'
3
3
  import { reportHydrationDivergence } from '../runtime/reportHydrationDivergence.ts'
4
+ import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
4
5
 
5
6
  /* The attribute string an element carries for `value`, or `null` when absent — the present/absent +
6
7
  stringify semantics below, factored so the hydration compare and the write agree exactly. */
@@ -24,7 +25,21 @@ export function attr(element: Element, name: string, read: () => unknown): void
24
25
  invisible, since the effect always overwrites. */
25
26
  let hydrating = RENDER.hydration !== undefined
26
27
  effect(() => {
27
- const value = read()
28
+ let value: unknown
29
+ try {
30
+ value = read()
31
+ } catch (signal) {
32
+ /* A pending blocking `await` read (ADR-0042) suspends this attribute: leave it
33
+ unset until the value resolves. The read tracked its cell, so this effect
34
+ re-runs on settle. A suspend can only occur on a cold client render (warm-seed
35
+ keeps a hydrating read `refreshing()`, never `pending()`), so the hydration
36
+ compare below is never reached while suspended. */
37
+ if (!(signal instanceof SuspenseSignal)) {
38
+ throw signal
39
+ }
40
+ element.removeAttribute(name)
41
+ return
42
+ }
28
43
  const next = attributeText(value)
29
44
  if (hydrating) {
30
45
  hydrating = false
@@ -7,6 +7,7 @@ import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
7
7
  import { RENDER } from '../runtime/RENDER.ts'
8
8
  import type { ResumeEntry } from '../runtime/RESUME.ts'
9
9
  import { RESUME } from '../runtime/RESUME.ts'
10
+ import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
10
11
  import { scopeGroup } from '../runtime/scopeGroup.ts'
11
12
  import type { State } from '../runtime/types/State.ts'
12
13
  import { withoutHydration } from '../runtime/withoutHydration.ts'
@@ -109,17 +110,10 @@ export function awaitBlock(
109
110
  activeKind = 'catch'
110
111
  }
111
112
 
112
- /* Render a settled-or-pending result into the current generation. */
113
- const render = (result: unknown): void => {
114
- const gen = guard.token()
115
- if (!isThenable(result)) {
116
- settleThen(result) // warm-sync → resolved now, no flash
117
- return
118
- }
119
- /* A then-branch is already mounted (a revalidation): keep it visible and update in
120
- place when the new value settles, instead of blanking to pending and rebuilding —
121
- this is the no-flash live-update path. A first load (or a prior pending/catch)
122
- shows the pending branch (or detaches) while the promise is in flight. */
113
+ /* Show the pending branch (or detach when there is none), UNLESS a then-branch is already
114
+ mounted a revalidation keeps it visible and updates in place when the new value settles
115
+ (the no-flash live-update path). Shared by an in-flight promise and a suspended subject read. */
116
+ const showPending = (): void => {
123
117
  if (activeKind !== 'then') {
124
118
  if (renderPending !== undefined) {
125
119
  branch.place((host) => renderPending(host))
@@ -129,6 +123,18 @@ export function awaitBlock(
129
123
  activeKind = undefined
130
124
  }
131
125
  }
126
+ }
127
+
128
+ /* Render a settled-or-pending result into the current generation. */
129
+ const render = (result: unknown): void => {
130
+ const gen = guard.token()
131
+ if (!isThenable(result)) {
132
+ settleThen(result) // warm-sync → resolved now, no flash
133
+ return
134
+ }
135
+ /* A first load (or a prior pending/catch) shows the pending branch while the promise is in
136
+ flight; a live then-branch stays put (see `showPending`). */
137
+ showPending()
132
138
  result.then(
133
139
  (value) => {
134
140
  if (guard.live(gen)) {
@@ -296,8 +302,23 @@ export function awaitBlock(
296
302
  }
297
303
  /* Read the promise every subsequent run so an invalidate re-runs the block. ONLY this
298
304
  read is tracked (the branch builds untracked via `scope`), so the block re-runs only
299
- when its promise source does, not on any branch-state change. */
300
- render(promiseThunk())
305
+ when its promise source does, not on any branch-state change. A blocking `await` cell
306
+ embedded in the subject (`{#await loadMore(user.id)}` where `user` is an `await` cell)
307
+ throws a `SuspenseSignal` while pending (ADR-0042) — on a COLD client render the read
308
+ runs synchronously here, so withhold to the pending branch until it settles (mirroring
309
+ the local suspense withhold every other reading region does), rather than letting the
310
+ throw escape the build. The read tracked its cell, so this effect re-runs on resolve. */
311
+ let result: unknown
312
+ try {
313
+ result = promiseThunk()
314
+ } catch (signal) {
315
+ if (!(signal instanceof SuspenseSignal)) {
316
+ throw signal
317
+ }
318
+ showPending()
319
+ return
320
+ }
321
+ render(result)
301
322
  })
302
323
  }
303
324
 
@@ -13,6 +13,7 @@ import { buildDetachedRange } from './buildDetachedRange.ts'
13
13
  import { moveRange } from './moveRange.ts'
14
14
  import { removeRange } from './removeRange.ts'
15
15
  import type { EachRow } from './types/EachRow.ts'
16
+ import { withSuspense } from './withSuspense.ts'
16
17
 
17
18
  /*
18
19
  Keyed list binding — the runtime for `<template each key=>`. Each row is a content
@@ -119,6 +120,13 @@ export function each<T>(
119
120
  }
120
121
  }
121
122
 
123
+ /* Read the source, tolerating a pending blocking `await` read (a SuspenseSignal): treat a
124
+ suspend as an empty list — identical to the undefined-while-pending peek (ADR-0032 D3) — so
125
+ the each renders no rows until the source resolves. The read still tracked its cell, so the
126
+ driving effect re-runs on settle. */
127
+ const readSource = (): Iterable<T> | undefined =>
128
+ withSuspense<Iterable<T> | undefined>(items, undefined)
129
+
122
130
  let anchor: Node
123
131
  /* When hydrating, the first effect run must NOT reconcile — the rows it would
124
132
  build are already adopted in place below. */
@@ -128,7 +136,7 @@ export function each<T>(
128
136
  let position = 0
129
137
  /* An undefined source renders an empty list, not a throw — a `{#for x in promise}`
130
138
  whose lifted source peeks undefined while pending (ADR-0032 D3). */
131
- for (const item of items() ?? []) {
139
+ for (const item of readSource() ?? []) {
132
140
  const key = keyOf(item)
133
141
  const row = buildRow(item, position) // claims the SSR row where it sits
134
142
  if (rows.has(key)) {
@@ -156,7 +164,7 @@ export function each<T>(
156
164
  effect(() => {
157
165
  /* Read (subscribe) every run, including the adopting one. Materialize a
158
166
  non-array iterable to an array so a generator yields fresh each run. */
159
- const source = items()
167
+ const source = readSource()
160
168
  if (adopting) {
161
169
  adopting = false // rows already adopted in document order; nothing to move
162
170
  return
@@ -11,6 +11,7 @@ import { state } from '../state.ts'
11
11
  import { buildDetachedRange } from './buildDetachedRange.ts'
12
12
  import { removeRange } from './removeRange.ts'
13
13
  import type { EachRow } from './types/EachRow.ts'
14
+ import { withSuspense } from './withSuspense.ts'
14
15
 
15
16
  /*
16
17
  Async keyed list — the runtime for `<template each await key=>`. Like `each`, but
@@ -113,7 +114,14 @@ export function eachAsync<T>(
113
114
  removeRange(row.start, row.end)
114
115
  }
115
116
  rows.clear()
116
- const iterable = items() // read (subscribe) synchronously
117
+ /* Read (subscribe) the source synchronously, tolerating a pending blocking `await` read
118
+ embedded in it (a SuspenseSignal, ADR-0042): withhold — render no rows until it resolves,
119
+ exactly as sync `each` does. On a cold client render the read runs here at build; the
120
+ read tracked its cell, so this effect re-runs on settle. */
121
+ const iterable = withSuspense<AsyncIterable<T> | undefined>(items, undefined)
122
+ if (iterable === undefined) {
123
+ return
124
+ }
117
125
  const present = new Set<string>()
118
126
  let arrivals = 0 // stream arrival ordinal → each row's index
119
127
  const drain = async (): Promise<void> => {
@@ -0,0 +1,29 @@
1
+ import { isAsyncCell } from '../../shared/isAsyncCell.ts'
2
+ import type { AsyncComputed } from '../../shared/types/AsyncComputed.ts'
3
+ import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
4
+ import { readCell } from './readCell.ts'
5
+
6
+ /*
7
+ The blocking-cell read the compiler lowers to `$$readCellBlocking(NAME)` for an `await`-marked
8
+ cell (ADR-0042 D5.3). Exactly `readCell`, with one added rule ahead of it: a cell with no value
9
+ *yet* throws a `SuspenseSignal` so the enclosing region SUSPENDS until the value resolves rather
10
+ than rendering against `undefined`. This is what makes an `await` binding read as a resolved `T`
11
+ (never `undefined`-while-pending) on both sides — the client mirrors the server flush barrier.
12
+
13
+ The suspend is keyed on `cell.pending()` (in-flight AND no value), NOT `peek() === undefined`:
14
+ - a blocking cell that legitimately resolves to `undefined` is not pending, so it does not
15
+ suspend forever;
16
+ - a warm-seeded cell on hydrate is `refreshing()` (has value, in flight), not `pending()`, so it
17
+ reads its held value and adopts the SSR markup with no suspend and no flash (ADR-0042 D4).
18
+ Everything past the pending gate — the throwing peek (error-with-no-retained-value → `AsyncCellError`
19
+ to `{#try}`, a retained value winning over pending/error via SWR), a function call, or a `.value`
20
+ read — is `readCell` verbatim, so the two reads can never drift.
21
+ */
22
+ // @documentation plumbing
23
+ export function readCellBlocking(cell: unknown): unknown {
24
+ /* No value yet → suspend the reading region (distinct from an error, which `readCell` routes). */
25
+ if (isAsyncCell(cell) && (cell as AsyncComputed<unknown>).pending()) {
26
+ throw new SuspenseSignal(cell as AsyncComputed<unknown>)
27
+ }
28
+ return readCell(cell)
29
+ }
@@ -0,0 +1,21 @@
1
+ import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
2
+
3
+ /*
4
+ The text a reactive `{expr}` binding writes: the stringified value, or `''` while a blocking
5
+ `await` read is pending — a `SuspenseSignal` (ADR-0042). Nullish stringifies to `''` (never the
6
+ literal `"undefined"`), so a pending streaming read and a suspended blocking read both show nothing.
7
+ A suspending read still tracked its cell before throwing, so the enclosing bind effect re-runs and
8
+ fills in once the value resolves. Rethrows any non-suspense error unchanged.
9
+ */
10
+ export function readTextOrSuspend(read: () => unknown): string {
11
+ let next: unknown
12
+ try {
13
+ next = read()
14
+ } catch (signal) {
15
+ if (!(signal instanceof SuspenseSignal)) {
16
+ throw signal
17
+ }
18
+ return ''
19
+ }
20
+ return next == null ? '' : String(next)
21
+ }