@abide/abide 0.38.1 → 0.40.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 +5 -5
  2. package/CHANGELOG.md +63 -0
  3. package/package.json +9 -1
  4. package/src/build.ts +14 -0
  5. package/src/lib/bundle/exitWithParent.ts +4 -2
  6. package/src/lib/server/rpc/parseArgs.ts +10 -1
  7. package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
  8. package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
  9. package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
  10. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
  11. package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
  12. package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
  13. package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
  14. package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
  15. package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
  16. package/src/lib/server/sockets/createSocketDispatcher.ts +19 -3
  17. package/src/lib/server/sockets/defineSocket.ts +3 -1
  18. package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
  19. package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
  20. package/src/lib/shared/buildRpcRequest.ts +8 -1
  21. package/src/lib/shared/createRemoteFunction.ts +20 -11
  22. package/src/lib/shared/decodeRefJson.ts +110 -0
  23. package/src/lib/shared/encodeRefJson.ts +106 -0
  24. package/src/lib/shared/escapeHtml.ts +15 -0
  25. package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
  26. package/src/lib/shared/streamResponse.ts +8 -1
  27. package/src/lib/shared/types/RemoteCallable.ts +12 -3
  28. package/src/lib/shared/types/RpcOptions.ts +22 -0
  29. package/src/lib/shared/types/SourceMap.ts +14 -0
  30. package/src/lib/test/createTestSocketChannel.ts +6 -2
  31. package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
  32. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
  33. package/src/lib/ui/compile/compileModule.ts +5 -2
  34. package/src/lib/ui/compile/compileSSR.ts +32 -9
  35. package/src/lib/ui/compile/compileShadow.ts +25 -10
  36. package/src/lib/ui/compile/composeProps.ts +53 -0
  37. package/src/lib/ui/compile/desugarSignals.ts +45 -17
  38. package/src/lib/ui/compile/generateBuild.ts +87 -76
  39. package/src/lib/ui/compile/generateSSR.ts +217 -71
  40. package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
  41. package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
  42. package/src/lib/ui/compile/parseTemplate.ts +118 -1
  43. package/src/lib/ui/compile/renameSignalRefs.ts +12 -0
  44. package/src/lib/ui/compile/resolveBranches.ts +21 -0
  45. package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
  46. package/src/lib/ui/compile/staticAttr.ts +1 -1
  47. package/src/lib/ui/compile/staticTextPart.ts +1 -1
  48. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
  49. package/src/lib/ui/compile/types/TemplateNode.ts +15 -3
  50. package/src/lib/ui/createScope.ts +14 -0
  51. package/src/lib/ui/dom/appendText.ts +2 -3
  52. package/src/lib/ui/dom/appendTextAt.ts +2 -3
  53. package/src/lib/ui/dom/applyResolved.ts +5 -8
  54. package/src/lib/ui/dom/awaitBlock.ts +14 -1
  55. package/src/lib/ui/dom/mergeProps.ts +32 -0
  56. package/src/lib/ui/dom/on.ts +7 -0
  57. package/src/lib/ui/dom/parseRawNodes.ts +17 -0
  58. package/src/lib/ui/dom/readCall.ts +27 -0
  59. package/src/lib/ui/dom/restProps.ts +32 -0
  60. package/src/lib/ui/dom/spreadAttrs.ts +34 -0
  61. package/src/lib/ui/dom/spreadProps.ts +32 -0
  62. package/src/lib/ui/installHotBridge.ts +10 -0
  63. package/src/lib/ui/navigate.ts +28 -3
  64. package/src/lib/ui/remoteProxy.ts +68 -36
  65. package/src/lib/ui/renderChain.ts +39 -37
  66. package/src/lib/ui/renderToStream.ts +84 -68
  67. package/src/lib/ui/resumeSeedScript.ts +27 -0
  68. package/src/lib/ui/router.ts +81 -51
  69. package/src/lib/ui/runtime/RESUME.ts +13 -6
  70. package/src/lib/ui/runtime/createEffectNode.ts +5 -0
  71. package/src/lib/ui/runtime/localStoragePersistence.ts +21 -8
  72. package/src/lib/ui/runtime/toTeardown.ts +10 -5
  73. package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
  74. package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
  75. package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
  76. package/src/lib/ui/socketChannel.ts +5 -2
  77. package/src/lib/ui/tryEncodeResume.ts +20 -0
  78. package/src/lib/ui/types/Scope.ts +9 -3
  79. package/src/lib/ui/compile/escapeHtml.ts +0 -15
  80. /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
@@ -1,11 +1,14 @@
1
1
  import { OUTLET_CLOSE, OUTLET_OPEN } from '../runtime/OUTLET_MARKER.ts'
2
2
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
3
3
  import { asOutlet } from './asOutlet.ts'
4
+ import { composeProps } from './composeProps.ts'
4
5
  import { groupBindParts } from './groupBindParts.ts'
5
6
  import { isAnchorPositioned } from './isAnchorPositioned.ts'
6
7
  import { lowerContext } from './lowerContext.ts'
8
+ import { resolveBranches } from './resolveBranches.ts'
7
9
  import { scopeAttr } from './scopeAttr.ts'
8
10
  import { skeletonContext } from './skeletonContext.ts'
11
+ import { spreadExcludedNames } from './spreadExcludedNames.ts'
9
12
  import { staticAttr } from './staticAttr.ts'
10
13
  import { staticTextPart } from './staticTextPart.ts'
11
14
  import { stripEffects } from './stripEffects.ts'
@@ -19,17 +22,6 @@ import { VOID_TAGS } from './VOID_TAGS.ts'
19
22
  const RANGE_OPEN = '<!--[-->'
20
23
  const RANGE_CLOSE = '<!--]-->'
21
24
 
22
- /* The `then`/`catch`/`finally` branch child of an await/try block, or undefined. */
23
- function branchNamed(
24
- children: TemplateNode[],
25
- which: 'then' | 'catch' | 'finally',
26
- ): Extract<TemplateNode, { kind: 'branch' }> | undefined {
27
- return children.find(
28
- (child): child is Extract<TemplateNode, { kind: 'branch' }> =>
29
- child.kind === 'branch' && child.branch === which,
30
- )
31
- }
32
-
33
25
  /*
34
26
  Server code generator: turns the parsed template into statements that push HTML
35
27
  fragments onto an output array, reading the document synchronously (no DOM, no
@@ -37,12 +29,14 @@ listeners). Same expression lowering as the client back-end, so server and clien
37
29
  render the same markup. Dynamic values go through `$esc`; `if` is a plain `if`,
38
30
  `each` a `for…of`.
39
31
 
40
- An `await` block emits boundary comments (`<!--abide:await:N-->…<!--/abide:await:N-->`)
41
- and registers the promise plus its resolved/error string-renderers on `$awaits`. A
42
- streaming block (no `then` on the tag) puts its pending branch between the markers;
43
- `renderToStream` flushes each resolved fragment out of order — the await-block-streams
44
- half of the cache rule. A blocking block (`then` on the tag) emits an empty boundary
45
- and flags the entry, so `renderToStream` settles it before the first flush.
32
+ An `await` block emits boundary comments (`<!--abide:await:N-->…<!--/abide:await:N-->`).
33
+ A streaming block (no `then` on the tag) puts its pending branch between the markers and
34
+ registers the promise + async resolved/error renderers on `$awaits`; `renderToStream`
35
+ flushes each resolved fragment out of order — the await-block-streams half of the cache
36
+ rule. A blocking block (`then` on the tag) is `await`ed INLINE at its structural position
37
+ and its resolved branch rendered between the markers, so the render body is async and its
38
+ id (`$ctx.next++`) allocates depth-first like the client; the resolved value lands in
39
+ `$resume`. Block ids draw from the request-local `$ctx`, shared with inlined child renders.
46
40
  */
47
41
  export function generateSSR(
48
42
  nodes: TemplateNode[],
@@ -52,7 +46,7 @@ export function generateSSR(
52
46
  isLayout = false,
53
47
  ): string {
54
48
  /* Compile-time counter for unique temp var names (runtime block ids, child render
55
- results) — block ids themselves are allocated at runtime via nextBlockId(). */
49
+ results) — block ids themselves are allocated at runtime via `$ctx.next++`. */
56
50
  let varCounter = 0
57
51
  const nextVar = (prefix: string): string => `${prefix}${varCounter++}`
58
52
 
@@ -82,6 +76,75 @@ export function generateSSR(
82
76
  through the generic element path exactly as the client clones it. */
83
77
  const rootNodes = isLayout ? nodes.map(asOutlet) : nodes
84
78
 
79
+ /* A snippet name (any identifier, `$` included) interpolated into a RegExp must have its
80
+ regex metacharacters escaped, or e.g. a trailing `$` would read as an end-anchor and the
81
+ call site would never match — leaving an un-awaited Promise stringified as `[object
82
+ Promise]`. */
83
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
84
+ /* A leading boundary that, unlike `\b`, also fires before a `$`-leading name: `\b$row`
85
+ never matches (`$` is a non-word char, so there is no word boundary before it), which
86
+ would silently miss every `$row(...)` call. A negative lookbehind for word-or-`$`
87
+ matches the same call sites as `\b` for word-leading names while also catching them. */
88
+ const callPattern = (name: string): RegExp =>
89
+ new RegExp(`(?<![$\\w])${escapeRegExp(name)}\\s*\\(`)
90
+ /* A subtree call to any of `names` from a TEXT interpolation (`name()` / `name(args)`). */
91
+ const subtreeCalls = (children: TemplateNode[], names: ReadonlySet<string>): boolean =>
92
+ children.some((child) => {
93
+ if (child.kind === 'text') {
94
+ return child.parts.some(
95
+ (part) =>
96
+ part.kind !== 'static' &&
97
+ [...names].some((name) => callPattern(name).test(part.code)),
98
+ )
99
+ }
100
+ return 'children' in child && subtreeCalls(child.children, names)
101
+ })
102
+
103
+ /* Snippet names whose body produces an `await`, so the snippet must be an `async function`
104
+ and its `{name(...)}` call sites awaited: it inlines a child component / holds an await
105
+ block (a structural scan), OR it text-calls another async snippet. The latter is a
106
+ dependency between snippets, so resolve it to a fixpoint — seed with the structural set,
107
+ then keep adding any snippet that calls an already-async one until nothing changes. */
108
+ const subtreeAwaits = (children: TemplateNode[]): boolean =>
109
+ children.some(
110
+ (child) =>
111
+ child.kind === 'component' ||
112
+ child.kind === 'await' ||
113
+ ('children' in child && subtreeAwaits(child.children)),
114
+ )
115
+ const snippetDefs = new Map<string, TemplateNode[]>()
116
+ const collectSnippetDefs = (children: TemplateNode[]): void => {
117
+ for (const child of children) {
118
+ if (child.kind === 'snippet') {
119
+ snippetDefs.set(child.name, child.children)
120
+ }
121
+ if ('children' in child) {
122
+ collectSnippetDefs(child.children)
123
+ }
124
+ }
125
+ }
126
+ collectSnippetDefs(rootNodes)
127
+ const asyncSnippets = new Set<string>()
128
+ for (const [name, children] of snippetDefs) {
129
+ if (subtreeAwaits(children)) {
130
+ asyncSnippets.add(name)
131
+ }
132
+ }
133
+ let grew = true
134
+ while (grew) {
135
+ grew = false
136
+ for (const [name, children] of snippetDefs) {
137
+ if (!asyncSnippets.has(name) && subtreeCalls(children, asyncSnippets)) {
138
+ asyncSnippets.add(name)
139
+ grew = true
140
+ }
141
+ }
142
+ }
143
+ /* A text-part expression that calls an async snippet, so its value is `await`ed before
144
+ `$text`. */
145
+ const callsAsyncSnippet = (code: string): boolean =>
146
+ [...asyncSnippets].some((name) => callPattern(name).test(code))
147
+
85
148
  /* Per-node skeleton position, computed once. Both back-ends read this single source of
86
149
  truth so their `<!--a-->` anchor placement cannot drift — the fresh-context boundaries
87
150
  (control-flow branches, component/slot/snippet content) are enumerated there, not
@@ -124,7 +187,14 @@ export function generateSSR(
124
187
  const markup = staticTextPart(part.value)
125
188
  return markup === '' ? '' : push(target, markup)
126
189
  }
127
- const value = `$text(${lowerExpression(part.code)})`
190
+ /* A call to an async snippet returns a Promise — `await` it before `$text`.
191
+ (The enclosing context is async: the render body and async-snippet bodies.)
192
+ Plain expressions stay sync, so a component with only interpolation keeps a
193
+ sync render. */
194
+ const lowered = lowerExpression(part.code)
195
+ const value = callsAsyncSnippet(part.code)
196
+ ? `$text(await (${lowered}))`
197
+ : `$text(${lowered})`
128
198
  return markText.get(node)
129
199
  ? `${target}.push('<!--a-->' + ${value});\n`
130
200
  : `${target}.push(${value});\n`
@@ -132,11 +202,19 @@ export function generateSSR(
132
202
  .join('')
133
203
  }
134
204
  if (node.kind === 'if') {
135
- const elseBranch = node.children.find((child) => child.kind === 'case')
205
+ /* `case` children are the `elseif`/`else` branches in source order; the rest are
206
+ the `then` content. Each `elseif` becomes an `else if`, the match-less `else`
207
+ the trailing `else`. */
208
+ const branches = node.children.filter(
209
+ (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
210
+ )
136
211
  const thenChildren = node.children.filter((child) => child.kind !== 'case')
137
212
  let code = `if (${lowerExpression(node.condition)}) {\n${branchContent(thenChildren, target)}}`
138
- if (elseBranch !== undefined && elseBranch.kind === 'case') {
139
- code += ` else {\n${branchContent(elseBranch.children, target)}}`
213
+ for (const branch of branches) {
214
+ code +=
215
+ branch.condition !== undefined
216
+ ? ` else if (${lowerExpression(branch.condition)}) {\n${branchContent(branch.children, target)}}`
217
+ : ` else {\n${branchContent(branch.children, target)}}`
140
218
  }
141
219
  return `${anchor}${openRange(target)}${code}\n${closeRange(target)}`
142
220
  }
@@ -165,7 +243,11 @@ export function generateSSR(
165
243
  /* A hoisted function returning the snippet's `$snip`-branded HTML string;
166
244
  `{name(args)}` pushes it via `$text`, which wraps it in markers. */
167
245
  const body = generateInto(node.children, '$o')
168
- return `function ${node.name}(${node.params ?? ''}) {\nconst $o = [];\n${body}return $snip($o.join(''));\n}\n`
246
+ /* `async` only when the body produces an `await` (it inlines a child component) — then
247
+ call sites `await` it (`$text(await frag())`). A sync snippet stays a plain function
248
+ called inline, preserving the sync render contract. */
249
+ const keyword = asyncSnippets.has(node.name) ? 'async function' : 'function'
250
+ return `${keyword} ${node.name}(${node.params ?? ''}) {\nconst $o = [];\n${body}return $snip($o.join(''));\n}\n`
169
251
  }
170
252
  if (node.kind === 'script') {
171
253
  /* A scoped reactive block: re-seed its local signals (lowered, in scope)
@@ -203,32 +285,42 @@ export function generateSSR(
203
285
  so SSR and client agree and the child's root lays out as a direct child. Props
204
286
  pass as thunks; slot content passes as a string-returning `$children` the child
205
287
  invokes from its <slot>. */
206
- const parts = node.props.map(
207
- (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
208
- )
209
288
  /* Slot content is a fresh build context — the child's `<slot>` mounts it via
210
289
  `mountSlot`, not the parent skeleton clone, and the client builds it through
211
- `componentParts`/`generateChildren` (never the skeleton path). `skeletonContext`
290
+ `propsArg`/`generateChildren` (never the skeleton path). `skeletonContext`
212
291
  records it reset, so its children emit no enclosing-skeleton anchors the client
213
292
  slot builder would lack. */
214
293
  const slotCode = generateInto(node.children, '$slot')
215
- if (slotCode.trim() !== '') {
216
- parts.push(
217
- `"$children": () => { const $slot = []; ${slotCode}return $slot.join(''); }`,
218
- )
219
- }
220
- /* Render the child and MERGE its await blocks into this page's `$awaits`
221
- so they join the page's SSR stream — their markers carry render-pass
222
- block ids (nextBlockId), unique across page + children, so the streamed
223
- fragments resolve into the right boundaries. ($awaits is captured from
224
- the enclosing render body, including from branch closures.) */
294
+ /* `$children` is an ASYNC builder the child `await`s at its `<slot>` position
295
+ (`generateSlot`), NOT a pre-resolved string. Pre-resolving here would run the
296
+ slot's `$ctx.next++` block ids BEFORE the child render's own, but the client
297
+ builds slot content lazily at the `<slot>` site — so a child with an await/try
298
+ before its `<slot>` would allocate ids in the opposite order and desync hydration.
299
+ Keeping the slot lazy makes the slot's ids draw at the `<slot>` site on both
300
+ sides. The builder shares the enclosing render's `$ctx`/`$awaits`/`$resume` (a
301
+ closure), so nested awaits register and number correctly during the child render.
302
+ A child with a `<slot>` is therefore always an async render. */
303
+ const slotPart =
304
+ slotCode.trim() === ''
305
+ ? undefined
306
+ : `"$children": async () => { const $slot = []; ${slotCode}return $slot.join(''); }`
307
+ /* The same last-wins layering the client build emits (`composeProps`), so SSR
308
+ and hydration read the same prop bag. */
309
+ const propsExpr = composeProps(node.props, lowerExpression, slotPart)
310
+ /* Render the child (awaited — render is async) sharing this render's `$ctx`,
311
+ so its `await`/`try` block ids draw from the same depth-first counter,
312
+ unique across page + children, and the streamed fragments resolve into the
313
+ right boundaries. MERGE its streaming awaits into `$awaits` and its inline
314
+ blocking values into `$resume`. ($awaits/$resume are captured from the
315
+ enclosing render body, including from branch closures.) */
225
316
  const result = nextVar('$child')
226
317
  return (
227
318
  anchor +
228
319
  push(target, RANGE_OPEN) +
229
- `const ${result} = ${node.name}.render({ ${parts.join(', ')} });\n` +
320
+ `const ${result} = await ${node.name}.render(${propsExpr}, $ctx);\n` +
230
321
  `${target}.push(${result}.html);\n` +
231
322
  `for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
323
+ `Object.assign($resume, ${result}.resume);\n` +
232
324
  push(target, RANGE_CLOSE)
233
325
  )
234
326
  }
@@ -262,6 +354,12 @@ export function generateSSR(
262
354
  /* present/absent semantics matching the client `attr` binding:
263
355
  false/null/undefined drops it, true emits the bare attribute. */
264
356
  code += `${target}.push($attr(${JSON.stringify(attr.name)}, ${lowerExpression(attr.code)}));\n`
357
+ } else if (attr.kind === 'spread') {
358
+ /* `{...expr}` element spread: each key as an attribute, functions (event
359
+ handlers) skipped — the client `spreadAttrs` wires those on hydrate. Keys
360
+ explicitly named on the element are skipped (the explicit attr wins), so
361
+ no duplicate attribute is emitted and the client DOM agrees. */
362
+ code += `${target}.push($spread(${lowerExpression(attr.code)}, ${JSON.stringify(spreadExcludedNames(node.attrs))}));\n`
265
363
  } else if (attr.kind === 'bind' && attr.property === 'group') {
266
364
  /* Render the checked state as a boolean attribute: present when the
267
365
  path holds (radio) or contains (checkbox) this control's value. */
@@ -304,57 +402,105 @@ export function generateSSR(
304
402
  ): string {
305
403
  const wrap = inSkeleton.get(node)
306
404
  const fallback = generateInto(node.children, target)
405
+ /* `$children` is an async builder the parent passes lazily; `await` it here so the
406
+ slot content's block ids allocate AT the `<slot>` position — the same order the
407
+ client builds slot content — keeping hydration congruent. The `await` makes a
408
+ component with a `<slot>` an async render (its caller already `await`s `render()`). */
307
409
  const body =
308
410
  fallback.trim() === ''
309
- ? `if ($props && $props.$children) { ${target}.push($props.$children()); }\n`
310
- : `if ($props && $props.$children) { ${target}.push($props.$children()); } else {\n${fallback}}\n`
411
+ ? `if ($props && $props.$children) { ${target}.push(await $props.$children()); }\n`
412
+ : `if ($props && $props.$children) { ${target}.push(await $props.$children()); } else {\n${fallback}}\n`
311
413
  if (!wrap) {
312
414
  return body
313
415
  }
314
416
  return `${anchor}${openRange(target)}${body}${closeRange(target)}`
315
417
  }
316
418
 
317
- /* Boundary markers + a `$awaits` registration carrying the promise and
318
- string-renderers for the resolved/error branches. Streaming emits the pending
319
- branch between the markers (flushed now, value streamed later); blocking emits
320
- an empty boundary — its resolved branch is the children bound to `node.as` — and
321
- flags the entry so `renderToStream` settles it before the first flush. */
419
+ /* A blocking await (`then` on the tag) renders INLINE during the async render pass;
420
+ a streaming await defers its resolved branch to `renderToStream`. */
322
421
  function generateAwait(node: Extract<TemplateNode, { kind: 'await' }>, target: string): string {
323
- const catchBranch = branchNamed(node.children, 'catch')
324
- const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
325
- /* Resolved branch + its bound value: the children directly when blocking, the
326
- `then` child when streaming. Pending (streaming only) is the non-branch
327
- children. */
328
- const thenBranch = branchNamed(node.children, 'then')
329
- const resolvedChildren = node.blocking
330
- ? node.children.filter((child) => child.kind !== 'branch')
331
- : (thenBranch?.children ?? [])
332
- const resolvedAs = node.blocking ? node.as : thenBranch?.as
333
- const pending = node.blocking
334
- ? []
335
- : node.children.filter((child) => child.kind !== 'branch')
336
- /* Runtime block id (shared with the client + child components in this pass). */
422
+ return node.blocking
423
+ ? generateBlockingAwait(node, target)
424
+ : generateStreamingAwait(node, target)
425
+ }
426
+
427
+ /*
428
+ A blocking await — `await`ed at its structural position in the async render pass and
429
+ its resolved branch rendered INLINE into `target`, between the boundary markers. This
430
+ matches the client (which runs `then` inline during adopt): one render pass, so the
431
+ block id (`$ctx.next++`) and any nested await's id are allocated depth-first in the
432
+ same order the client hydrates them, and the resolved value is captured into `$resume`
433
+ for the manifest. The resolved children bind `node.as`; `finally` appends after, matching
434
+ the client's concatenated node range. With no catch/finally branch a rejection propagates
435
+ (renderToStream / the 500 surfaces it); otherwise the catch branch renders and seeds an
436
+ error resume. The value `const` is block-scoped to its `try`, so sibling blocking awaits
437
+ that bind the same name don't collide.
438
+ */
439
+ function generateBlockingAwait(
440
+ node: Extract<TemplateNode, { kind: 'await' }>,
441
+ target: string,
442
+ ): string {
443
+ const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
444
+ const finallyChildren = finallyBranch?.children ?? []
445
+ const resolvedChildren = node.children.filter((child) => child.kind !== 'branch')
446
+ const resolvedAs = node.as ?? '_value'
447
+ const errorAs = catchBranch?.as ?? '_error'
448
+ const id = nextVar('$aid')
449
+ let code = `const ${id} = $ctx.next++;\n`
450
+ code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
451
+ code += `try {\n`
452
+ code += `const ${resolvedAs} = await (${lowerExpression(node.promise)});\n`
453
+ code += branchContent(resolvedChildren, target)
454
+ code += branchContent(finallyChildren, target)
455
+ code += `$resume[${id}] = { ok: true, value: ${resolvedAs} };\n`
456
+ if (catchBranch === undefined && finallyChildren.length === 0) {
457
+ /* No catch/finally → let the rejection surface instead of an empty branch. */
458
+ code += `} catch (_error) { throw _error; }\n`
459
+ } else {
460
+ code += `} catch (${errorAs}) {\n`
461
+ code += branchContent(catchBranch?.children ?? [], target)
462
+ code += branchContent(finallyChildren, target)
463
+ code += `$resume[${id}] = { ok: false, error: String(${errorAs}) };\n`
464
+ code += `}\n`
465
+ }
466
+ code += `${target}.push("<!--/abide:await:" + ${id} + "-->");\n`
467
+ return code
468
+ }
469
+
470
+ /* A streaming await — emits the pending branch between the markers (flushed now) and
471
+ registers the promise + async string-renderers on `$awaits`; `renderToStream`
472
+ flushes the resolved fragment out of order. The renderers are async so a nested
473
+ `await` block inside the branch composes. `finally` appends after the outcome,
474
+ matching the client's concatenated node range. Neither catch nor finally → omit
475
+ `catch` so a rejection surfaces (renderToStream re-throws); a finally-only block
476
+ keeps a catch renderer that renders just finally. */
477
+ function generateStreamingAwait(
478
+ node: Extract<TemplateNode, { kind: 'await' }>,
479
+ target: string,
480
+ ): string {
481
+ const [thenBranch, catchBranch, finallyBranch] = resolveBranches(
482
+ node,
483
+ 'then',
484
+ 'catch',
485
+ 'finally',
486
+ )
487
+ const finallyChildren = finallyBranch?.children ?? []
488
+ const pending = node.children.filter((child) => child.kind !== 'branch')
337
489
  const id = nextVar('$aid')
338
- let code = `const ${id} = nextBlockId();\n`
490
+ let code = `const ${id} = $ctx.next++;\n`
339
491
  code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
340
492
  code += branchContent(pending, target)
341
493
  code += `${target}.push("<!--/abide:await:" + ${id} + "-->");\n`
342
- /* The settled closures append `finally` after the outcome markup, matching the
343
- client's concatenated node range so hydration aligns. */
344
494
  const settled = (binding: string, children: TemplateNode[]) =>
345
- `(${binding}) => { const $o = []; ${branchContent(children, '$o')}${branchContent(finallyChildren, '$o')}return $o.join(''); }`
346
- /* Neither catch nor finally → omit `catch` so a rejection surfaces to the
347
- stream/error path (renderToStream re-throws) instead of rendering an empty
348
- branch. A finally-only block keeps a catch closure that renders just finally. */
495
+ `async (${binding}) => { const $o = []; ${branchContent(children, '$o')}${branchContent(finallyChildren, '$o')}return $o.join(''); }`
349
496
  const catchProp =
350
497
  catchBranch === undefined && finallyChildren.length === 0
351
498
  ? ''
352
499
  : `catch: ${settled(catchBranch?.as ?? '_error', catchBranch?.children ?? [])} `
353
500
  code +=
354
501
  `$awaits.push({ id: ${id}, ` +
355
- (node.blocking ? 'blocking: true, ' : '') +
356
502
  `promise: () => (${lowerExpression(node.promise)}), ` +
357
- `then: ${settled(resolvedAs ?? '_value', resolvedChildren)}, ` +
503
+ `then: ${settled(thenBranch?.as ?? '_value', thenBranch?.children ?? [])}, ` +
358
504
  `${catchProp}});\n`
359
505
  return code
360
506
  }
@@ -366,13 +512,13 @@ export function generateSSR(
366
512
  an enclosing boundary / the 500 / the stream). Boundary comments let hydration
367
513
  discard the server content if the client adoption fails. */
368
514
  function generateTry(node: Extract<TemplateNode, { kind: 'try' }>, target: string): string {
369
- const catchBranch = branchNamed(node.children, 'catch')
370
- const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
515
+ const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
516
+ const finallyChildren = finallyBranch?.children ?? []
371
517
  const guarded = node.children.filter((child) => child.kind !== 'branch')
372
518
  const errName = catchBranch?.as ?? '_error'
373
519
  const id = nextVar('$tid')
374
520
  const mark = nextVar('$trim')
375
- let code = `const ${id} = nextBlockId();\n`
521
+ let code = `const ${id} = $ctx.next++;\n`
376
522
  code += `${target}.push("<!--abide:try:" + ${id} + "-->");\n`
377
523
  code += `const ${mark} = ${target}.length;\n`
378
524
  code += `try {\n`
@@ -0,0 +1,11 @@
1
+ import type { TemplateNode } from './types/TemplateNode.ts'
2
+
3
+ /* A text node that is purely whitespace (no interpolation, only blank static
4
+ parts). Both back-ends drop it, so it neither contributes markup nor breaks a
5
+ static clone run — it stays transparent so `<a/>\n<b/>` still coalesces. */
6
+ export function isWhitespaceText(node: TemplateNode): boolean {
7
+ return (
8
+ node.kind === 'text' &&
9
+ node.parts.every((part) => part.kind === 'static' && part.value.trim() === '')
10
+ )
11
+ }
@@ -107,35 +107,72 @@ function docAccessTransformer(docName: string): ts.TransformerFactory<ts.SourceF
107
107
  }
108
108
  }
109
109
  }
110
- /* doc array `.push(x)` → add patch at the array's `-` slot. */
110
+ /* doc array `.push(a, b, …)` → one `add` patch per argument at the array's `-`
111
+ slot, matching native multi-arg push (each lands at the end in order). */
111
112
  if (
112
113
  ts.isCallExpression(node) &&
113
114
  ts.isPropertyAccessExpression(node.expression) &&
114
115
  node.expression.name.text === 'push'
115
116
  ) {
116
117
  const segments = pathSegments(node.expression.expression)
117
- const pushed = node.arguments[0]
118
- if (segments && pushed !== undefined) {
119
- return docCall(docName, 'add', [
120
- buildPath([...segments, { kind: 'literal', value: '-' }]),
121
- ts.visitNode(pushed, visit) as ts.Expression,
122
- ])
118
+ if (segments && node.arguments.length > 0) {
119
+ const adds = node.arguments.map(
120
+ (pushed) =>
121
+ docCall(docName, 'add', [
122
+ buildPath([...segments, { kind: 'literal', value: '-' }]),
123
+ ts.visitNode(pushed, visit) as ts.Expression,
124
+ ]) as ts.Expression,
125
+ )
126
+ /* `node.arguments.length > 0` above guarantees `adds` is non-empty, so the
127
+ single-element branch's `adds[0]` is defined. */
128
+ return adds.length === 1 ? adds[0]! : ts.factory.createCommaListExpression(adds)
123
129
  }
124
130
  }
125
131
  /* A called member on a doc chain is a method on the read value, not a
126
- deeper path: `model.draft.trim()` → `model.read("draft").trim()`,
127
- `model.items.map(f)` → `model.read("items").map(f)`. (Array `.push`
132
+ deeper path: `model.draft.trim()` → `readCall(model.read("draft"), …)`,
133
+ `model.items.map(f)` → `readCall(model.read("items"), …)`. (Array `.push`
128
134
  above is the exception — it lowers to an `add` patch.) */
129
135
  if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
130
- const segments = pathSegments(node.expression.expression)
136
+ const access = node.expression
137
+ const segments = pathSegments(access.expression)
131
138
  if (segments) {
139
+ const read = docCall(docName, 'read', [buildPath(segments)])
140
+ const args = node.arguments.map(
141
+ (arg) => ts.visitNode(arg, visit) as ts.Expression,
142
+ )
143
+ /* Optional chaining is the author's explicit skip-if-absent: a nullish
144
+ read short-circuits the whole call to `undefined`. Keep it bare —
145
+ routing it through the throwing guard would invert that semantics.
146
+ The call continues the chain whenever the access OR the call carries a
147
+ `?.`, so `model.x?.m()` short-circuits the call too. */
148
+ if (access.questionDotToken || node.questionDotToken) {
149
+ const member = access.questionDotToken
150
+ ? ts.factory.createPropertyAccessChain(
151
+ read,
152
+ access.questionDotToken,
153
+ access.name,
154
+ )
155
+ : ts.factory.createPropertyAccessExpression(read, access.name.text)
156
+ return ts.factory.createCallChain(
157
+ member,
158
+ node.questionDotToken,
159
+ node.typeArguments,
160
+ args,
161
+ )
162
+ }
163
+ /* A non-optional call on an absent read throws the engine's opaque
164
+ `undefined is not an object`. Route it through `readCall`, which throws
165
+ naming the authored scope path and member — the key and member are both
166
+ in hand here, the `.abide` location comes free off the mapped stack. */
132
167
  return ts.factory.createCallExpression(
133
- ts.factory.createPropertyAccessExpression(
134
- docCall(docName, 'read', [buildPath(segments)]),
135
- node.expression.name.text,
136
- ),
137
- node.typeArguments,
138
- node.arguments.map((arg) => ts.visitNode(arg, visit) as ts.Expression),
168
+ ts.factory.createIdentifier('readCall'),
169
+ undefined,
170
+ [
171
+ read,
172
+ buildPath(segments),
173
+ ts.factory.createStringLiteral(access.name.text),
174
+ ts.factory.createArrayLiteralExpression(args),
175
+ ],
139
176
  )
140
177
  }
141
178
  }