@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
@@ -2,12 +2,13 @@ import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
2
2
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
3
3
  import { asOutlet } from './asOutlet.ts'
4
4
  import { bindListenEvent } from './bindListenEvent.ts'
5
- import { componentWrapperTag } from './componentWrapperTag.ts'
5
+ import { composeProps } from './composeProps.ts'
6
6
  import { groupBindParts } from './groupBindParts.ts'
7
7
  import { isControlFlow } from './isControlFlow.ts'
8
8
  import { lowerContext } from './lowerContext.ts'
9
9
  import { scopeAttr } from './scopeAttr.ts'
10
10
  import { skeletonContext } from './skeletonContext.ts'
11
+ import { spreadExcludedNames } from './spreadExcludedNames.ts'
11
12
  import { staticAttr } from './staticAttr.ts'
12
13
  import { staticTextPart } from './staticTextPart.ts'
13
14
  import type { TemplateNode } from './types/TemplateNode.ts'
@@ -22,6 +23,25 @@ surface (`count` → `model.count`) and then lowered to the doc patch/read API
22
23
  `hostVar` and expects the dom bindings, `doc`, `effect`, and the component's
23
24
  `model` in scope — the body the component compiler wraps and hoists cells into.
24
25
  */
26
+
27
+ /* A JS-identifier-safe frame name from an authored construct label (an attribute name
28
+ like `aria-label`, a bound property). Non-identifier chars → `_`; a leading digit gets
29
+ an `_` prefix; empty falls back to `thunk`. Callers prefix the label (`attr_`/`bind_`),
30
+ so the result is never a bare reserved word. */
31
+ function thunkName(label: string): string {
32
+ const safe = label.replace(/[^A-Za-z0-9_$]/g, '_').replace(/^(?=\d)/, '_')
33
+ return safe === '' ? 'thunk' : safe
34
+ }
35
+
36
+ /* Names a reactive thunk so a stack frame reads `name@File.abide:line` instead of
37
+ `(anonymous)` — disambiguating which binding a frame is when several share a line. Emits
38
+ a named function expression (the only form whose name a debugger displays); the named
39
+ bodies never reference `this`/`arguments`, so the arrow→function swap is behaviour-safe,
40
+ and minify strips the name, so it costs nothing in production. */
41
+ function namedThunk(name: string, body: string): string {
42
+ return `function ${thunkName(name)}() { ${body} }`
43
+ }
44
+
25
45
  export function generateBuild(
26
46
  nodes: TemplateNode[],
27
47
  hostVar: string,
@@ -77,7 +97,7 @@ export function generateBuild(
77
97
  varName: string,
78
98
  ): string {
79
99
  if (attr.kind === 'expression') {
80
- return `attr(${varName}, ${JSON.stringify(attr.name)}, () => (${lowerExpression(attr.code)}));\n`
100
+ return `attr(${varName}, ${JSON.stringify(attr.name)}, ${namedThunk(`attr_${attr.name}`, `return (${lowerExpression(attr.code)})`)});\n`
81
101
  }
82
102
  if (attr.kind === 'event') {
83
103
  return `on(${varName}, ${JSON.stringify(attr.event)}, (${lowerExpression(attr.code)}));\n`
@@ -96,12 +116,12 @@ export function generateBuild(
96
116
  const value = lowerExpression(valueCode)
97
117
  if (isRadio) {
98
118
  return (
99
- `effect(() => { ${varName}.checked = (${lowerExpression(attr.code)}) === (${value}); });\n` +
119
+ `effect(${namedThunk('bind_group', `${varName}.checked = (${lowerExpression(attr.code)}) === (${value});`)});\n` +
100
120
  `on(${varName}, "change", () => { if (${varName}.checked) { ${lowerStatement(`${attr.code} = ${valueCode}`)} } });\n`
101
121
  )
102
122
  }
103
123
  return (
104
- `effect(() => { ${varName}.checked = (${lowerExpression(attr.code)}).includes(${value}); });\n` +
124
+ `effect(${namedThunk('bind_group', `${varName}.checked = (${lowerExpression(attr.code)}).includes(${value});`)});\n` +
105
125
  `on(${varName}, "change", () => { const $groupValue = ${value}; if (${varName}.checked) { if (!(${lowerExpression(attr.code)}).includes($groupValue)) { ${lowerStatement(`${attr.code}.push($groupValue)`)} } } else { const $groupIndex = (${lowerExpression(attr.code)}).indexOf($groupValue); if ($groupIndex !== -1) { ${lowerStatement(`delete ${attr.code}[$groupIndex]`)} } } });\n`
106
126
  )
107
127
  }
@@ -112,7 +132,7 @@ export function generateBuild(
112
132
  `.get()` and writes via `.set(v)` — see `bindRead`/`bindWrite`. */
113
133
  const event = bindListenEvent(attr.property, node.tag)
114
134
  return (
115
- `effect(() => { ${varName}.${attr.property} = ${bindRead(attr.code)}; });\n` +
135
+ `effect(${namedThunk(`bind_${attr.property}`, `${varName}.${attr.property} = ${bindRead(attr.code)};`)});\n` +
116
136
  `on(${varName}, ${JSON.stringify(event)}, () => { ${bindWrite(attr.code, `${varName}.${attr.property}`)} });\n`
117
137
  )
118
138
  }
@@ -125,6 +145,16 @@ export function generateBuild(
125
145
  block or slot drops an `<!--a-->` anchor at its position and mounts there (see
126
146
  `anchorCursor`), so it can sit ANYWHERE among static siblings. Static descendants are
127
147
  plain markup. */
148
+ /* The skeleton anchor var for an anchor-positioned node: declares `an<n> = sk.an[i]` as
149
+ a bind and returns the var name. The three anchored kinds (control-flow/component,
150
+ outlet, slot) all mount at this `<!--a-->` anchor, so they number through this ONE
151
+ site (`anIndex`) — no per-branch copy of the lookup to drift from the runtime scan. */
152
+ function anchorVarAt(node: TemplateNode, skVar: string, binds: string[]): string {
153
+ const anchorVar = nextVar('an')
154
+ binds.push(`const ${anchorVar} = ${skVar}.an[${holeIndex(anIndex, node)}];\n`)
155
+ return anchorVar
156
+ }
157
+
128
158
  function skeletonMarkup(node: TemplateNode, skVar: string, binds: string[]): string {
129
159
  if (node.kind === 'text') {
130
160
  /* Reactive text reached here is INTERLEAVED with element siblings (a text-leaf
@@ -137,31 +167,23 @@ export function generateBuild(
137
167
  return staticTextPart(part.value)
138
168
  }
139
169
  binds.push(
140
- `appendTextAt(${skVar}.an[${holeIndex(anIndex, part)}], () => (${lowerExpression(part.code)}));\n`,
170
+ `appendTextAt(${skVar}.an[${holeIndex(anIndex, part)}], ${namedThunk('text', `return (${lowerExpression(part.code)})`)});\n`,
141
171
  )
142
172
  return '<!--a-->'
143
173
  })
144
174
  .join('')
145
175
  }
146
- if (isControlFlow(node)) {
147
- /* A control-flow block at its position: an `<!--a-->` anchor in the clone, the
148
- block mounted at it. `anchorCursor` parks the hydrate cursor past the anchor
149
- and returns the create insertion reference; the block's parent is the located
150
- element the anchor was cloned into (`anchor.parentNode`). */
151
- const anchorVar = nextVar('an')
152
- binds.push(`const ${anchorVar} = ${skVar}.an[${holeIndex(anIndex, node)}];\n`)
176
+ if (isControlFlow(node) || node.kind === 'component') {
177
+ /* A control-flow block OR a child component at its position: an `<!--a-->` anchor
178
+ in the clone, its content mounted as a marker-bounded range at it. `anchorCursor`
179
+ parks the hydrate cursor past the anchor and returns the create insertion
180
+ reference; the parent is the located element the anchor was cloned into
181
+ (`anchor.parentNode`). A component takes an anchor like a block — no wrapper
182
+ element so its root lays out as a true direct child of `anchor.parentNode`. */
183
+ const anchorVar = anchorVarAt(node, skVar, binds)
153
184
  binds.push(generateChild(node, `${anchorVar}.parentNode`, `anchorCursor(${anchorVar})`))
154
185
  return '<!--a-->'
155
186
  }
156
- if (node.kind === 'component') {
157
- /* The wrapper element is a positioned hole in the skeleton; the child mounts
158
- into the located node. display:contents (idempotent, static so it lives in
159
- the markup) keeps the wrapper out of layout — a pure mount host. */
160
- const tag = componentWrapperTag(node.name)
161
- const { code } = mountComponent(node, `${skVar}.el[${holeIndex(elIndex, node)}]`)
162
- binds.push(code)
163
- return `<${tag} ${HOLE_ATTRIBUTE} style="display:contents"></${tag}>`
164
- }
165
187
  if (node.kind === 'script') {
166
188
  /* A nested `<script>` (scoped reactive block) emits no markup — its lowered body
167
189
  runs as a bind, in document order, so its signals are declared before the later
@@ -179,11 +201,18 @@ export function generateBuild(
179
201
  if (node.kind !== 'element') {
180
202
  return '' // <style> emits no markup
181
203
  }
204
+ if (node.tag === OUTLET_TAG) {
205
+ /* A layout's router fill point at its position: an `<!--a-->` anchor, an empty
206
+ `outlet` boundary the router fills with the next chain layer (`fillBoundary`).
207
+ No wrapper element — the filled child lays out as a direct child of the parent. */
208
+ const anchorVar = anchorVarAt(node, skVar, binds)
209
+ binds.push(`outlet(${anchorVar}.parentNode, anchorCursor(${anchorVar}));\n`)
210
+ return '<!--a-->'
211
+ }
182
212
  if (node.tag === 'slot') {
183
213
  /* A `<slot>` outlet at its position: an `<!--a-->` anchor, the slot's content
184
214
  mounted as a marker-bounded range (`mountSlot`) so it positions like a block. */
185
- const anchorVar = nextVar('an')
186
- binds.push(`const ${anchorVar} = ${skVar}.an[${holeIndex(anIndex, node)}];\n`)
215
+ const anchorVar = anchorVarAt(node, skVar, binds)
187
216
  const hostVar = nextVar('host')
188
217
  binds.push(
189
218
  `mountSlot(${anchorVar}.parentNode, (${hostVar}) => {\n${generateSlot(node, hostVar)}}, anchorCursor(${anchorVar}));\n`,
@@ -211,7 +240,14 @@ export function generateBuild(
211
240
  binds.push(`const ${elVar} = ${skVar}.el[${holeIndex(elIndex, node)}];\n`)
212
241
  openTag += ` ${HOLE_ATTRIBUTE}`
213
242
  for (const attr of node.attrs) {
214
- if (attr.kind !== 'static') {
243
+ if (attr.kind === 'spread') {
244
+ /* `{...expr}` onto the element: each key binds as a reactive attribute
245
+ (or an `on<event>` function as a listener) via `spreadAttrs`, skipping
246
+ any key explicitly named on the element (the explicit attr wins). */
247
+ binds.push(
248
+ `spreadAttrs(${elVar}, ${namedThunk('spread', `return (${lowerExpression(attr.code)})`)}, ${JSON.stringify(spreadExcludedNames(node.attrs))});\n`,
249
+ )
250
+ } else if (attr.kind !== 'static') {
215
251
  binds.push(dynamicAttr(node, attr, elVar))
216
252
  }
217
253
  }
@@ -246,7 +282,7 @@ export function generateBuild(
246
282
  skeleton string (parsed once, cloned per mount) plus each hole's wiring against
247
283
  its located node. */
248
284
  function generateSkeleton(
249
- node: Extract<TemplateNode, { kind: 'element' | 'component' }>,
285
+ node: Extract<TemplateNode, { kind: 'element' }>,
250
286
  parentVar: string,
251
287
  ): string {
252
288
  const skVar = nextVar('sk')
@@ -279,18 +315,23 @@ export function generateBuild(
279
315
  const splitAlways = index < consumers.length - 1 ? ', true' : ''
280
316
  return part.kind === 'static'
281
317
  ? `appendStatic(${parentVar}, ${JSON.stringify(part.value)}${splitAlways});\n`
282
- : `appendText(${parentVar}, () => (${lowerExpression(part.code)})${splitAlways});\n`
318
+ : `appendText(${parentVar}, ${namedThunk('text', `return (${lowerExpression(part.code)})`)}${splitAlways});\n`
283
319
  })
284
320
  .join('')
285
321
  }
322
+ if (node.kind === 'element' && node.tag === OUTLET_TAG) {
323
+ /* A standalone layout outlet (a top-level/element-nested `<slot/>` rewritten by
324
+ `asOutlet`, reached outside any skeleton): an empty `outlet` boundary at
325
+ `before`, no anchor — the router fills it with the next chain layer. */
326
+ return `outlet(${parentVar}, ${before});\n`
327
+ }
286
328
  if (node.kind === 'element' && node.tag === 'slot') {
287
- /* In a layout, `<slot/>` is the router's page outlet: a bare empty `OUTLET_TAG`
288
- element the router mounts the next chain layer into, cloned (create) / claimed
289
- (hydrate) so it matches the SSR placeholder. (Top-level/nested-in-element layout
290
- slots are rewritten to `OUTLET_TAG` up front by `asOutlet`; this covers a slot
291
- reached inside a control-flow branch.) */
329
+ /* In a layout, `<slot/>` is the router's page outlet (`outlet` boundary the
330
+ router fills with the next chain layer). Top-level/element-nested layout slots
331
+ are rewritten to `OUTLET_TAG` up front by `asOutlet` and handled above; this
332
+ covers a layout slot reached inside a control-flow branch. */
292
333
  if (isLayout) {
293
- return `cloneStatic(${parentVar}, ${JSON.stringify(`<${OUTLET_TAG}></${OUTLET_TAG}>`)});\n`
334
+ return `outlet(${parentVar}, ${before});\n`
294
335
  }
295
336
  return generateSlot(node, parentVar)
296
337
  }
@@ -314,10 +355,10 @@ export function generateBuild(
314
355
  return '' // branches are consumed by their await block, never standalone
315
356
  }
316
357
  if (node.kind === 'component') {
317
- /* A standalone component builds through the skeleton too its wrapper element
318
- is a located hole, the child mounts into it (same as a component nested in a
319
- skeletonable element). */
320
- return generateSkeleton(node, parentVar)
358
+ /* A standalone component (top-level, or a bare child of a branch/row/slot) mounts
359
+ directly as a marker range on `parentVar` at `before` no anchor, no wrapper,
360
+ same as a standalone control-flow block routes through `generateIf`/etc. */
361
+ return generateChildComponent(node, parentVar, before)
321
362
  }
322
363
  if (node.kind === 'switch') {
323
364
  return generateSwitch(node, parentVar, before)
@@ -402,33 +443,31 @@ export function generateBuild(
402
443
  return `if ($props && $props.$children) { ${invoke}; } else {\n${fallback}}\n`
403
444
  }
404
445
 
405
- /* Mounts a child component into a wrapper element, passing each prop as a
406
- reactive thunk so the child re-reads when the parent expression changes. */
407
- /* The prop + slot thunks a child mount receives — its props as value thunks and
408
- its slot content as a host-taking builder (`$children`). */
409
- function componentParts(node: Extract<TemplateNode, { kind: 'component' }>): string[] {
410
- const parts = node.props.map(
411
- (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
412
- )
446
+ /* The child's slot content as a host-taking builder (`$children`), or undefined when
447
+ the component has no slotted children. */
448
+ function slotPart(node: Extract<TemplateNode, { kind: 'component' }>): string | undefined {
413
449
  const slotCode = generateChildren(node.children, '$slot')
414
- if (slotCode.trim() !== '') {
415
- parts.push(`"$children": ($slot) => {\n${slotCode}}`)
416
- }
417
- return parts
450
+ return slotCode.trim() === '' ? undefined : `"$children": ($slot) => {\n${slotCode}}`
418
451
  }
419
452
 
420
- /* Mounts a child into a wrapper obtained via `varExpr` (a skeleton-located node).
421
- Hydration stays active, so the child adopts its server markup inside the wrapper.
422
- Returns the wrapper var. */
423
- function mountComponent(
453
+ /* The props bag a child mount receives composed by the shared `composeProps` so the
454
+ build and SSR back-ends emit the same last-wins layering. */
455
+ function propsArg(node: Extract<TemplateNode, { kind: 'component' }>): string {
456
+ return composeProps(node.props, lowerExpression, slotPart(node))
457
+ }
458
+
459
+ /* Mounts a child component as a marker-bounded range on `parentVar`, positioned at
460
+ `before` (a skeleton anchor's `anchorCursor`, or `null` for a standalone child).
461
+ `mountRange` opens the `[`/`]` markers and builds the child between them — no
462
+ wrapper element — so the child's root is a true direct child of `parentVar`.
463
+ Hydration stays ambient, so the child claims its server range in place. The
464
+ component name passes as the scope label (the inspector's `<Counter>` name). */
465
+ function generateChildComponent(
424
466
  node: Extract<TemplateNode, { kind: 'component' }>,
425
- varExpr: string,
426
- ): { code: string; varName: string } {
427
- const wrapper = nextVar('cmp')
428
- const code =
429
- `const ${wrapper} = ${varExpr};\n` +
430
- `mountChild(${wrapper}, ${node.name}, { ${componentParts(node).join(', ')} });\n`
431
- return { code, varName: wrapper }
467
+ parentVar: string,
468
+ before: string,
469
+ ): string {
470
+ return `mountChild(${parentVar}, ${node.name}, ${propsArg(node)}, ${before}, ${JSON.stringify(node.name)});\n`
432
471
  }
433
472
 
434
473
  /* An await block: pending → resolved(value) / error branches. Each branch is a
@@ -633,7 +672,7 @@ imperative. Static text and elements nested INSIDE a qualifying element are fine
633
672
  enclosed by its tags.
634
673
  */
635
674
  function isStaticCloneableElement(node: TemplateNode): boolean {
636
- if (node.kind !== 'element' || node.tag === 'slot') {
675
+ if (node.kind !== 'element' || node.tag === 'slot' || node.tag === OUTLET_TAG) {
637
676
  return false
638
677
  }
639
678
  if (node.attrs.some((attr) => attr.kind !== 'static')) {