@abide/abide 0.31.1 → 0.32.1

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 (41) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +26 -0
  3. package/package.json +1 -2
  4. package/src/checkAbide.ts +11 -6
  5. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +4 -3
  6. package/src/lib/server/runtime/createServer.ts +28 -23
  7. package/src/lib/server/runtime/createUiPageRenderer.ts +1 -1
  8. package/src/lib/ui/compile/AbideCompileError.ts +16 -0
  9. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +0 -1
  10. package/src/lib/ui/compile/abideUiPlugin.ts +25 -2
  11. package/src/lib/ui/compile/bindListenEvent.ts +19 -0
  12. package/src/lib/ui/compile/compileShadow.ts +65 -52
  13. package/src/lib/ui/compile/componentWrapperTag.ts +20 -0
  14. package/src/lib/ui/compile/generateBuild.ts +114 -212
  15. package/src/lib/ui/compile/generateSSR.ts +54 -88
  16. package/src/lib/ui/compile/lowerContext.ts +64 -0
  17. package/src/lib/ui/compile/lowerDocAccess.ts +6 -1
  18. package/src/lib/ui/compile/offsetToLineColumn.ts +16 -0
  19. package/src/lib/ui/compile/scopeAttr.ts +9 -0
  20. package/src/lib/ui/compile/staticAttr.ts +11 -0
  21. package/src/lib/ui/compile/staticTextPart.ts +12 -0
  22. package/src/lib/ui/compile/unwrapParens.ts +10 -0
  23. package/src/lib/ui/dom/applyResolved.ts +9 -6
  24. package/src/lib/ui/dom/awaitBlock.ts +27 -21
  25. package/src/lib/ui/dom/clearBetween.ts +16 -0
  26. package/src/lib/ui/dom/each.ts +64 -38
  27. package/src/lib/ui/dom/eachAsync.ts +41 -54
  28. package/src/lib/ui/dom/fillBefore.ts +16 -0
  29. package/src/lib/ui/dom/moveRange.ts +19 -0
  30. package/src/lib/ui/dom/openMarker.ts +22 -0
  31. package/src/lib/ui/dom/removeRange.ts +18 -0
  32. package/src/lib/ui/dom/switchBlock.ts +32 -40
  33. package/src/lib/ui/dom/tryBlock.ts +31 -35
  34. package/src/lib/ui/dom/types/EachRow.ts +10 -3
  35. package/src/lib/ui/dom/types/SwitchCase.ts +3 -2
  36. package/src/lib/ui/dom/when.ts +34 -43
  37. package/src/lib/ui/installHotBridge.ts +0 -2
  38. package/src/lib/ui/renderToStream.ts +14 -11
  39. package/src/lib/ui/state.ts +14 -5
  40. package/src/lib/ui/compile/branchElements.ts +0 -50
  41. package/src/lib/ui/dom/openRoot.ts +0 -20
@@ -1,16 +1,23 @@
1
1
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
- import { branchElements } from './branchElements.ts'
3
- import { escapeHtml } from './escapeHtml.ts'
2
+ import { componentWrapperTag } from './componentWrapperTag.ts'
4
3
  import { groupBindParts } from './groupBindParts.ts'
5
- import { lowerDocAccess } from './lowerDocAccess.ts'
4
+ import { lowerContext } from './lowerContext.ts'
6
5
  import { partitionSlots } from './partitionSlots.ts'
7
- import { nestedBindingNames } from './prepareNestedScript.ts'
8
- import { renameSignalRefs } from './renameSignalRefs.ts'
6
+ import { scopeAttr } from './scopeAttr.ts'
7
+ import { staticAttr } from './staticAttr.ts'
9
8
  import { staticAttrValue } from './staticAttrValue.ts'
9
+ import { staticTextPart } from './staticTextPart.ts'
10
10
  import { stripEffects } from './stripEffects.ts'
11
11
  import type { TemplateNode } from './types/TemplateNode.ts'
12
12
  import { VOID_TAGS } from './VOID_TAGS.ts'
13
13
 
14
+ /* The range boundary comments a control-flow block emits around its content. They
15
+ serialize exactly as the client's `document.createComment('[' | ']')` markers, so
16
+ the client claims the same `[ … ]` boundary it builds — the comment-marked range
17
+ that lets a branch hold any content. */
18
+ const RANGE_OPEN = '<!--[-->'
19
+ const RANGE_CLOSE = '<!--]-->'
20
+
14
21
  /*
15
22
  Server code generator: turns the parsed template into statements that push HTML
16
23
  fragments onto an output array, reading the document synchronously (no DOM, no
@@ -36,24 +43,16 @@ export function generateSSR(
36
43
  let varCounter = 0
37
44
  const nextVar = (prefix: string): string => `${prefix}${varCounter++}`
38
45
 
39
- /* Branch-scoped nested-script bindings, deref'd to `.value` (see generateBuild). */
40
- const localDerived = new Set<string>()
41
- const derefScope = (): ReadonlySet<string> =>
42
- localDerived.size === 0 ? derivedNames : new Set([...derivedNames, ...localDerived])
43
-
44
- function lowerExpression(code: string): string {
45
- return lowerDocAccess(renameSignalRefs(code, stateNames, derefScope()), 'model')
46
- .trim()
47
- .replace(/;$/, '')
48
- }
46
+ /* The shared signal→`model` lowering + branch-scoped nested-script deref scope. */
47
+ const {
48
+ expression: lowerExpression,
49
+ statement,
50
+ withNestedScripts,
51
+ } = lowerContext(stateNames, derivedNames)
49
52
 
50
- /* Lowers a scoped-script body for SSR: rename refs, lower doc access, then strip
51
- effects (client-only lifecycle that emits no HTML). */
52
- function lowerScript(code: string): string {
53
- return stripEffects(
54
- lowerDocAccess(renameSignalRefs(code, stateNames, derefScope()), 'model').trim(),
55
- )
56
- }
53
+ /* A scoped-script body for SSR: the shared lowering, then strip effects
54
+ (client-only lifecycle that emits no HTML) — the one SSR-side asymmetry. */
55
+ const lowerScript = (code: string): string => stripEffects(statement(code))
57
56
 
58
57
  function push(target: string, literal: string): string {
59
58
  return `${target}.push(${JSON.stringify(literal)});\n`
@@ -63,41 +62,25 @@ export function generateSSR(
63
62
  return children.map((child) => generate(child, target)).join('')
64
63
  }
65
64
 
66
- /* A control-flow branch's body: run its nested `<script>`s (lowered, in scope)
67
- first, then push the element markup so SSR re-seeds the same local signals
68
- the client build does, keeping hydration aligned. */
69
- function branchInto(children: TemplateNode[], context: string, target: string): string {
70
- const added: string[] = []
71
- for (const child of children) {
72
- if (child.kind === 'script') {
73
- for (const name of nestedBindingNames(child.code)) {
74
- if (!localDerived.has(name)) {
75
- localDerived.add(name)
76
- added.push(name)
77
- }
78
- }
79
- }
80
- }
81
- const scriptCode = children
82
- .filter(
83
- (child): child is Extract<TemplateNode, { kind: 'script' }> =>
84
- child.kind === 'script',
85
- )
86
- .map((child) => `${lowerScript(child.code)}\n`)
87
- .join('')
88
- const markup = generateInto(branchElements(children, context, true), target)
89
- for (const name of added) {
90
- localDerived.delete(name)
91
- }
92
- return scriptCode + markup
65
+ /* A control-flow branch's content, generated exactly like a normal child list so
66
+ a branch holds ANY content (components, text, nested blocks). `generate` emits
67
+ nested `<script>`s in document order; `withNestedScripts` puts their bindings in
68
+ scope matching the client build, so hydration stays aligned. The caller wraps
69
+ it in the `[]` range markers the runtime tracks (unconditionally per block,
70
+ so an empty/false branch still emits the boundary the client claims). */
71
+ function branchContent(children: TemplateNode[], target: string): string {
72
+ return withNestedScripts(children, () => generateInto(children, target))
93
73
  }
74
+ const openRange = (target: string): string => push(target, RANGE_OPEN)
75
+ const closeRange = (target: string): string => push(target, RANGE_CLOSE)
94
76
 
95
77
  function generate(node: TemplateNode, target: string): string {
96
78
  if (node.kind === 'text') {
97
79
  return node.parts
98
80
  .map((part) => {
99
81
  if (part.kind === 'static') {
100
- return part.value.trim() === '' ? '' : push(target, escapeHtml(part.value))
82
+ const markup = staticTextPart(part.value)
83
+ return markup === '' ? '' : push(target, markup)
101
84
  }
102
85
  return `${target}.push($text(${lowerExpression(part.code)}));\n`
103
86
  })
@@ -106,11 +89,11 @@ export function generateSSR(
106
89
  if (node.kind === 'if') {
107
90
  const elseBranch = node.children.find((child) => child.kind === 'case')
108
91
  const thenChildren = node.children.filter((child) => child.kind !== 'case')
109
- let code = `if (${lowerExpression(node.condition)}) {\n${branchInto(thenChildren, '<template if>', target)}}`
92
+ let code = `if (${lowerExpression(node.condition)}) {\n${branchContent(thenChildren, target)}}`
110
93
  if (elseBranch !== undefined && elseBranch.kind === 'case') {
111
- code += ` else {\n${branchInto(elseBranch.children, '<template else>', target)}}`
94
+ code += ` else {\n${branchContent(elseBranch.children, target)}}`
112
95
  }
113
- return `${code}\n`
96
+ return `${openRange(target)}${code}\n${closeRange(target)}`
114
97
  }
115
98
  if (node.kind === 'switch') {
116
99
  const cases = node.children.filter(
@@ -120,15 +103,15 @@ export function generateSSR(
120
103
  let started = false
121
104
  for (const branch of cases) {
122
105
  if (branch.match !== undefined) {
123
- code += `${started ? 'else ' : ''}if ($s === (${lowerExpression(branch.match)})) {\n${branchInto(branch.children, '<template case>', target)}}\n`
106
+ code += `${started ? 'else ' : ''}if ($s === (${lowerExpression(branch.match)})) {\n${branchContent(branch.children, target)}}\n`
124
107
  started = true
125
108
  }
126
109
  }
127
110
  const fallback = cases.find((branch) => branch.match === undefined)
128
111
  if (fallback !== undefined) {
129
- code += `${started ? 'else ' : ''}{\n${branchInto(fallback.children, '<template case>', target)}}\n`
112
+ code += `${started ? 'else ' : ''}{\n${branchContent(fallback.children, target)}}\n`
130
113
  }
131
- return `${code}}\n`
114
+ return `${openRange(target)}${code}}\n${closeRange(target)}`
132
115
  }
133
116
  if (node.kind === 'case') {
134
117
  return ''
@@ -156,7 +139,7 @@ export function generateSSR(
156
139
  if (node.async) {
157
140
  return ''
158
141
  }
159
- return `for (const ${node.as} of (${lowerExpression(node.items)})) {\n${branchInto(node.children, '<template each>', target)}}\n`
142
+ return `for (const ${node.as} of (${lowerExpression(node.items)})) {\n${openRange(target)}${branchContent(node.children, target)}${closeRange(target)}}\n`
160
143
  }
161
144
  if (node.kind === 'await') {
162
145
  return generateAwait(node, target)
@@ -172,7 +155,7 @@ export function generateSSR(
172
155
  the same wrapper the client mounts into, so SSR and client agree.
173
156
  Props pass as thunks; slot content passes as a string-returning
174
157
  `$children` the child invokes from its <slot>. */
175
- const tag = node.name.toLowerCase()
158
+ const { tag, transparent } = componentWrapperTag(node.name)
176
159
  const parts = node.props.map(
177
160
  (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
178
161
  )
@@ -199,7 +182,7 @@ export function generateSSR(
199
182
  the enclosing render body, including from branch closures.) */
200
183
  const result = nextVar('$child')
201
184
  return (
202
- push(target, `<${tag}>`) +
185
+ push(target, `<${tag}${transparent ? ' style="display:contents"' : ''}>`) +
203
186
  `const ${result} = ${node.name}.render({ ${parts.join(', ')} });\n` +
204
187
  `${target}.push(${result}.html);\n` +
205
188
  `for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
@@ -218,14 +201,11 @@ export function generateSSR(
218
201
  /* Every `<style>` active at this element (own siblings + ancestors) — same set
219
202
  the client stamps, so server and client markup carry identical attributes. */
220
203
  for (const scope of node.scopes ?? []) {
221
- code += push(target, ` ${scope}=""`)
204
+ code += push(target, scopeAttr(scope))
222
205
  }
223
206
  for (const attr of node.attrs) {
224
207
  if (attr.kind === 'static') {
225
- /* Escape the literal value so a `"`/`&`/`<` in it can't break out of
226
- the attribute or inject markup (the client uses setAttribute, which
227
- needs no escaping — escaping here keeps SSR and client in sync). */
228
- code += push(target, ` ${attr.name}="${escapeHtml(attr.value)}"`)
208
+ code += push(target, staticAttr(attr.name, attr.value))
229
209
  } else if (attr.kind === 'expression') {
230
210
  /* present/absent semantics matching the client `attr` binding:
231
211
  false/null/undefined drops it, true emits the bare attribute. */
@@ -249,21 +229,7 @@ export function generateSSR(
249
229
  code += push(target, '>')
250
230
  if (!VOID_TAGS.has(node.tag)) {
251
231
  /* A `<script>` child scopes its bindings to this element's subtree. */
252
- const added: string[] = []
253
- for (const child of node.children) {
254
- if (child.kind === 'script') {
255
- for (const name of nestedBindingNames(child.code)) {
256
- if (!localDerived.has(name)) {
257
- localDerived.add(name)
258
- added.push(name)
259
- }
260
- }
261
- }
262
- }
263
- code += generateInto(node.children, target)
264
- for (const name of added) {
265
- localDerived.delete(name)
266
- }
232
+ code += withNestedScripts(node.children, () => generateInto(node.children, target))
267
233
  code += push(target, `</${node.tag}>`)
268
234
  }
269
235
  return code
@@ -318,24 +284,24 @@ export function generateSSR(
318
284
  const id = nextVar('$aid')
319
285
  let code = `const ${id} = nextBlockId();\n`
320
286
  code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
321
- code += branchInto(pending, '<template await> pending', target)
287
+ code += branchContent(pending, target)
322
288
  code += `${target}.push("<!--/abide:await:" + ${id} + "-->");\n`
323
289
  /* The settled closures append `finally` after the outcome markup, matching the
324
290
  client's concatenated node range so hydration aligns. */
325
- const settled = (binding: string, children: TemplateNode[], context: string) =>
326
- `(${binding}) => { const $o = []; ${branchInto(children, context, '$o')}${branchInto(finallyChildren, '<template finally>', '$o')}return $o.join(''); }`
291
+ const settled = (binding: string, children: TemplateNode[]) =>
292
+ `(${binding}) => { const $o = []; ${branchContent(children, '$o')}${branchContent(finallyChildren, '$o')}return $o.join(''); }`
327
293
  /* Neither catch nor finally → omit `catch` so a rejection surfaces to the
328
294
  stream/error path (renderToStream re-throws) instead of rendering an empty
329
295
  branch. A finally-only block keeps a catch closure that renders just finally. */
330
296
  const catchProp =
331
297
  catchBranch === undefined && finallyChildren.length === 0
332
298
  ? ''
333
- : `catch: ${settled(catchBranch?.as ?? '_error', catchBranch?.children ?? [], '<template catch>')} `
299
+ : `catch: ${settled(catchBranch?.as ?? '_error', catchBranch?.children ?? [])} `
334
300
  code +=
335
301
  `$awaits.push({ id: ${id}, ` +
336
302
  (node.blocking ? 'blocking: true, ' : '') +
337
303
  `promise: () => (${lowerExpression(node.promise)}), ` +
338
- `then: ${settled(resolvedAs ?? '_value', resolvedChildren, node.blocking ? '<template await then>' : '<template then>')}, ` +
304
+ `then: ${settled(resolvedAs ?? '_value', resolvedChildren)}, ` +
339
305
  `${catchProp}});\n`
340
306
  return code
341
307
  }
@@ -362,12 +328,12 @@ export function generateSSR(
362
328
  code += `${target}.push("<!--abide:try:" + ${id} + "-->");\n`
363
329
  code += `const ${mark} = ${target}.length;\n`
364
330
  code += `try {\n`
365
- code += branchInto(guarded, '<template try>', target)
366
- code += branchInto(finallyChildren, '<template finally>', target)
331
+ code += branchContent(guarded, target)
332
+ code += branchContent(finallyChildren, target)
367
333
  code += `} catch (${errName}) {\n${target}.length = ${mark};\n`
368
334
  if (catchBranch !== undefined) {
369
- code += branchInto(catchBranch.children, '<template catch>', target)
370
- code += branchInto(finallyChildren, '<template finally>', target)
335
+ code += branchContent(catchBranch.children, target)
336
+ code += branchContent(finallyChildren, target)
371
337
  } else {
372
338
  code += `throw ${errName};\n`
373
339
  }
@@ -0,0 +1,64 @@
1
+ import { lowerDocAccess } from './lowerDocAccess.ts'
2
+ import { nestedBindingNames } from './prepareNestedScript.ts'
3
+ import { renameSignalRefs } from './renameSignalRefs.ts'
4
+ import type { TemplateNode } from './types/TemplateNode.ts'
5
+ import { unwrapParens } from './unwrapParens.ts'
6
+
7
+ /*
8
+ The shared expression-lowering context both back-ends build on: the signal→`model`
9
+ rewrite and doc-access lowering that turns the signal surface the author writes
10
+ (`count` → `model.count` → patch/read) into the doc API, plus the branch-scoped
11
+ nested-`<script>` deref scope. Identical on both sides by design — server and
12
+ client must lower an expression the same way or their markup diverges and
13
+ hydration breaks — so it lives in one place: a new sugar token or a paren/scope
14
+ fix lands here once instead of in lockstep across `generateBuild`/`generateSSR`.
15
+ SSR's effect-stripping stays a caller-side wrap (`stripEffects`), the one real
16
+ asymmetry; the node-walk skeletons stay in each back-end.
17
+ */
18
+ export function lowerContext(stateNames: ReadonlySet<string>, derivedNames: ReadonlySet<string>) {
19
+ /* Branch-scoped signal bindings (from nested `<script>`s) — they deref to
20
+ `.value` like a `derived`. Pushed while a branch's script + markup compile,
21
+ popped after, so they shadow only within that subtree. */
22
+ const localDerived = new Set<string>()
23
+ const derefScope = (): ReadonlySet<string> =>
24
+ localDerived.size === 0 ? derivedNames : new Set([...derivedNames, ...localDerived])
25
+
26
+ /* Rewrites signal refs, then lowers a single expression (no trailing `;`).
27
+ Wrapped in parens so a bare object literal (`{ a: 1 }`) parses as an
28
+ expression, not a block of labeled statements, through both rewrite passes;
29
+ the wrapper is then peeled back off. */
30
+ function expression(code: string): string {
31
+ const renamed = renameSignalRefs(`(${code})`, stateNames, derefScope())
32
+ return unwrapParens(lowerDocAccess(renamed, 'model').trim().replace(/;$/, ''))
33
+ }
34
+
35
+ /* As above but keeps the trailing `;` for a statement/handler body. */
36
+ function statement(code: string): string {
37
+ const renamed = renameSignalRefs(code, stateNames, derefScope())
38
+ return lowerDocAccess(renamed, 'model').trim()
39
+ }
40
+
41
+ /* Adds any `<script>` children's binding names to the deref scope (so the script
42
+ bodies and the branch's markup auto-deref them), runs `body` within that scope,
43
+ then pops the names it added. Returns whatever `body` produces. */
44
+ function withNestedScripts<T>(children: TemplateNode[], body: () => T): T {
45
+ const added: string[] = []
46
+ for (const child of children) {
47
+ if (child.kind === 'script') {
48
+ for (const name of nestedBindingNames(child.code)) {
49
+ if (!localDerived.has(name)) {
50
+ localDerived.add(name)
51
+ added.push(name)
52
+ }
53
+ }
54
+ }
55
+ }
56
+ const result = body()
57
+ for (const name of added) {
58
+ localDerived.delete(name)
59
+ }
60
+ return result
61
+ }
62
+
63
+ return { expression, statement, withNestedScripts }
64
+ }
@@ -32,12 +32,17 @@ export function lowerDocAccess(code: string, docName: string): string {
32
32
  /* A path segment is either a literal key or a runtime expression (a dynamic index). */
33
33
  type Segment = { kind: 'literal'; value: string } | { kind: 'expression'; node: ts.Expression }
34
34
 
35
- /* Maps a compound-assignment operator to its plain binary counterpart. */
35
+ /* Maps a compound-assignment operator to its plain binary counterpart. Logical
36
+ assignments (`||=`/`&&=`/`??=`) lower to an unconditional replace of the
37
+ combined value — consistent with how `+=` lowers (the patch always writes). */
36
38
  const COMPOUND_OPERATORS = new Map<ts.SyntaxKind, ts.BinaryOperator>([
37
39
  [ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.PlusToken],
38
40
  [ts.SyntaxKind.MinusEqualsToken, ts.SyntaxKind.MinusToken],
39
41
  [ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.AsteriskToken],
40
42
  [ts.SyntaxKind.SlashEqualsToken, ts.SyntaxKind.SlashToken],
43
+ [ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.BarBarToken],
44
+ [ts.SyntaxKind.AmpersandAmpersandEqualsToken, ts.SyntaxKind.AmpersandAmpersandToken],
45
+ [ts.SyntaxKind.QuestionQuestionEqualsToken, ts.SyntaxKind.QuestionQuestionToken],
41
46
  ])
42
47
 
43
48
  function docAccessTransformer(docName: string): ts.TransformerFactory<ts.SourceFile> {
@@ -0,0 +1,16 @@
1
+ /*
2
+ Converts an absolute source offset to 1-based `{ line, column }`. Used to turn a
3
+ compile error's tracked offset into a human location for the loader's message
4
+ (Bun frames plugin throws at `<file>:0`, so abide carries the real position in the
5
+ message text itself).
6
+ */
7
+ export function offsetToLineColumn(
8
+ source: string,
9
+ offset: number,
10
+ ): { line: number; column: number } {
11
+ const clamped = Math.max(0, Math.min(offset, source.length))
12
+ const preceding = source.slice(0, clamped)
13
+ const line = preceding.split('\n').length
14
+ const column = clamped - (preceding.lastIndexOf('\n') + 1) + 1
15
+ return { line, column }
16
+ }
@@ -0,0 +1,9 @@
1
+ /*
2
+ Serializes one `<style>` scope marker to its empty-valued attribute fragment,
3
+ leading space included. Shared by the SSR generator and the static-clone skeleton
4
+ generator so server markup and the client clone template stamp the same scope
5
+ attributes in the same byte-shape.
6
+ */
7
+ export function scopeAttr(scope: string): string {
8
+ return ` ${scope}=""`
9
+ }
@@ -0,0 +1,11 @@
1
+ import { escapeHtml } from './escapeHtml.ts'
2
+
3
+ /*
4
+ Serializes one static attribute to its markup fragment, leading space included.
5
+ Shared by the SSR generator and the static-clone skeleton generator so the two
6
+ back-ends can't diverge on attribute byte-shape or value escaping — the contract
7
+ that lets the client clone template hydrate the server markup.
8
+ */
9
+ export function staticAttr(name: string, value: string): string {
10
+ return ` ${name}="${escapeHtml(value)}"`
11
+ }
@@ -0,0 +1,12 @@
1
+ import { escapeHtml } from './escapeHtml.ts'
2
+
3
+ /*
4
+ Serializes one static text part to its markup: whitespace-only parts drop (both
5
+ back-ends omit them, so a blank part neither emits markup nor breaks a clone run),
6
+ everything else is HTML-escaped. Shared by the SSR generator and the static-clone
7
+ skeleton generator so server markup and the client clone template agree on both
8
+ the whitespace rule and escaping.
9
+ */
10
+ export function staticTextPart(value: string): string {
11
+ return value.trim() === '' ? '' : escapeHtml(value)
12
+ }
@@ -0,0 +1,10 @@
1
+ /*
2
+ Peels one outer paren pair off a lowered expression. The lowering passes wrap an
3
+ expression in `(…)` to force expression-position parsing (so a bare object literal
4
+ isn't read as a block); the printer preserves that wrapper, so the result is
5
+ always `(EXPR)` and the outermost pair is the one we added. Removed for clean,
6
+ canonical output.
7
+ */
8
+ export function unwrapParens(code: string): string {
9
+ return code.startsWith('(') && code.endsWith(')') ? code.slice(1, -1) : code
10
+ }
@@ -2,8 +2,8 @@ import { RESUME } from '../runtime/RESUME.ts'
2
2
 
3
3
  /*
4
4
  Client consumer of an SSR stream fragment. Parses a streamed
5
- `<abide-resolve data-id="ID" data-resume="">…</abide-resolve>` frame, registers
6
- its serialized value in the resume manifest (for later hydration), finds the
5
+ `<abide-resolve data-id="ID"><script type="application/json">…</script>…</abide-resolve>`
6
+ frame, registers its serialized value in the resume manifest (for later hydration), finds the
7
7
  matching `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary in `root`, removes
8
8
  the pending nodes between the markers, and inserts the resolved content in their
9
9
  place. The pending shell painted instantly; this swaps in each value as it
@@ -21,14 +21,17 @@ export function applyResolved(root: Element, frame: string): void {
21
21
  if (id === null) {
22
22
  return
23
23
  }
24
- /* Record the resolved value so a later hydrate adopts this branch (no re-fetch). */
25
- const resume = resolved.getAttribute('data-resume')
26
- if (resume !== null) {
24
+ /* The resolved value rides in a leading <script type=application/json>; parse and
25
+ remove it so only the resolved markup moves into the boundary. Recording it lets a
26
+ later hydrate adopt this branch (no re-fetch). */
27
+ const payload = resolved.firstChild as Element | null
28
+ if (payload !== null && payload.nodeName === 'SCRIPT') {
27
29
  try {
28
- RESUME[Number(id)] = JSON.parse(resume)
30
+ RESUME[Number(id)] = JSON.parse(payload.textContent ?? 'null')
29
31
  } catch {
30
32
  /* malformed payload — leave unregistered, hydration re-runs the promise */
31
33
  }
34
+ payload.remove()
32
35
  }
33
36
  const open = `abide:await:${id}`
34
37
  const close = `/abide:await:${id}`
@@ -29,11 +29,11 @@ export function awaitBlock(
29
29
  parent: Node,
30
30
  id: number,
31
31
  promiseThunk: () => unknown,
32
- renderPending: ((parent: Node) => Node[]) | undefined,
33
- renderThen: (parent: Node, value: unknown) => Node[],
32
+ renderPending: ((parent: Node) => void) | undefined,
33
+ renderThen: (parent: Node, value: unknown) => void,
34
34
  /* Absent when the block has no catch branch — a rejection then surfaces (re-throws
35
35
  to the unhandled-rejection path) instead of rendering an empty branch. */
36
- renderCatch: ((parent: Node, error: unknown) => Node[]) | undefined,
36
+ renderCatch: ((parent: Node, error: unknown) => void) | undefined,
37
37
  ): void {
38
38
  const hydration = RENDER.hydration
39
39
  let active: { nodes: Node[]; dispose: () => void } | undefined
@@ -45,24 +45,27 @@ export function awaitBlock(
45
45
  const detach = (): void => {
46
46
  if (active !== undefined) {
47
47
  active.dispose()
48
+ /* Remove via each node's LIVE parent, not the captured `parent` — when this
49
+ await is a bare child of a control-flow branch, `parent` is the branch's
50
+ build fragment, emptied into the document once the enclosing block placed
51
+ it (`place` already inserts via `anchor.parentNode` for the same reason). */
48
52
  for (const node of active.nodes) {
49
- parent.removeChild(node)
53
+ node.parentNode?.removeChild(node)
50
54
  }
51
55
  active = undefined
52
56
  }
53
57
  }
54
58
 
55
- /* Replace the current content with a freshly-built range, before the anchor. */
56
- const place = (build: (parent: Node) => Node[]): void => {
59
+ /* Replace the current content with a freshly-built branch, before the anchor. The
60
+ branch builds into a fragment (so any content components, text, nested blocks
61
+ — appends freely), whose top-level nodes are tracked for the next swap. */
62
+ const place = (build: (parent: Node) => void): void => {
57
63
  detach()
58
- let nodes: Node[] = []
59
- const dispose = scope(() => {
60
- nodes = build(parent)
61
- })
64
+ const fragment = document.createDocumentFragment()
65
+ const dispose = scope(() => build(fragment))
66
+ const nodes = [...fragment.childNodes]
67
+ ;(anchor?.parentNode ?? parent).insertBefore(fragment, anchor ?? null)
62
68
  active = { nodes, dispose }
63
- for (const node of nodes) {
64
- parent.insertBefore(node, anchor ?? null)
65
- }
66
69
  }
67
70
 
68
71
  /* Render a settled-or-pending result into the current generation. */
@@ -96,17 +99,20 @@ export function awaitBlock(
96
99
  )
97
100
  }
98
101
 
99
- /* Adopt an SSR-resolved branch in place (its roots claim the existing nodes),
100
- then park an anchor just before the close marker for later swaps. */
101
- const adopt = (open: Node | null, build: (parent: Node) => Node[]): void => {
102
+ /* Adopt an SSR-resolved branch in place (its content claims the existing nodes),
103
+ then park an anchor just before the close marker for later swaps. The adopted
104
+ content is everything the build claimed between the open and close markers. */
105
+ const adopt = (open: Node | null, build: (parent: Node) => void): void => {
102
106
  const cursor = hydration as NonNullable<typeof hydration>
103
107
  cursor.next.set(parent, open?.nextSibling ?? null)
104
- let nodes: Node[] = []
105
- const dispose = scope(() => {
106
- nodes = build(parent)
107
- })
108
+ const dispose = scope(() => build(parent))
108
109
  const close = claimChild(cursor, parent)
109
110
  cursor.next.set(parent, close?.nextSibling ?? null)
111
+ const nodes: Node[] = []
112
+ for (let node = open?.nextSibling ?? null; node !== null && node !== close; ) {
113
+ nodes.push(node)
114
+ node = node.nextSibling
115
+ }
110
116
  anchor = document.createTextNode('')
111
117
  parent.insertBefore(anchor, close)
112
118
  active = { nodes, dispose }
@@ -143,7 +149,7 @@ export function awaitBlock(
143
149
  const open = claimChild(cursor, parent)
144
150
  const entry = RESUME[id]
145
151
  if (entry !== undefined) {
146
- let build: (host: Node) => Node[]
152
+ let build: (host: Node) => void
147
153
  if (entry.ok) {
148
154
  build = (host) => renderThen(host, entry.value)
149
155
  } else if (renderCatch !== undefined) {
@@ -0,0 +1,16 @@
1
+ /*
2
+ Tears down a control-flow range: disposes its reactive scope, then removes every
3
+ node between the `start` and `end` markers (exclusive). Walking between the markers
4
+ at clear time — rather than tracking a captured node list — is what lets a branch
5
+ hold dynamic nested blocks: nodes a nested block inserted after the initial build
6
+ still sit between the markers, so they're removed too.
7
+ */
8
+ export function clearBetween(start: Node, end: Node, dispose?: () => void): void {
9
+ dispose?.()
10
+ let node = start.nextSibling
11
+ while (node !== null && node !== end) {
12
+ const next = node.nextSibling
13
+ end.parentNode?.removeChild(node)
14
+ node = next
15
+ }
16
+ }