@abide/abide 0.38.1 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/AGENTS.md +5 -5
  2. package/CHANGELOG.md +63 -0
  3. package/package.json +9 -1
  4. package/src/build.ts +14 -0
  5. package/src/lib/bundle/exitWithParent.ts +4 -2
  6. package/src/lib/server/rpc/parseArgs.ts +10 -1
  7. package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
  8. package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
  9. package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
  10. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
  11. package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
  12. package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
  13. package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
  14. package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
  15. package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
  16. package/src/lib/server/sockets/createSocketDispatcher.ts +19 -3
  17. package/src/lib/server/sockets/defineSocket.ts +3 -1
  18. package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
  19. package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
  20. package/src/lib/shared/buildRpcRequest.ts +8 -1
  21. package/src/lib/shared/createRemoteFunction.ts +20 -11
  22. package/src/lib/shared/decodeRefJson.ts +110 -0
  23. package/src/lib/shared/encodeRefJson.ts +106 -0
  24. package/src/lib/shared/escapeHtml.ts +15 -0
  25. package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
  26. package/src/lib/shared/streamResponse.ts +8 -1
  27. package/src/lib/shared/types/RemoteCallable.ts +12 -3
  28. package/src/lib/shared/types/RpcOptions.ts +22 -0
  29. package/src/lib/shared/types/SourceMap.ts +14 -0
  30. package/src/lib/test/createTestSocketChannel.ts +6 -2
  31. package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
  32. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
  33. package/src/lib/ui/compile/compileModule.ts +5 -2
  34. package/src/lib/ui/compile/compileSSR.ts +32 -9
  35. package/src/lib/ui/compile/compileShadow.ts +25 -10
  36. package/src/lib/ui/compile/composeProps.ts +53 -0
  37. package/src/lib/ui/compile/desugarSignals.ts +45 -17
  38. package/src/lib/ui/compile/generateBuild.ts +87 -76
  39. package/src/lib/ui/compile/generateSSR.ts +217 -71
  40. package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
  41. package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
  42. package/src/lib/ui/compile/parseTemplate.ts +118 -1
  43. package/src/lib/ui/compile/renameSignalRefs.ts +12 -0
  44. package/src/lib/ui/compile/resolveBranches.ts +21 -0
  45. package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
  46. package/src/lib/ui/compile/staticAttr.ts +1 -1
  47. package/src/lib/ui/compile/staticTextPart.ts +1 -1
  48. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
  49. package/src/lib/ui/compile/types/TemplateNode.ts +15 -3
  50. package/src/lib/ui/createScope.ts +14 -0
  51. package/src/lib/ui/dom/appendText.ts +2 -3
  52. package/src/lib/ui/dom/appendTextAt.ts +2 -3
  53. package/src/lib/ui/dom/applyResolved.ts +5 -8
  54. package/src/lib/ui/dom/awaitBlock.ts +14 -1
  55. package/src/lib/ui/dom/mergeProps.ts +32 -0
  56. package/src/lib/ui/dom/on.ts +7 -0
  57. package/src/lib/ui/dom/parseRawNodes.ts +17 -0
  58. package/src/lib/ui/dom/readCall.ts +27 -0
  59. package/src/lib/ui/dom/restProps.ts +32 -0
  60. package/src/lib/ui/dom/spreadAttrs.ts +34 -0
  61. package/src/lib/ui/dom/spreadProps.ts +32 -0
  62. package/src/lib/ui/installHotBridge.ts +10 -0
  63. package/src/lib/ui/navigate.ts +28 -3
  64. package/src/lib/ui/remoteProxy.ts +68 -36
  65. package/src/lib/ui/renderChain.ts +39 -37
  66. package/src/lib/ui/renderToStream.ts +84 -68
  67. package/src/lib/ui/resumeSeedScript.ts +27 -0
  68. package/src/lib/ui/router.ts +81 -51
  69. package/src/lib/ui/runtime/RESUME.ts +13 -6
  70. package/src/lib/ui/runtime/createEffectNode.ts +5 -0
  71. package/src/lib/ui/runtime/localStoragePersistence.ts +21 -8
  72. package/src/lib/ui/runtime/toTeardown.ts +10 -5
  73. package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
  74. package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
  75. package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
  76. package/src/lib/ui/socketChannel.ts +5 -2
  77. package/src/lib/ui/tryEncodeResume.ts +20 -0
  78. package/src/lib/ui/types/Scope.ts +9 -3
  79. package/src/lib/ui/compile/escapeHtml.ts +0 -15
  80. /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
@@ -1,4 +1,5 @@
1
1
  import { decodeHtmlEntities } from './decodeHtmlEntities.ts'
2
+ import { isWhitespaceText } from './isWhitespaceText.ts'
2
3
  import type { TemplateAttr } from './types/TemplateAttr.ts'
3
4
  import type { TemplateNode } from './types/TemplateNode.ts'
4
5
  import type { TextPart } from './types/TextPart.ts'
@@ -121,6 +122,24 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
121
122
  if (char === '>' || char === '/' || char === undefined) {
122
123
  break
123
124
  }
125
+ /* `{...expr}` standing where an attribute name would — a spread of an object's
126
+ keys onto the tag: props on a component, attributes on a native element. Only
127
+ a `<template>` directive rejects it (see `readElement`). */
128
+ if (char === '{') {
129
+ const { code, loc } = readBracedExpression()
130
+ if (!code.startsWith('...')) {
131
+ throw new Error(
132
+ `[abide] a bare {expr} is not a valid attribute — write {...expr} to spread an object's keys as props`,
133
+ )
134
+ }
135
+ /* Advance `loc` past `...` and any whitespace so it points at the spread
136
+ EXPRESSION, not the dots — the shadow source-map invariant (source text at
137
+ `loc` equals the emitted code) requires `loc` and `code` to align. */
138
+ const inner = code.slice(3)
139
+ const leading = inner.length - inner.trimStart().length
140
+ attrs.push({ kind: 'spread', code: inner.trim(), loc: loc + 3 + leading })
141
+ continue
142
+ }
124
143
  let name = ''
125
144
  while (cursor < source.length && !/[\s=>/]/.test(source.charAt(cursor))) {
126
145
  name += source.charAt(cursor)
@@ -163,6 +182,12 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
163
182
  `>`, per the HTML unquoted-attribute rule. No delimiter to consume. */
164
183
  let value = ''
165
184
  while (cursor < source.length && !/[\s>]/.test(source.charAt(cursor))) {
185
+ /* Stop before a `/` that closes the tag (`<Comp x=y/>`) so the value
186
+ doesn't swallow the self-closing slash and defeat detection; a `/`
187
+ elsewhere (e.g. a URL `href=/a/b`) stays part of the value. */
188
+ if (source.charAt(cursor) === '/' && source.charAt(cursor + 1) === '>') {
189
+ break
190
+ }
166
191
  value += source.charAt(cursor)
167
192
  cursor += 1
168
193
  }
@@ -214,6 +239,11 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
214
239
  const slotted = selfClosing ? [] : readChildren(tag)
215
240
  return { kind: 'component', name: tag, props: toProps(attrs), children: slotted }
216
241
  }
242
+ /* `{...expr}` spreads onto a component (its props) or a native element (its
243
+ attributes), but a `<template>` directive has no such bag — reject it there. */
244
+ if (tag === 'template' && attrs.some((attr) => attr.kind === 'spread')) {
245
+ throw new Error('[abide] {...expr} spread is not supported on a <template> directive')
246
+ }
217
247
  const children = selfClosing || VOID_TAGS.has(tag) ? [] : readChildren(tag)
218
248
  if (tag === 'template') {
219
249
  return toControlFlow(attrs, children)
@@ -274,6 +304,13 @@ function rejectStrayBranches(
274
304
  '[abide] <template else>/<template case> must be nested inside its <template if>/<template switch> — a sibling branch is not supported',
275
305
  )
276
306
  }
307
+ /* `elseif` is an `if`-chain branch; inside a `switch` it would silently read as the
308
+ default (match-less), so reject the cross-construct mix. */
309
+ if (node.kind === 'case' && node.condition !== undefined && parentKind === 'switch') {
310
+ throw new Error(
311
+ '[abide] <template elseif> belongs to a <template if> chain, not a <template switch> — use <template case>',
312
+ )
313
+ }
277
314
  if (
278
315
  node.kind === 'branch' &&
279
316
  parentKind !== 'await' &&
@@ -284,20 +321,77 @@ function rejectStrayBranches(
284
321
  '[abide] <template then>/<template catch>/<template finally> must be nested inside its <template await>/<template try>/<template each await>',
285
322
  )
286
323
  }
324
+ if (node.kind === 'if' || node.kind === 'switch') {
325
+ rejectMisplacedBranchContent(node)
326
+ }
287
327
  if ('children' in node) {
288
328
  rejectStrayBranches(node.children, node.kind)
289
329
  }
290
330
  }
291
331
  }
292
332
 
333
+ /* A `<template if>` chain's `then` content precedes its first branch tag
334
+ (`elseif`/`else`); a `<template switch>` renders only its branch tags. So rendered
335
+ content sitting AFTER the first branch in an `if` — or ANYWHERE in a `switch` —
336
+ belongs to no branch: today it silently folds into `then` / is dropped. Reject it so
337
+ the misplacement surfaces. Whitespace stays transparent, and `<script>`/`<style>` are
338
+ directives (scoping, not rendered output), so both remain legal anywhere. */
339
+ function rejectMisplacedBranchContent(
340
+ node: Extract<TemplateNode, { kind: 'if' | 'switch' }>,
341
+ ): void {
342
+ const firstBranch = node.children.findIndex((child) => child.kind === 'case')
343
+ node.children.forEach((child, index) => {
344
+ const isRenderedContent =
345
+ child.kind !== 'case' &&
346
+ child.kind !== 'script' &&
347
+ child.kind !== 'style' &&
348
+ !isWhitespaceText(child)
349
+ /* In a switch nothing but branches renders, so every position is illegal; in an if
350
+ only content past the first branch is (the leading then-content is legal). */
351
+ const illegalPosition =
352
+ node.kind === 'switch' || (firstBranch !== -1 && index > firstBranch)
353
+ if (isRenderedContent && illegalPosition) {
354
+ throw new Error(
355
+ node.kind === 'switch'
356
+ ? '[abide] a <template switch> renders only its <template case>/<template default> branches — move stray content into a branch'
357
+ : '[abide] content after a <template elseif>/<template else> belongs to no branch — the then-content must precede the first branch tag',
358
+ )
359
+ }
360
+ })
361
+ /* In an `if` chain the match-less `<template else>` is the trailing block, so any
362
+ branch after it (a second `else`, or an `elseif`) compiles to invalid `} else {…}
363
+ else if {…}` (SSR/type-shadow) or a silently-shadowed branch (the `switchBlock`
364
+ default wins). Reject so the misordering surfaces here, not as opaque codegen. */
365
+ if (node.kind === 'if') {
366
+ const branches = node.children.filter(
367
+ (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
368
+ )
369
+ const elseIndex = branches.findIndex(
370
+ (branch) => branch.match === undefined && branch.condition === undefined,
371
+ )
372
+ if (elseIndex !== -1 && elseIndex < branches.length - 1) {
373
+ throw new Error(
374
+ '[abide] <template else> must be the last branch of its <template if> chain — no <template elseif>/<template else> may follow it',
375
+ )
376
+ }
377
+ }
378
+ }
379
+
293
380
  /* Turns a component's attributes into props. A component has no directives —
294
381
  every attribute is a prop under its written name, so `on*`/`bind:`/`attach`
295
382
  round-trip to their original names (the kinds the tag-blind attribute parser
296
383
  assigned) instead of being dropped. A static value becomes a string literal —
297
384
  a bare attribute coerces to `true` instead; every other kind keeps its `code`,
298
385
  letting a prop hold any value, functions included (e.g. an `onclick` callback). */
299
- function toProps(attrs: TemplateAttr[]): { name: string; code: string; loc?: number }[] {
386
+ function toProps(
387
+ attrs: TemplateAttr[],
388
+ ): { name: string; code: string; loc?: number; spread?: boolean }[] {
300
389
  return attrs.map((attr) => {
390
+ /* A `{...expr}` spread carries no name — its keys merge in at runtime
391
+ (`mergeProps`/`spreadProps`); `spread: true` marks it for the back-ends. */
392
+ if (attr.kind === 'spread') {
393
+ return { name: '', code: attr.code, loc: attr.loc, spread: true }
394
+ }
301
395
  if (attr.kind === 'static') {
302
396
  /* A bare attribute (`<Toggle on />`) is a boolean flag: coerce it to
303
397
  `true` so the prop reads as a boolean, not the empty string a native
@@ -347,6 +441,13 @@ function attrName(attr: TemplateAttr): string {
347
441
  if (attr.kind === 'attach') {
348
442
  return 'attach'
349
443
  }
444
+ /* A spread has no name. `attrName` only feeds `<template>` directive lookups, and a
445
+ spread on a `<template>` is rejected at parse, so this branch is unreachable in
446
+ practice. (Spread itself IS supported — on components as props and on native
447
+ elements as attributes; only `<template>` directives reject it.) */
448
+ if (attr.kind === 'spread') {
449
+ return ''
450
+ }
350
451
  return attr.name
351
452
  }
352
453
 
@@ -425,6 +526,22 @@ function toControlFlow(attrs: TemplateAttr[], children: TemplateNode[]): Templat
425
526
  }
426
527
  return { kind: 'case', match: matchCode, children, loc: attrLoc(caseAttr) }
427
528
  }
529
+ /* `<template elseif={c}>` is a match-less case carrying a condition — a branch of the
530
+ enclosing `<template if>` chain, truthy-tested in source order. */
531
+ const elseif = find('elseif')
532
+ if (elseif !== undefined) {
533
+ const conditionCode = attrText(elseif)
534
+ if (conditionCode === undefined) {
535
+ throw new Error('[abide] <template elseif> requires a condition expression')
536
+ }
537
+ return {
538
+ kind: 'case',
539
+ match: undefined,
540
+ condition: conditionCode,
541
+ children,
542
+ loc: attrLoc(elseif),
543
+ }
544
+ }
428
545
  if (find('default') !== undefined || find('else') !== undefined) {
429
546
  return { kind: 'case', match: undefined, children } // default (switch) / else (if)
430
547
  }
@@ -68,6 +68,18 @@ export function renameSignalRefs(
68
68
  with that name added, so shadowing is per-branch (a sibling scope is unaffected). */
69
69
  const makeVisitor = (shadowed: ReadonlySet<string>): ts.Visitor => {
70
70
  const visit = (node: ts.Node): ts.Node => {
71
+ /* Type space never holds a value read. A type alias, an interface, or any
72
+ type annotation can name a signal — a prop-type member `option?: …`, a
73
+ `typeof x` — without it being a runtime reference. Leave the whole type
74
+ subtree untouched so it isn't rewritten into a call/access (`option()`)
75
+ and emitted as broken code (types erase at build anyway). */
76
+ if (
77
+ ts.isTypeAliasDeclaration(node) ||
78
+ ts.isInterfaceDeclaration(node) ||
79
+ ts.isTypeNode(node)
80
+ ) {
81
+ return node
82
+ }
71
83
  /* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`,
72
84
  unless a nearer scope shadows the name. */
73
85
  if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
@@ -0,0 +1,21 @@
1
+ import type { TemplateNode } from './types/TemplateNode.ts'
2
+
3
+ type Branch = Extract<TemplateNode, { kind: 'branch' }>
4
+ type BranchName = Branch['branch']
5
+
6
+ /* Extracts the `then`/`catch`/`finally` branch children of a control-flow block
7
+ (await/try), one slot per requested name in the order asked, so callers
8
+ destructure them directly. A requested branch that is absent yields `undefined`
9
+ — the caller reads `?.children`/`?.as` for its content and bound var. This is
10
+ the one branch-lookup site both back-ends share, replacing the per-generator
11
+ `findBranch`/`branchNamed`/`branchChildren`/`branchVar` copies. */
12
+ export function resolveBranches(
13
+ node: Extract<TemplateNode, { children: TemplateNode[] }>,
14
+ ...which: BranchName[]
15
+ ): (Branch | undefined)[] {
16
+ return which.map((name) =>
17
+ node.children.find(
18
+ (child): child is Branch => child.kind === 'branch' && child.branch === name,
19
+ ),
20
+ )
21
+ }
@@ -0,0 +1,27 @@
1
+ import type { TemplateAttr } from './types/TemplateAttr.ts'
2
+
3
+ /*
4
+ The explicitly-authored attribute names on a native element that also carries a
5
+ `{...spread}`. An explicit attribute wins over a spread key of the same name, so
6
+ both back-ends pass this set to skip those keys from the spread — keeping the
7
+ server string and the client DOM congruent (no duplicate attribute, and no
8
+ SSR-first-wins vs client-spread-wins divergence). Excluding by NAME (not by a
9
+ runtime present/absent check) means the two sides agree regardless of a dynamic
10
+ attribute's runtime value. Static and dynamic (`{expr}`) attrs contribute their
11
+ name, a `bind:` its property, an `on<event>` its `on…` form; `attach`/`spread`
12
+ carry no attribute name.
13
+ */
14
+ export function spreadExcludedNames(attrs: TemplateAttr[]): string[] {
15
+ return attrs.flatMap((attr) => {
16
+ if (attr.kind === 'static' || attr.kind === 'expression') {
17
+ return [attr.name]
18
+ }
19
+ if (attr.kind === 'bind') {
20
+ return [attr.property]
21
+ }
22
+ if (attr.kind === 'event') {
23
+ return [`on${attr.event}`]
24
+ }
25
+ return []
26
+ })
27
+ }
@@ -1,4 +1,4 @@
1
- import { escapeHtml } from './escapeHtml.ts'
1
+ import { escapeHtml } from '../../shared/escapeHtml.ts'
2
2
 
3
3
  /*
4
4
  Serializes one static attribute to its markup fragment, leading space included.
@@ -1,4 +1,4 @@
1
- import { escapeHtml } from './escapeHtml.ts'
1
+ import { escapeHtml } from '../../shared/escapeHtml.ts'
2
2
 
3
3
  /*
4
4
  Serializes one static text part to its markup: whitespace-only parts drop (both
@@ -6,7 +6,9 @@ reactively; `event` is `on<event>={code}` where `code` evaluates to the handler;
6
6
  `bind` is `bind:<property>={lvalue}`, a two-way binding whose `code` is the
7
7
  writable doc path read into the property and written back on input; `attach` is
8
8
  `attach={code}` where `code` evaluates to an attachment `(node) => teardown` run
9
- at build with node-lifetime teardown. `loc` is the
9
+ at build with node-lifetime teardown; `spread` is `{...code}` where `code`
10
+ evaluates to an object whose own keys each become a prop on a component or an
11
+ attribute on a native element. `loc` is the
10
12
  absolute offset of `code` in the original `.abide` source (see TextPart) —
11
13
  optional, set only when the parser tracks positions for the type-checking shadow.
12
14
  */
@@ -16,3 +18,4 @@ export type TemplateAttr =
16
18
  | { kind: 'event'; event: string; code: string; loc?: number }
17
19
  | { kind: 'bind'; property: string; code: string; loc?: number }
18
20
  | { kind: 'attach'; code: string; loc?: number }
21
+ | { kind: 'spread'; code: string; loc?: number }
@@ -4,7 +4,7 @@ import type { TextPart } from './TextPart.ts'
4
4
  /*
5
5
  A parsed template node. `text` carries interpolation parts; `element` carries
6
6
  attributes and children; `each` is the `<template each as key>` control flow over
7
- a list. (if/await/switch are future siblings.)
7
+ a list; `if`/`await`/`switch`/`try` are its control-flow siblings.
8
8
 
9
9
  `loc` (where present) is the absolute offset of the node's primary expression in
10
10
  the original `.abide` source — additive, set only when the parser tracks
@@ -61,11 +61,23 @@ export type TemplateNode =
61
61
  | {
62
62
  kind: 'component'
63
63
  name: string
64
- props: { name: string; code: string; loc?: number }[]
64
+ /* Each authored attribute as a prop. A `spread` entry (`{...code}`) carries no
65
+ `name`; its keys merge in at runtime (`mergeProps`/`spreadProps`). */
66
+ props: { name: string; code: string; loc?: number; spread?: boolean }[]
65
67
  children: TemplateNode[]
66
68
  }
67
69
  | { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
68
- | { kind: 'case'; match: string | undefined; children: TemplateNode[]; loc?: number }
70
+ /* A branch of a `<template switch>` (`match` set) or `<template if>` chain. Inside an
71
+ `if`, `<template elseif={c}>` sets `condition` (match-less, truthy-tested), and
72
+ `<template else>` leaves both unset (the default). `loc` points at whichever
73
+ expression the node carries (`match` or `condition`). */
74
+ | {
75
+ kind: 'case'
76
+ match: string | undefined
77
+ condition?: string
78
+ children: TemplateNode[]
79
+ loc?: number
80
+ }
69
81
  /* A `<template name="row" args={item}>` snippet: a named, scope-capturing
70
82
  builder declared once and called like a function (`{row(item)}`). `params`
71
83
  is the raw `args` source spliced into the builder's parameter list. */
@@ -37,6 +37,9 @@ export function createScope(
37
37
  const data = (): Doc => (document ??= createDoc({}))
38
38
  const id = parent === undefined ? `scope-${nextId++}` : `${parent.id}.${nextId++}`
39
39
  const children: Scope[] = []
40
+ /* Context values shared down the tree, held apart from the reactive doc (which
41
+ a child does not inherit): keyed by name, read by the closest ancestor walk. */
42
+ const shared = new Map<string, unknown>()
40
43
  let past: History | undefined
41
44
  let persistence: PersistHandle | undefined
42
45
  let unsync: (() => void) | undefined
@@ -64,6 +67,16 @@ export function createScope(
64
67
  return created
65
68
  },
66
69
  root: () => (parent === undefined ? self : parent.root()),
70
+ /* Reference store — no tracking, so a lookup never subscribes; reactivity comes
71
+ from what is shared (a cell/scope), not from the share. */
72
+ share: (key, value) => {
73
+ shared.set(key, value)
74
+ },
75
+ /* Closest-ancestor resolve: own map first (self can read what it shared), then
76
+ defer up via each scope's own `shared`. `has` distinguishes a shared
77
+ `undefined` from "not provided"; undefined at the root means no provider. */
78
+ shared: <T>(key: string): T | undefined =>
79
+ shared.has(key) ? (shared.get(key) as T) : parent?.shared<T>(key),
67
80
  record: (options) => {
68
81
  past ??= history(data(), options)
69
82
  },
@@ -82,6 +95,7 @@ export function createScope(
82
95
  created.dispose()
83
96
  }
84
97
  children.length = 0
98
+ shared.clear()
85
99
  past?.dispose()
86
100
  past = undefined
87
101
  persistence?.dispose()
@@ -4,6 +4,7 @@ import { effect } from '../effect.ts'
4
4
  import { claimChild } from '../runtime/claimChild.ts'
5
5
  import { RENDER } from '../runtime/RENDER.ts'
6
6
  import { appendSnippet } from './appendSnippet.ts'
7
+ import { parseRawNodes } from './parseRawNodes.ts'
7
8
 
8
9
  const CLOSE = '/abide:html'
9
10
 
@@ -73,9 +74,7 @@ function appendRawHtml(parent: Node, read: () => unknown): void {
73
74
  for (const node of nodes) {
74
75
  parent.removeChild(node)
75
76
  }
76
- const holder = document.createElement('div')
77
- holder.innerHTML = value
78
- nodes = [...holder.childNodes]
77
+ nodes = parseRawNodes(parent, value)
79
78
  for (const node of nodes) {
80
79
  parent.insertBefore(node, anchor)
81
80
  }
@@ -4,6 +4,7 @@ import { effect } from '../effect.ts'
4
4
  import { RENDER } from '../runtime/RENDER.ts'
5
5
  import { appendSnippet } from './appendSnippet.ts'
6
6
  import { appendText } from './appendText.ts'
7
+ import { parseRawNodes } from './parseRawNodes.ts'
7
8
 
8
9
  /*
9
10
  A reactive `{expr}` interpolation mounted at a skeleton anchor comment (`<!--a-->`), used
@@ -51,9 +52,7 @@ export function appendTextAt(anchor: Node, read: () => unknown): void {
51
52
  for (const node of nodes) {
52
53
  parent.removeChild(node)
53
54
  }
54
- const holder = document.createElement('div')
55
- holder.innerHTML = rawHtmlString(read()) ?? ''
56
- nodes = [...holder.childNodes]
55
+ nodes = parseRawNodes(parent, rawHtmlString(read()) ?? '')
57
56
  /* Insert the fresh markup just after the anchor (its live re-insertion point). */
58
57
  const after = anchor.nextSibling
59
58
  for (const node of nodes) {
@@ -41,16 +41,13 @@ export function applyResolved(root: Element, frame: string): void {
41
41
  if (id === null) {
42
42
  return
43
43
  }
44
- /* The resolved value rides in a leading <script type=application/json>; parse and
45
- remove it so only the resolved markup moves into the boundary. Recording it lets a
46
- later hydrate adopt this branch (no re-fetch). */
44
+ /* The resolved value rides in a leading <script type=application/json> as ref-json
45
+ text; store it raw and remove the node so only the resolved markup moves into the
46
+ boundary. Decoding is deferred to the read in `awaitBlock` (which has the codec);
47
+ recording it lets a later hydrate adopt this branch (no re-fetch). */
47
48
  const payload = resolved.firstChild as Element | null
48
49
  if (payload !== null && payload.nodeName === 'SCRIPT') {
49
- try {
50
- RESUME[Number(id)] = JSON.parse(payload.textContent ?? 'null')
51
- } catch {
52
- /* malformed payload — leave unregistered, hydration re-runs the promise */
53
- }
50
+ RESUME[Number(id)] = payload.textContent ?? ''
54
51
  payload.remove()
55
52
  }
56
53
  const open = `abide:await:${id}`
@@ -1,6 +1,8 @@
1
+ import { decodeRefJson } from '../../shared/decodeRefJson.ts'
1
2
  import { effect } from '../effect.ts'
2
3
  import { claimChild } from '../runtime/claimChild.ts'
3
4
  import { RENDER } from '../runtime/RENDER.ts'
5
+ import type { ResumeEntry } from '../runtime/RESUME.ts'
4
6
  import { RESUME } from '../runtime/RESUME.ts'
5
7
  import { scope } from '../runtime/scope.ts'
6
8
  import { scopeGroup } from '../runtime/scopeGroup.ts'
@@ -161,7 +163,18 @@ export function awaitBlock(
161
163
  const firstHydrate = (result: unknown): void => {
162
164
  const cursor = hydration as NonNullable<typeof hydration>
163
165
  const open = claimChild(cursor, parent)
164
- const entry = RESUME[id]
166
+ /* RESUME holds the ref-json-encoded entry STRING; decode here, where the codec
167
+ lives. A decode failure (malformed/absent payload) reads as "no resume" — fall
168
+ through to the live promise rather than crash hydration. */
169
+ const raw = RESUME[id]
170
+ let entry: ResumeEntry | undefined
171
+ if (raw !== undefined) {
172
+ try {
173
+ entry = decodeRefJson(raw) as ResumeEntry
174
+ } catch {
175
+ entry = undefined
176
+ }
177
+ }
165
178
  if (entry !== undefined) {
166
179
  let build: (host: Node) => void
167
180
  if (entry.ok) {
@@ -0,0 +1,32 @@
1
+ import type { UiProps } from '../runtime/types/UiProps.ts'
2
+
3
+ /*
4
+ Composes a child's props from ordered layers — explicit prop runs (`{ name: () =>
5
+ value }` thunk maps), `{...spread}` layers (`spreadProps`), and a trailing slot
6
+ layer (`$children`) — into one prop bag, last layer wins per key (source order, like
7
+ JSX). A key resolves by scanning layers in reverse for the first that carries it, so
8
+ an explicit prop after a spread overrides it and vice-versa. Emitted only when a
9
+ component carries a spread; the plain object literal stays the path otherwise.
10
+ */
11
+ // @documentation plumbing
12
+ export function mergeProps(layers: Record<string | symbol, unknown>[]): UiProps {
13
+ return new Proxy(Object.create(null), {
14
+ get(_target, key) {
15
+ for (let index = layers.length - 1; index >= 0; index -= 1) {
16
+ const layer = layers[index]
17
+ if (layer !== undefined && key in layer) {
18
+ return layer[key]
19
+ }
20
+ }
21
+ return undefined
22
+ },
23
+ has: (_target, key) => layers.some((layer) => key in layer),
24
+ /* Enumerable across every layer (deduped), so `restProps` can collect a
25
+ parent-spread bag's keys. */
26
+ ownKeys: () => [...new Set(layers.flatMap((layer) => Reflect.ownKeys(layer)))],
27
+ getOwnPropertyDescriptor: (_target, key) =>
28
+ layers.some((layer) => key in layer)
29
+ ? { enumerable: true, configurable: true }
30
+ : undefined,
31
+ }) as UiProps
32
+ }
@@ -12,6 +12,13 @@ resolves the component, not whatever is current when the event fires.
12
12
  */
13
13
  // @documentation plumbing
14
14
  export function on(element: Element, type: string, handler: EventListener): void {
15
+ /* A caller that omits an optional `on*` prop forwards it as undefined, yet the
16
+ compiler still emits this on() call — so skip attaching a non-function rather
17
+ than invoke undefined when the event fires (e.g. input/keydown on a search box,
18
+ which would throw "handler is not a function" on every keystroke). */
19
+ if (typeof handler !== 'function') {
20
+ return
21
+ }
15
22
  const captured = CURRENT_SCOPE.current
16
23
  const wrapped: EventListener = (event) => inScope(captured, () => handler(event))
17
24
  element.addEventListener(type, wrapped)
@@ -0,0 +1,17 @@
1
+ import { foreignWrapperTag } from './foreignWrapperTag.ts'
2
+
3
+ /*
4
+ Parses a trusted raw-markup string into detached nodes in the SAME foreign namespace
5
+ as `parent`. The HTML fragment parser namespaces by its context element, so markup
6
+ bound inside an <svg>/<math> must parse inside a matching wrapper — otherwise a bare
7
+ `<path>` lands in the HTML namespace and never renders. Mirrors `cloneStatic`'s
8
+ foreign handling, but parses per-call into a throwaway `<template>` rather than the
9
+ skeleton cache: raw values are dynamic, so caching every distinct string would leak.
10
+ */
11
+ export function parseRawNodes(parent: Node, markup: string): Node[] {
12
+ const wrapper = foreignWrapperTag(parent)
13
+ const template = document.createElement('template')
14
+ template.innerHTML = wrapper === undefined ? markup : `<${wrapper}>${markup}</${wrapper}>`
15
+ const source = wrapper === undefined ? template.content : (template.content.firstChild as Node)
16
+ return [...source.childNodes]
17
+ }
@@ -0,0 +1,27 @@
1
+ /*
2
+ Guarded method call on a reactive-document read. The doc-access lowering rewrites
3
+ `model.draft.trim()` into `model.read("draft").trim()`; when that read is nullish
4
+ the bare form throws the engine's opaque `undefined is not an object (evaluating
5
+ '…read("draft").trim()')`, naming only the desugared call. This wraps the
6
+ non-optional call so the throw names the authored scope path and member instead —
7
+ the source-mapped stack frame still resolves to the `.abide` line, so message and
8
+ location together read in authored terms. Both opaque engine errors are covered: a
9
+ nullish receiver (`undefined is not an object`) AND a present receiver whose member
10
+ is not callable (`… is not a function`, the misspelled/missing-method case).
11
+ Optional-chained calls are left bare: `?.` means skip-if-absent, not throw, so
12
+ guarding them would change semantics. `.apply(target, …)` preserves the receiver, so
13
+ the method sees the same `this` the bare `target.member(…)` would.
14
+ */
15
+ // @documentation plumbing
16
+ export function readCall(target: unknown, path: string, member: string, args: unknown[]): unknown {
17
+ if (target === undefined || target === null) {
18
+ throw new TypeError(`abide: cannot call .${member}() — scope value "${path}" is ${target}`)
19
+ }
20
+ const method = (target as Record<string, unknown>)[member]
21
+ if (typeof method !== 'function') {
22
+ throw new TypeError(
23
+ `abide: cannot call .${member}() — "${path}".${member} is not a function (got ${typeof method})`,
24
+ )
25
+ }
26
+ return (method as (...args: unknown[]) => unknown).apply(target, args)
27
+ }
@@ -0,0 +1,32 @@
1
+ import type { UiProps } from '../runtime/types/UiProps.ts'
2
+
3
+ /*
4
+ The rest of a component's props — `const { foo, ...rest } = props()` — as a live
5
+ object of the UNCONSUMED prop values. A child receives prop THUNKS, so `rest.key`
6
+ unwraps them (`$props[key]?.()`), tracking as a reactive dependency; a top-level
7
+ page/layout instead receives its route params as PLAIN values, so a non-function
8
+ value is returned as-is rather than called (which would throw `value is not a
9
+ function`). The explicitly-destructured keys and the `$children` slot are excluded.
10
+ Enumerable (`ownKeys`/`for…in`/`Object.keys`), so `{...rest}` can forward the
11
+ remaining props onto a child or a native element. Key membership is live, but a
12
+ consumer that captures the key SET (a `{...rest}` spread) snapshots it at that point.
13
+ */
14
+ // @documentation plumbing
15
+ export function restProps(props: UiProps, consumed: string[]): Record<string, unknown> {
16
+ const skip = new Set([...consumed, '$children'])
17
+ const bag = props as Record<string, unknown>
18
+ const visible = (key: string | symbol): key is string =>
19
+ typeof key === 'string' && !skip.has(key) && key in bag
20
+ const read = (key: string): unknown =>
21
+ typeof bag[key] === 'function' ? (bag[key] as () => unknown)() : bag[key]
22
+ return new Proxy(
23
+ {},
24
+ {
25
+ get: (_target, key) => (visible(key) ? read(key) : undefined),
26
+ has: (_target, key) => visible(key),
27
+ ownKeys: () => Reflect.ownKeys(bag).filter(visible),
28
+ getOwnPropertyDescriptor: (_target, key) =>
29
+ visible(key) ? { enumerable: true, configurable: true } : undefined,
30
+ },
31
+ )
32
+ }
@@ -0,0 +1,34 @@
1
+ import { attr } from './attr.ts'
2
+ import { on } from './on.ts'
3
+
4
+ /*
5
+ Spreads an object's keys onto a native element — the `<div {...rest}>` runtime. `source`
6
+ is a thunk over the spread expression; its keys are enumerated ONCE (so the attribute set
7
+ is fixed at build, not reactively added/removed), but each value stays live: an `on<event>`
8
+ key holding a function attaches as that event listener (mirroring `onclick={…}` on an
9
+ element), and every other key binds as a reactive attribute (`attr`, re-reading the source
10
+ per change, with the same present/absent semantics — `false`/`null`/`undefined` removes it,
11
+ `true` sets it bare). A function under a non-`on` key is skipped (no attribute for it). A key
12
+ in `exclude` is skipped — the element names it explicitly, and an explicit attribute wins
13
+ over a spread key (the same set SSR's `$spread` skips, keeping the two sides congruent).
14
+ */
15
+ // @documentation plumbing
16
+ export function spreadAttrs(
17
+ element: Element,
18
+ source: () => Record<string, unknown>,
19
+ exclude: string[] = [],
20
+ ): void {
21
+ const skip = new Set(exclude)
22
+ const object = source()
23
+ for (const key in object) {
24
+ if (skip.has(key)) {
25
+ continue
26
+ }
27
+ const value = object[key]
28
+ if (key.startsWith('on') && typeof value === 'function') {
29
+ on(element, key.slice(2), value as EventListener)
30
+ } else if (typeof value !== 'function') {
31
+ attr(element, key, () => source()[key])
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,32 @@
1
+ /*
2
+ Wraps a `{...source}` spread layer so every key resolves to a live value thunk —
3
+ the same `() => value` shape an explicit `name={expr}` prop compiles to, so a child
4
+ reads a spread key exactly like an authored one (`$props[key]?.()`). `source` is a
5
+ THUNK over the spread expression (not its value), re-evaluated on each key read and
6
+ membership test, so a reactive source stays live both when its keys mutate and when
7
+ the whole object is replaced — and the read registers as the reader's dependency. A
8
+ nullish source spreads nothing. The reserved `$children` slot key is never surfaced —
9
+ a source happening to carry one must not masquerade as slot content.
10
+ */
11
+ // @documentation plumbing
12
+ export function spreadProps(
13
+ source: () => Record<string, unknown> | null | undefined,
14
+ ): Record<string, () => unknown> {
15
+ /* A spread key the merged bag exposes — present on the current source and not the
16
+ reserved `$children` slot key (a source carrying one must not become slot content). */
17
+ const carries = (key: string | symbol): boolean =>
18
+ key !== '$children' && key in (source() ?? {})
19
+ return new Proxy(
20
+ {},
21
+ {
22
+ /* Each read returns a fresh thunk that re-evaluates the source for the key. */
23
+ get: (_target, key) => () => source()?.[key as string],
24
+ /* Live membership: whether the current source object carries the key. */
25
+ has: (_target, key) => carries(key),
26
+ /* Enumerable over the current source, so a merged bag reports spread keys. */
27
+ ownKeys: () => Reflect.ownKeys(source() ?? {}).filter(carries),
28
+ getOwnPropertyDescriptor: (_target, key) =>
29
+ carries(key) ? { enumerable: true, configurable: true } : undefined,
30
+ },
31
+ ) as Record<string, () => unknown>
32
+ }