@abide/abide 0.38.0 → 0.39.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 (82) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +24 -0
  3. package/package.json +10 -1
  4. package/src/build.ts +14 -0
  5. package/src/lib/bundle/exitWithParent.ts +4 -2
  6. package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
  7. package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
  8. package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
  9. package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
  10. package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
  11. package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
  12. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -0
  13. package/src/lib/shared/createRemoteFunction.ts +20 -11
  14. package/src/lib/shared/escapeHtml.ts +15 -0
  15. package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
  16. package/src/lib/shared/streamResponse.ts +8 -1
  17. package/src/lib/shared/types/RemoteCallable.ts +12 -3
  18. package/src/lib/shared/types/RpcOptions.ts +22 -0
  19. package/src/lib/shared/types/SourceMap.ts +14 -0
  20. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -3
  21. package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
  22. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +6 -0
  23. package/src/lib/ui/compile/asOutlet.ts +6 -5
  24. package/src/lib/ui/compile/compileModule.ts +18 -11
  25. package/src/lib/ui/compile/compileSSR.ts +32 -9
  26. package/src/lib/ui/compile/compileShadow.ts +11 -3
  27. package/src/lib/ui/compile/composeProps.ts +53 -0
  28. package/src/lib/ui/compile/desugarSignals.ts +45 -17
  29. package/src/lib/ui/compile/generateBuild.ts +100 -61
  30. package/src/lib/ui/compile/generateSSR.ts +226 -73
  31. package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
  32. package/src/lib/ui/compile/isControlFlow.ts +3 -1
  33. package/src/lib/ui/compile/isTextLeaf.ts +1 -1
  34. package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
  35. package/src/lib/ui/compile/parseTemplate.ts +44 -1
  36. package/src/lib/ui/compile/skeletonContext.ts +19 -20
  37. package/src/lib/ui/compile/skeletonable.ts +3 -2
  38. package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
  39. package/src/lib/ui/compile/staticAttr.ts +1 -1
  40. package/src/lib/ui/compile/staticTextPart.ts +1 -1
  41. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
  42. package/src/lib/ui/compile/types/TemplateNode.ts +3 -1
  43. package/src/lib/ui/dom/commentData.ts +14 -0
  44. package/src/lib/ui/dom/disposeRange.ts +21 -0
  45. package/src/lib/ui/dom/fillBoundary.ts +38 -0
  46. package/src/lib/ui/dom/fillRange.ts +30 -0
  47. package/src/lib/ui/dom/hydrate.ts +11 -21
  48. package/src/lib/ui/dom/mergeProps.ts +32 -0
  49. package/src/lib/ui/dom/mount.ts +16 -25
  50. package/src/lib/ui/dom/mountChild.ts +27 -14
  51. package/src/lib/ui/dom/mountRange.ts +45 -0
  52. package/src/lib/ui/dom/outlet.ts +62 -0
  53. package/src/lib/ui/dom/readCall.ts +27 -0
  54. package/src/lib/ui/dom/restProps.ts +32 -0
  55. package/src/lib/ui/dom/scopeLabel.ts +8 -6
  56. package/src/lib/ui/dom/skeleton.ts +11 -30
  57. package/src/lib/ui/dom/spreadAttrs.ts +34 -0
  58. package/src/lib/ui/dom/spreadProps.ts +32 -0
  59. package/src/lib/ui/dom/withScope.ts +33 -0
  60. package/src/lib/ui/installHotBridge.ts +12 -0
  61. package/src/lib/ui/remoteProxy.ts +68 -36
  62. package/src/lib/ui/renderChain.ts +49 -39
  63. package/src/lib/ui/renderToStream.ts +69 -61
  64. package/src/lib/ui/resumeSeedScript.ts +17 -0
  65. package/src/lib/ui/router.ts +155 -85
  66. package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
  67. package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
  68. package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
  69. package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
  70. package/src/lib/ui/runtime/createEffectNode.ts +5 -0
  71. package/src/lib/ui/runtime/hotReplace.ts +14 -10
  72. package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
  73. package/src/lib/ui/runtime/toTeardown.ts +10 -5
  74. package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
  75. package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
  76. package/src/lib/ui/runtime/types/Route.ts +6 -5
  77. package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
  78. package/src/lib/ui/runtime/types/UiComponent.ts +12 -1
  79. package/src/lib/ui/compile/componentWrapperTag.ts +0 -15
  80. package/src/lib/ui/compile/escapeHtml.ts +0 -15
  81. package/src/lib/ui/runtime/firstOutlet.ts +0 -22
  82. /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
@@ -1,11 +1,13 @@
1
+ import { OUTLET_CLOSE, OUTLET_OPEN } from '../runtime/OUTLET_MARKER.ts'
1
2
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
3
  import { asOutlet } from './asOutlet.ts'
3
- import { componentWrapperTag } from './componentWrapperTag.ts'
4
+ import { composeProps } from './composeProps.ts'
4
5
  import { groupBindParts } from './groupBindParts.ts'
5
- import { isControlFlow } from './isControlFlow.ts'
6
+ import { isAnchorPositioned } from './isAnchorPositioned.ts'
6
7
  import { lowerContext } from './lowerContext.ts'
7
8
  import { scopeAttr } from './scopeAttr.ts'
8
9
  import { skeletonContext } from './skeletonContext.ts'
10
+ import { spreadExcludedNames } from './spreadExcludedNames.ts'
9
11
  import { staticAttr } from './staticAttr.ts'
10
12
  import { staticTextPart } from './staticTextPart.ts'
11
13
  import { stripEffects } from './stripEffects.ts'
@@ -37,12 +39,14 @@ listeners). Same expression lowering as the client back-end, so server and clien
37
39
  render the same markup. Dynamic values go through `$esc`; `if` is a plain `if`,
38
40
  `each` a `for…of`.
39
41
 
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.
42
+ An `await` block emits boundary comments (`<!--abide:await:N-->…<!--/abide:await:N-->`).
43
+ A streaming block (no `then` on the tag) puts its pending branch between the markers and
44
+ registers the promise + async resolved/error renderers on `$awaits`; `renderToStream`
45
+ flushes each resolved fragment out of order — the await-block-streams half of the cache
46
+ rule. A blocking block (`then` on the tag) is `await`ed INLINE at its structural position
47
+ and its resolved branch rendered between the markers, so the render body is async and its
48
+ id (`$ctx.next++`) allocates depth-first like the client; the resolved value lands in
49
+ `$resume`. Block ids draw from the request-local `$ctx`, shared with inlined child renders.
46
50
  */
47
51
  export function generateSSR(
48
52
  nodes: TemplateNode[],
@@ -52,7 +56,7 @@ export function generateSSR(
52
56
  isLayout = false,
53
57
  ): string {
54
58
  /* Compile-time counter for unique temp var names (runtime block ids, child render
55
- results) — block ids themselves are allocated at runtime via nextBlockId(). */
59
+ results) — block ids themselves are allocated at runtime via `$ctx.next++`. */
56
60
  let varCounter = 0
57
61
  const nextVar = (prefix: string): string => `${prefix}${varCounter++}`
58
62
 
@@ -82,6 +86,75 @@ export function generateSSR(
82
86
  through the generic element path exactly as the client clones it. */
83
87
  const rootNodes = isLayout ? nodes.map(asOutlet) : nodes
84
88
 
89
+ /* A snippet name (any identifier, `$` included) interpolated into a RegExp must have its
90
+ regex metacharacters escaped, or e.g. a trailing `$` would read as an end-anchor and the
91
+ call site would never match — leaving an un-awaited Promise stringified as `[object
92
+ Promise]`. */
93
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
94
+ /* A leading boundary that, unlike `\b`, also fires before a `$`-leading name: `\b$row`
95
+ never matches (`$` is a non-word char, so there is no word boundary before it), which
96
+ would silently miss every `$row(...)` call. A negative lookbehind for word-or-`$`
97
+ matches the same call sites as `\b` for word-leading names while also catching them. */
98
+ const callPattern = (name: string): RegExp =>
99
+ new RegExp(`(?<![$\\w])${escapeRegExp(name)}\\s*\\(`)
100
+ /* A subtree call to any of `names` from a TEXT interpolation (`name()` / `name(args)`). */
101
+ const subtreeCalls = (children: TemplateNode[], names: ReadonlySet<string>): boolean =>
102
+ children.some((child) => {
103
+ if (child.kind === 'text') {
104
+ return child.parts.some(
105
+ (part) =>
106
+ part.kind !== 'static' &&
107
+ [...names].some((name) => callPattern(name).test(part.code)),
108
+ )
109
+ }
110
+ return 'children' in child && subtreeCalls(child.children, names)
111
+ })
112
+
113
+ /* Snippet names whose body produces an `await`, so the snippet must be an `async function`
114
+ and its `{name(...)}` call sites awaited: it inlines a child component / holds an await
115
+ block (a structural scan), OR it text-calls another async snippet. The latter is a
116
+ dependency between snippets, so resolve it to a fixpoint — seed with the structural set,
117
+ then keep adding any snippet that calls an already-async one until nothing changes. */
118
+ const subtreeAwaits = (children: TemplateNode[]): boolean =>
119
+ children.some(
120
+ (child) =>
121
+ child.kind === 'component' ||
122
+ child.kind === 'await' ||
123
+ ('children' in child && subtreeAwaits(child.children)),
124
+ )
125
+ const snippetDefs = new Map<string, TemplateNode[]>()
126
+ const collectSnippetDefs = (children: TemplateNode[]): void => {
127
+ for (const child of children) {
128
+ if (child.kind === 'snippet') {
129
+ snippetDefs.set(child.name, child.children)
130
+ }
131
+ if ('children' in child) {
132
+ collectSnippetDefs(child.children)
133
+ }
134
+ }
135
+ }
136
+ collectSnippetDefs(rootNodes)
137
+ const asyncSnippets = new Set<string>()
138
+ for (const [name, children] of snippetDefs) {
139
+ if (subtreeAwaits(children)) {
140
+ asyncSnippets.add(name)
141
+ }
142
+ }
143
+ let grew = true
144
+ while (grew) {
145
+ grew = false
146
+ for (const [name, children] of snippetDefs) {
147
+ if (!asyncSnippets.has(name) && subtreeCalls(children, asyncSnippets)) {
148
+ asyncSnippets.add(name)
149
+ grew = true
150
+ }
151
+ }
152
+ }
153
+ /* A text-part expression that calls an async snippet, so its value is `await`ed before
154
+ `$text`. */
155
+ const callsAsyncSnippet = (code: string): boolean =>
156
+ [...asyncSnippets].some((name) => callPattern(name).test(code))
157
+
85
158
  /* Per-node skeleton position, computed once. Both back-ends read this single source of
86
159
  truth so their `<!--a-->` anchor placement cannot drift — the fresh-context boundaries
87
160
  (control-flow branches, component/slot/snippet content) are enumerated there, not
@@ -111,12 +184,12 @@ export function generateSSR(
111
184
  inSkeleton.get(node) ? push(target, '<!--a-->') : ''
112
185
 
113
186
  function generate(node: TemplateNode, target: string): string {
114
- /* A control-flow block is positioned by an `<!--a-->` anchor when in a skeleton
115
- context. Emit it ONCE here one decision site gated by the shared `isControlFlow`,
116
- mirroring the client's single `skeletonMarkup` control-flow branch rather than
117
- prefixing each block kind's own `anchorMark` call (six sites that had to stay in
118
- lock-step). `anchorMark` no-ops outside a skeleton, so non-block nodes ignore it. */
119
- const anchor = isControlFlow(node) ? anchorMark(node, target) : ''
187
+ /* Every kind that mounts as a marker range is positioned by an `<!--a-->` anchor when
188
+ in a skeleton context: control-flow blocks, child components, and a layout's outlet /
189
+ a component's `<slot>` (both elements). `isAnchorPositioned` is the ONE decision site
190
+ (mirrored by the client's `skeletonMarkup`); `anchorMark` no-ops outside a skeleton,
191
+ so non-anchored nodes ignore the precomputed `anchor`. */
192
+ const anchor = isAnchorPositioned(node) ? anchorMark(node, target) : ''
120
193
  if (node.kind === 'text') {
121
194
  return node.parts
122
195
  .map((part) => {
@@ -124,7 +197,14 @@ export function generateSSR(
124
197
  const markup = staticTextPart(part.value)
125
198
  return markup === '' ? '' : push(target, markup)
126
199
  }
127
- const value = `$text(${lowerExpression(part.code)})`
200
+ /* A call to an async snippet returns a Promise — `await` it before `$text`.
201
+ (The enclosing context is async: the render body and async-snippet bodies.)
202
+ Plain expressions stay sync, so a component with only interpolation keeps a
203
+ sync render. */
204
+ const lowered = lowerExpression(part.code)
205
+ const value = callsAsyncSnippet(part.code)
206
+ ? `$text(await (${lowered}))`
207
+ : `$text(${lowered})`
128
208
  return markText.get(node)
129
209
  ? `${target}.push('<!--a-->' + ${value});\n`
130
210
  : `${target}.push(${value});\n`
@@ -165,7 +245,11 @@ export function generateSSR(
165
245
  /* A hoisted function returning the snippet's `$snip`-branded HTML string;
166
246
  `{name(args)}` pushes it via `$text`, which wraps it in markers. */
167
247
  const body = generateInto(node.children, '$o')
168
- return `function ${node.name}(${node.params ?? ''}) {\nconst $o = [];\n${body}return $snip($o.join(''));\n}\n`
248
+ /* `async` only when the body produces an `await` (it inlines a child component) — then
249
+ call sites `await` it (`$text(await frag())`). A sync snippet stays a plain function
250
+ called inline, preserving the sync render contract. */
251
+ const keyword = asyncSnippets.has(node.name) ? 'async function' : 'function'
252
+ return `${keyword} ${node.name}(${node.params ?? ''}) {\nconst $o = [];\n${body}return $snip($o.join(''));\n}\n`
169
253
  }
170
254
  if (node.kind === 'script') {
171
255
  /* A scoped reactive block: re-seed its local signals (lowered, in scope)
@@ -198,48 +282,66 @@ export function generateSSR(
198
282
  return ''
199
283
  }
200
284
  if (node.kind === 'component') {
201
- /* Server-render the child via its `render` and inline the HTML inside
202
- the same wrapper the client mounts into, so SSR and client agree.
203
- Props pass as thunks; slot content passes as a string-returning
204
- `$children` the child invokes from its <slot>. */
205
- const tag = componentWrapperTag(node.name)
206
- const parts = node.props.map(
207
- (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
208
- )
285
+ /* Server-render the child via its `render` and inline the HTML inside the same
286
+ `[ ]` marker range the client mounts into (`mountRange`) no wrapper element,
287
+ so SSR and client agree and the child's root lays out as a direct child. Props
288
+ pass as thunks; slot content passes as a string-returning `$children` the child
289
+ invokes from its <slot>. */
209
290
  /* Slot content is a fresh build context — the child's `<slot>` mounts it via
210
291
  `mountSlot`, not the parent skeleton clone, and the client builds it through
211
- `componentParts`/`generateChildren` (never the skeleton path). `skeletonContext`
292
+ `propsArg`/`generateChildren` (never the skeleton path). `skeletonContext`
212
293
  records it reset, so its children emit no enclosing-skeleton anchors the client
213
294
  slot builder would lack. */
214
295
  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.) */
296
+ /* `$children` is an ASYNC builder the child `await`s at its `<slot>` position
297
+ (`generateSlot`), NOT a pre-resolved string. Pre-resolving here would run the
298
+ slot's `$ctx.next++` block ids BEFORE the child render's own, but the client
299
+ builds slot content lazily at the `<slot>` site — so a child with an await/try
300
+ before its `<slot>` would allocate ids in the opposite order and desync hydration.
301
+ Keeping the slot lazy makes the slot's ids draw at the `<slot>` site on both
302
+ sides. The builder shares the enclosing render's `$ctx`/`$awaits`/`$resume` (a
303
+ closure), so nested awaits register and number correctly during the child render.
304
+ A child with a `<slot>` is therefore always an async render. */
305
+ const slotPart =
306
+ slotCode.trim() === ''
307
+ ? undefined
308
+ : `"$children": async () => { const $slot = []; ${slotCode}return $slot.join(''); }`
309
+ /* The same last-wins layering the client build emits (`composeProps`), so SSR
310
+ and hydration read the same prop bag. */
311
+ const propsExpr = composeProps(node.props, lowerExpression, slotPart)
312
+ /* Render the child (awaited — render is async) sharing this render's `$ctx`,
313
+ so its `await`/`try` block ids draw from the same depth-first counter,
314
+ unique across page + children, and the streamed fragments resolve into the
315
+ right boundaries. MERGE its streaming awaits into `$awaits` and its inline
316
+ blocking values into `$resume`. ($awaits/$resume are captured from the
317
+ enclosing render body, including from branch closures.) */
225
318
  const result = nextVar('$child')
226
319
  return (
227
- push(target, `<${tag} style="display:contents">`) +
228
- `const ${result} = ${node.name}.render({ ${parts.join(', ')} });\n` +
320
+ anchor +
321
+ push(target, RANGE_OPEN) +
322
+ `const ${result} = await ${node.name}.render(${propsExpr}, $ctx);\n` +
229
323
  `${target}.push(${result}.html);\n` +
230
324
  `for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
231
- push(target, `</${tag}>`)
325
+ `Object.assign($resume, ${result}.resume);\n` +
326
+ push(target, RANGE_CLOSE)
232
327
  )
233
328
  }
329
+ if (node.kind === 'element' && node.tag === OUTLET_TAG) {
330
+ /* A layout's router fill point (`asOutlet` rewrote its `<slot/>`): an `<!--a-->`
331
+ anchor (in a skeleton) + an empty `<!--abide:outlet-->`…`<!--/abide:outlet-->`
332
+ boundary the chain composer folds the child layer into (`renderChain`) and the
333
+ client router fills/hydrates (`outlet`/`fillBoundary`) — no wrapper element. */
334
+ return anchor + push(target, `<!--${OUTLET_OPEN}--><!--${OUTLET_CLOSE}-->`)
335
+ }
234
336
  if (node.kind === 'element' && node.tag === 'slot') {
235
337
  /* `asOutlet` already rewrote a layout's top-level/element-nested `<slot/>` to an
236
- `OUTLET_TAG` element (handled by the generic path below), so a `slot` node reaching
237
- here in a layout is control-flow-nested — emit the same bare outlet placeholder the
238
- client's nested-slot path clones, which the chain composer folds the child into. */
338
+ `OUTLET_TAG` element (handled above), so a `slot` node reaching here in a layout
339
+ is control-flow-nested — emit the same empty outlet boundary the client's
340
+ control-flow-nested path builds, which the chain composer folds the child into. */
239
341
  if (isLayout) {
240
- return push(target, `<${OUTLET_TAG}></${OUTLET_TAG}>`)
342
+ return push(target, `<!--${OUTLET_OPEN}--><!--${OUTLET_CLOSE}-->`)
241
343
  }
242
- return generateSlot(node, target)
344
+ return generateSlot(node, target, anchor)
243
345
  }
244
346
  let code = push(target, `<${node.tag}`)
245
347
  /* Every `<style>` active at this element (own siblings + ancestors) — same set
@@ -254,6 +356,12 @@ export function generateSSR(
254
356
  /* present/absent semantics matching the client `attr` binding:
255
357
  false/null/undefined drops it, true emits the bare attribute. */
256
358
  code += `${target}.push($attr(${JSON.stringify(attr.name)}, ${lowerExpression(attr.code)}));\n`
359
+ } else if (attr.kind === 'spread') {
360
+ /* `{...expr}` element spread: each key as an attribute, functions (event
361
+ handlers) skipped — the client `spreadAttrs` wires those on hydrate. Keys
362
+ explicitly named on the element are skipped (the explicit attr wins), so
363
+ no duplicate attribute is emitted and the client DOM agrees. */
364
+ code += `${target}.push($spread(${lowerExpression(attr.code)}, ${JSON.stringify(spreadExcludedNames(node.attrs))}));\n`
257
365
  } else if (attr.kind === 'bind' && attr.property === 'group') {
258
366
  /* Render the checked state as a boolean attribute: present when the
259
367
  path holds (radio) or contains (checkbox) this control's value. */
@@ -292,60 +400,105 @@ export function generateSSR(
292
400
  function generateSlot(
293
401
  node: Extract<TemplateNode, { kind: 'element' }>,
294
402
  target: string,
403
+ anchor: string,
295
404
  ): string {
296
405
  const wrap = inSkeleton.get(node)
297
406
  const fallback = generateInto(node.children, target)
407
+ /* `$children` is an async builder the parent passes lazily; `await` it here so the
408
+ slot content's block ids allocate AT the `<slot>` position — the same order the
409
+ client builds slot content — keeping hydration congruent. The `await` makes a
410
+ component with a `<slot>` an async render (its caller already `await`s `render()`). */
298
411
  const body =
299
412
  fallback.trim() === ''
300
- ? `if ($props && $props.$children) { ${target}.push($props.$children()); }\n`
301
- : `if ($props && $props.$children) { ${target}.push($props.$children()); } else {\n${fallback}}\n`
413
+ ? `if ($props && $props.$children) { ${target}.push(await $props.$children()); }\n`
414
+ : `if ($props && $props.$children) { ${target}.push(await $props.$children()); } else {\n${fallback}}\n`
302
415
  if (!wrap) {
303
416
  return body
304
417
  }
305
- return `${anchorMark(node, target)}${openRange(target)}${body}${closeRange(target)}`
418
+ return `${anchor}${openRange(target)}${body}${closeRange(target)}`
306
419
  }
307
420
 
308
- /* Boundary markers + a `$awaits` registration carrying the promise and
309
- string-renderers for the resolved/error branches. Streaming emits the pending
310
- branch between the markers (flushed now, value streamed later); blocking emits
311
- an empty boundary — its resolved branch is the children bound to `node.as` — and
312
- flags the entry so `renderToStream` settles it before the first flush. */
421
+ /* A blocking await (`then` on the tag) renders INLINE during the async render pass;
422
+ a streaming await defers its resolved branch to `renderToStream`. */
313
423
  function generateAwait(node: Extract<TemplateNode, { kind: 'await' }>, target: string): string {
424
+ return node.blocking
425
+ ? generateBlockingAwait(node, target)
426
+ : generateStreamingAwait(node, target)
427
+ }
428
+
429
+ /*
430
+ A blocking await — `await`ed at its structural position in the async render pass and
431
+ its resolved branch rendered INLINE into `target`, between the boundary markers. This
432
+ matches the client (which runs `then` inline during adopt): one render pass, so the
433
+ block id (`$ctx.next++`) and any nested await's id are allocated depth-first in the
434
+ same order the client hydrates them, and the resolved value is captured into `$resume`
435
+ for the manifest. The resolved children bind `node.as`; `finally` appends after, matching
436
+ the client's concatenated node range. With no catch/finally branch a rejection propagates
437
+ (renderToStream / the 500 surfaces it); otherwise the catch branch renders and seeds an
438
+ error resume. The value `const` is block-scoped to its `try`, so sibling blocking awaits
439
+ that bind the same name don't collide.
440
+ */
441
+ function generateBlockingAwait(
442
+ node: Extract<TemplateNode, { kind: 'await' }>,
443
+ target: string,
444
+ ): string {
445
+ const catchBranch = branchNamed(node.children, 'catch')
446
+ const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
447
+ const resolvedChildren = node.children.filter((child) => child.kind !== 'branch')
448
+ const resolvedAs = node.as ?? '_value'
449
+ const errorAs = catchBranch?.as ?? '_error'
450
+ const id = nextVar('$aid')
451
+ let code = `const ${id} = $ctx.next++;\n`
452
+ code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
453
+ code += `try {\n`
454
+ code += `const ${resolvedAs} = await (${lowerExpression(node.promise)});\n`
455
+ code += branchContent(resolvedChildren, target)
456
+ code += branchContent(finallyChildren, target)
457
+ code += `$resume[${id}] = { ok: true, value: ${resolvedAs} };\n`
458
+ if (catchBranch === undefined && finallyChildren.length === 0) {
459
+ /* No catch/finally → let the rejection surface instead of an empty branch. */
460
+ code += `} catch (_error) { throw _error; }\n`
461
+ } else {
462
+ code += `} catch (${errorAs}) {\n`
463
+ code += branchContent(catchBranch?.children ?? [], target)
464
+ code += branchContent(finallyChildren, target)
465
+ code += `$resume[${id}] = { ok: false, error: String(${errorAs}) };\n`
466
+ code += `}\n`
467
+ }
468
+ code += `${target}.push("<!--/abide:await:" + ${id} + "-->");\n`
469
+ return code
470
+ }
471
+
472
+ /* A streaming await — emits the pending branch between the markers (flushed now) and
473
+ registers the promise + async string-renderers on `$awaits`; `renderToStream`
474
+ flushes the resolved fragment out of order. The renderers are async so a nested
475
+ `await` block inside the branch composes. `finally` appends after the outcome,
476
+ matching the client's concatenated node range. Neither catch nor finally → omit
477
+ `catch` so a rejection surfaces (renderToStream re-throws); a finally-only block
478
+ keeps a catch renderer that renders just finally. */
479
+ function generateStreamingAwait(
480
+ node: Extract<TemplateNode, { kind: 'await' }>,
481
+ target: string,
482
+ ): string {
314
483
  const catchBranch = branchNamed(node.children, 'catch')
315
484
  const finallyChildren = branchNamed(node.children, 'finally')?.children ?? []
316
- /* Resolved branch + its bound value: the children directly when blocking, the
317
- `then` child when streaming. Pending (streaming only) is the non-branch
318
- children. */
319
485
  const thenBranch = branchNamed(node.children, 'then')
320
- const resolvedChildren = node.blocking
321
- ? node.children.filter((child) => child.kind !== 'branch')
322
- : (thenBranch?.children ?? [])
323
- const resolvedAs = node.blocking ? node.as : thenBranch?.as
324
- const pending = node.blocking
325
- ? []
326
- : node.children.filter((child) => child.kind !== 'branch')
327
- /* Runtime block id (shared with the client + child components in this pass). */
486
+ const pending = node.children.filter((child) => child.kind !== 'branch')
328
487
  const id = nextVar('$aid')
329
- let code = `const ${id} = nextBlockId();\n`
488
+ let code = `const ${id} = $ctx.next++;\n`
330
489
  code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
331
490
  code += branchContent(pending, target)
332
491
  code += `${target}.push("<!--/abide:await:" + ${id} + "-->");\n`
333
- /* The settled closures append `finally` after the outcome markup, matching the
334
- client's concatenated node range so hydration aligns. */
335
492
  const settled = (binding: string, children: TemplateNode[]) =>
336
- `(${binding}) => { const $o = []; ${branchContent(children, '$o')}${branchContent(finallyChildren, '$o')}return $o.join(''); }`
337
- /* Neither catch nor finally → omit `catch` so a rejection surfaces to the
338
- stream/error path (renderToStream re-throws) instead of rendering an empty
339
- branch. A finally-only block keeps a catch closure that renders just finally. */
493
+ `async (${binding}) => { const $o = []; ${branchContent(children, '$o')}${branchContent(finallyChildren, '$o')}return $o.join(''); }`
340
494
  const catchProp =
341
495
  catchBranch === undefined && finallyChildren.length === 0
342
496
  ? ''
343
497
  : `catch: ${settled(catchBranch?.as ?? '_error', catchBranch?.children ?? [])} `
344
498
  code +=
345
499
  `$awaits.push({ id: ${id}, ` +
346
- (node.blocking ? 'blocking: true, ' : '') +
347
500
  `promise: () => (${lowerExpression(node.promise)}), ` +
348
- `then: ${settled(resolvedAs ?? '_value', resolvedChildren)}, ` +
501
+ `then: ${settled(thenBranch?.as ?? '_value', thenBranch?.children ?? [])}, ` +
349
502
  `${catchProp}});\n`
350
503
  return code
351
504
  }
@@ -363,7 +516,7 @@ export function generateSSR(
363
516
  const errName = catchBranch?.as ?? '_error'
364
517
  const id = nextVar('$tid')
365
518
  const mark = nextVar('$trim')
366
- let code = `const ${id} = nextBlockId();\n`
519
+ let code = `const ${id} = $ctx.next++;\n`
367
520
  code += `${target}.push("<!--abide:try:" + ${id} + "-->");\n`
368
521
  code += `const ${mark} = ${target}.length;\n`
369
522
  code += `try {\n`
@@ -0,0 +1,19 @@
1
+ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
+ import { isControlFlow } from './isControlFlow.ts'
3
+ import type { TemplateNode } from './types/TemplateNode.ts'
4
+
5
+ /*
6
+ The node kinds that mount as a marker range and so take an `<!--a-->` anchor when in a
7
+ skeleton — control-flow, child component, and the two outlet elements (a layout's
8
+ `OUTLET_TAG`, a component's `<slot>`). The single positioning predicate every back-end
9
+ shares — `generateSSR` gates its anchor emission on it, `skeletonContext` gates its anchor
10
+ indexing on it — so SSR, the client clone, and the shared numberer cannot disagree on which
11
+ nodes anchor.
12
+ */
13
+ export function isAnchorPositioned(node: TemplateNode): boolean {
14
+ return (
15
+ isControlFlow(node) ||
16
+ node.kind === 'component' ||
17
+ (node.kind === 'element' && (node.tag === OUTLET_TAG || node.tag === 'slot'))
18
+ )
19
+ }
@@ -5,7 +5,9 @@ A control-flow block — `if`/`each`/`await`/`switch`/`try`. In a skeleton each
5
5
  `<!--a-->` anchor cloned into its located parent at the block's position (see `anchorCursor`),
6
6
  so a block can sit ANYWHERE among static siblings. The shared classification under block
7
7
  anchor placement: both back-ends — and `skeletonable` — consult this so they agree on which
8
- nodes are anchor-positioned, the way `componentWrapperTag`/`isTextLeaf` already are.
8
+ nodes are anchor-positioned. A child component is anchor-positioned too (it mounts as a
9
+ `[`…`]` range like a block, see `mountRange`); the back-ends OR this with `kind ===
10
+ 'component'` rather than folding it in here, since a standalone component routes differently.
9
11
  */
10
12
  export function isControlFlow(node: TemplateNode): boolean {
11
13
  return (
@@ -8,7 +8,7 @@ located element (`generateBuild`) / emits it without an `<!--a-->` prefix (`gene
8
8
  reactive text interleaved with element children is anchor-positioned instead. Computing it
9
9
  once keeps the SSR string and the client skeleton from disagreeing about a `<!--a-->` — the
10
10
  first slice of lifting the positional model out of the two parallel traversals, alongside
11
- the already-shared `componentWrapperTag` and `skeletonable`.
11
+ the already-shared `isControlFlow` and `skeletonable`.
12
12
  */
13
13
  export function isTextLeaf(node: Extract<TemplateNode, { kind: 'element' }>): boolean {
14
14
  return node.children.every((child) => child.kind === 'text' || child.kind === 'style')
@@ -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
  }
@@ -121,6 +121,24 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
121
121
  if (char === '>' || char === '/' || char === undefined) {
122
122
  break
123
123
  }
124
+ /* `{...expr}` standing where an attribute name would — a spread of an object's
125
+ keys onto the tag: props on a component, attributes on a native element. Only
126
+ a `<template>` directive rejects it (see `readElement`). */
127
+ if (char === '{') {
128
+ const { code, loc } = readBracedExpression()
129
+ if (!code.startsWith('...')) {
130
+ throw new Error(
131
+ `[abide] a bare {expr} is not a valid attribute — write {...expr} to spread an object's keys as props`,
132
+ )
133
+ }
134
+ /* Advance `loc` past `...` and any whitespace so it points at the spread
135
+ EXPRESSION, not the dots — the shadow source-map invariant (source text at
136
+ `loc` equals the emitted code) requires `loc` and `code` to align. */
137
+ const inner = code.slice(3)
138
+ const leading = inner.length - inner.trimStart().length
139
+ attrs.push({ kind: 'spread', code: inner.trim(), loc: loc + 3 + leading })
140
+ continue
141
+ }
124
142
  let name = ''
125
143
  while (cursor < source.length && !/[\s=>/]/.test(source.charAt(cursor))) {
126
144
  name += source.charAt(cursor)
@@ -163,6 +181,12 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
163
181
  `>`, per the HTML unquoted-attribute rule. No delimiter to consume. */
164
182
  let value = ''
165
183
  while (cursor < source.length && !/[\s>]/.test(source.charAt(cursor))) {
184
+ /* Stop before a `/` that closes the tag (`<Comp x=y/>`) so the value
185
+ doesn't swallow the self-closing slash and defeat detection; a `/`
186
+ elsewhere (e.g. a URL `href=/a/b`) stays part of the value. */
187
+ if (source.charAt(cursor) === '/' && source.charAt(cursor + 1) === '>') {
188
+ break
189
+ }
166
190
  value += source.charAt(cursor)
167
191
  cursor += 1
168
192
  }
@@ -214,6 +238,11 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
214
238
  const slotted = selfClosing ? [] : readChildren(tag)
215
239
  return { kind: 'component', name: tag, props: toProps(attrs), children: slotted }
216
240
  }
241
+ /* `{...expr}` spreads onto a component (its props) or a native element (its
242
+ attributes), but a `<template>` directive has no such bag — reject it there. */
243
+ if (tag === 'template' && attrs.some((attr) => attr.kind === 'spread')) {
244
+ throw new Error('[abide] {...expr} spread is not supported on a <template> directive')
245
+ }
217
246
  const children = selfClosing || VOID_TAGS.has(tag) ? [] : readChildren(tag)
218
247
  if (tag === 'template') {
219
248
  return toControlFlow(attrs, children)
@@ -296,8 +325,15 @@ function rejectStrayBranches(
296
325
  assigned) instead of being dropped. A static value becomes a string literal —
297
326
  a bare attribute coerces to `true` instead; every other kind keeps its `code`,
298
327
  letting a prop hold any value, functions included (e.g. an `onclick` callback). */
299
- function toProps(attrs: TemplateAttr[]): { name: string; code: string; loc?: number }[] {
328
+ function toProps(
329
+ attrs: TemplateAttr[],
330
+ ): { name: string; code: string; loc?: number; spread?: boolean }[] {
300
331
  return attrs.map((attr) => {
332
+ /* A `{...expr}` spread carries no name — its keys merge in at runtime
333
+ (`mergeProps`/`spreadProps`); `spread: true` marks it for the back-ends. */
334
+ if (attr.kind === 'spread') {
335
+ return { name: '', code: attr.code, loc: attr.loc, spread: true }
336
+ }
301
337
  if (attr.kind === 'static') {
302
338
  /* A bare attribute (`<Toggle on />`) is a boolean flag: coerce it to
303
339
  `true` so the prop reads as a boolean, not the empty string a native
@@ -347,6 +383,13 @@ function attrName(attr: TemplateAttr): string {
347
383
  if (attr.kind === 'attach') {
348
384
  return 'attach'
349
385
  }
386
+ /* A spread has no name. `attrName` only feeds `<template>` directive lookups, and a
387
+ spread on a `<template>` is rejected at parse, so this branch is unreachable in
388
+ practice. (Spread itself IS supported — on components as props and on native
389
+ elements as attributes; only `<template>` directives reject it.) */
390
+ if (attr.kind === 'spread') {
391
+ return ''
392
+ }
350
393
  return attr.name
351
394
  }
352
395