@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,9 +1,12 @@
1
1
  /*
2
- Source text for the `$esc` / `$attr` / `$text` / `$snip` helpers injected into
3
- every SSR render body. `$esc` escapes the five HTML-significant characters. `$attr`
2
+ Source text for the `$esc` / `$attr` / `$spread` / `$text` / `$snip` helpers injected
3
+ into every SSR render body. `$esc` escapes the five HTML-significant characters. `$attr`
4
4
  renders a dynamic `{expr}` attribute with the same present/absent semantics the
5
5
  client `attr` binding uses: false/null/undefined drops it, true emits the bare
6
- attribute, anything else emits `name="escaped"`. `$snip` brands a snippet's
6
+ attribute, anything else emits `name="escaped"`. `$spread` renders a `{...obj}`
7
+ element spread — each key as an attribute via `$attr`, skipping functions (event
8
+ handlers, wired client-side by `spreadAttrs`) and any key in the `skip` list (a key the
9
+ element names explicitly, which wins over the spread) — mirroring that client runtime. `$snip` brands a snippet's
7
10
  rendered string. `$text` is what `{expr}` interpolations push: a snippet call's
8
11
  value is emitted raw between `<!--abide:snippet-->` markers (the client runs its
9
12
  builder there to claim the nodes); a `html\`…\`` value raw between `<!--abide:html-->`
@@ -16,6 +19,13 @@ export const SSR_ESCAPE =
16
19
  "({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' })[c]);\n" +
17
20
  'const $attr = (n, v) => (v === false || v === null || v === undefined) ? "" ' +
18
21
  ': v === true ? (" " + n) : (" " + n + \'="\' + $esc(v) + \'"\');\n' +
22
+ 'const $spread = (o, skip) => { let s = ""; for (const k in o) { ' +
23
+ 'if (skip && skip.indexOf(k) >= 0) continue; ' +
24
+ /* Skip keys that aren't valid attribute names: $attr emits the name verbatim, so a
25
+ runtime-controlled key with whitespace/quotes/`>`/`=`/`/` would inject markup. The
26
+ client's setAttribute throws on these, so dropping them matches that behaviour. */
27
+ 'if (/[\\s"\'>\\/=]/.test(k)) continue; ' +
28
+ 'const v = o[k]; if (typeof v !== "function") s += $attr(k, v); } return s; };\n' +
19
29
  "const $RAW = Symbol.for('abide.rawHtml');\n" +
20
30
  "const $SNIP = Symbol.for('abide.snippet');\n" +
21
31
  'const $snip = (s) => ({ [$SNIP]: s });\n' +
@@ -32,6 +32,11 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string }[] = [
32
32
  { name: 'mountSlot', specifier: 'ui/dom/mountSlot' },
33
33
  { name: 'outlet', specifier: 'ui/dom/outlet' },
34
34
  { name: 'mountChild', specifier: 'ui/dom/mountChild' },
35
+ { name: 'mergeProps', specifier: 'ui/dom/mergeProps' },
36
+ { name: 'spreadProps', specifier: 'ui/dom/spreadProps' },
37
+ { name: 'restProps', specifier: 'ui/dom/restProps' },
38
+ { name: 'spreadAttrs', specifier: 'ui/dom/spreadAttrs' },
39
+ { name: 'readCall', specifier: 'ui/dom/readCall' },
35
40
  { name: 'hydrate', specifier: 'ui/dom/hydrate' },
36
41
  { name: 'escapeKey', specifier: 'ui/runtime/escapeKey' },
37
42
  { name: 'nextBlockId', specifier: 'ui/runtime/nextBlockId' },
@@ -9,7 +9,10 @@ Wraps a component into a complete ES module with two entry points:
9
9
 
10
10
  - default `component(host, $props)` — mounts the client build, returns the
11
11
  disposer (`import Counter from './Counter.abide'; const stop = Counter(host)`);
12
- - `render($props)` — server-renders to `{ html, state, awaits }` for SSR.
12
+ - `render($props, $ctx)` — async, server-renders to `{ html, state, awaits, resume }`
13
+ for SSR. `$ctx` is the request-local block-id counter, threaded so a child's
14
+ `await`/`try` ids share the page's depth-first numbering; omitted at the top level
15
+ (a fresh counter defaults in).
13
16
 
14
17
  `render` is also attached to the default export (`component.render`) so a parent
15
18
  can server-render a child it imported by its default name. Both entry points share
@@ -76,7 +79,7 @@ export function hydrateInto(host, $props) {
76
79
  return hydrate(host, build, $props)
77
80
  }
78
81
 
79
- export function render($props) {
82
+ export function render($props, $ctx) {
80
83
  ${ssrBody}
81
84
  }
82
85
 
@@ -16,10 +16,24 @@ front-end, then the SSR back-end, and returns `{ html, state, awaits }`:
16
16
  Effects are stripped — they are client lifecycle and emit no HTML, so the server
17
17
  render is a snapshot of the markup before any effect runs.
18
18
 
19
- Runs with `doc`/`state`/`computed`/`effect`/`nextBlockId`/`enterRenderPass`/
20
- `exitRenderPass` in scope and defines `model`. The body is bracketed by a render
21
- pass so the outermost render resets the block-id counter and an inlined child
22
- render continues it keeping await/try ids unique and aligned with the client.
19
+ Defines `model` with `doc`/`state`/`computed`/`effect` in scope. The block-id counter is
20
+ the request-local `$ctx` (threaded into child renders), not a module global a blocking
21
+ `await` block awaits its promise at its structural position and renders inline, so ids
22
+ allocate depth-first like the client; render yields at that `await`, and a shared global
23
+ counter would interleave across concurrent requests. `$ctx` defaults to a fresh counter
24
+ when a caller (a test, a top-level render) omits it.
25
+
26
+ The body is wrapped in an ASYNC IIFE (returns `Promise<SsrRender>`) ONLY when it contains
27
+ an inline `await` — a blocking `{#await … then}` block, a child-component render, a `<slot>`
28
+ read (its `$children` builder is async), or a top-level `await` in the author script. A
29
+ purely synchronous / streaming-await / try-only component returns `SsrRender` directly. The
30
+ framework always `await`s `render()`, so production is uniform either way; the sync return
31
+ keeps static pages off the microtask queue and leaves the bulk of the SSR tests synchronous.
32
+
33
+ The reactive scope brackets the body; it is entered synchronously before any inline `await`,
34
+ so `const model = scope()` resolves correctly, and `model` is captured for the rest of the
35
+ render (its reads are object-method calls, not scope lookups, so they stay correct across
36
+ the awaits).
23
37
 
24
38
  `analyzed` is a lazy default: a direct caller (tests) omits it and the front-end
25
39
  runs here, but `compileModule` shares one analysis across both back-ends.
@@ -31,15 +45,24 @@ export function compileSSR(
31
45
  analyzed: AnalyzedComponent = analyzeComponent(source, scopeSeed),
32
46
  ): string {
33
47
  const { script, stateNames, derivedNames, computedNames, nodes } = analyzed
48
+ const lowered = stripEffects(script)
34
49
  const ssr = generateSSR(nodes, stateNames, derivedNames, computedNames, isLayout)
35
50
  /* No `<style>` in the markup — the scoped CSS is bundled into the entry stylesheet
36
51
  the shell links (see `abideUiPlugin`), so SSR output is styled by that sheet. The
37
52
  elements still carry their `data-a-…` scopes via `generateSSR`. */
38
53
  /* `typeof model` guards a component with no reactive state (a pure-async or
39
54
  static component declares no `model`); its snapshot is then empty. */
40
- return (
41
- `enterRenderPass();\nconst $scope = enterScope();\ntry {\n${stripEffects(script)}\n${SSR_ESCAPE}\nconst $out = [];\nconst $awaits = [];\n${ssr}` +
42
- `return { html: $out.join(''), state: (typeof model !== 'undefined' ? model.snapshot() : {}), awaits: $awaits };\n` +
43
- `} finally { exitScope($scope); exitRenderPass(); }`
44
- )
55
+ const body =
56
+ `const $scope = enterScope();\ntry {\n${lowered}\n${SSR_ESCAPE}\nconst $out = [];\nconst $awaits = [];\nconst $resume = {};\n${ssr}` +
57
+ `return { html: $out.join(''), state: (typeof model !== 'undefined' ? model.snapshot() : {}), awaits: $awaits, resume: $resume };\n` +
58
+ `} finally { exitScope($scope); }`
59
+ /* An inline `await` — a blocking await block, a child render, a slot read, or a
60
+ top-level `await` in the author script — forces an async wrapper. Match `await` as a
61
+ standalone token (operator), so a no-space form (`await(x)`, `await[i]`, `` await`t` ``)
62
+ is caught where a `.includes('await ')` substring scan would miss it and emit a bare
63
+ top-level `await` in a non-async function (a SyntaxError). `(?!:)` excludes the
64
+ `<!--abide:await:N-->` boundary-marker strings; `\b` excludes `$awaits`. A false
65
+ positive (the token in author text) only costs a needless async wrapper, never a crash. */
66
+ const needsAsync = /\bawait\b(?!:)/.test(`${lowered}${ssr}`)
67
+ return `var $ctx = $ctx || { next: 0 };\n${needsAsync ? `return (async () => {\n${body}\n})();` : body}`
45
68
  }
@@ -357,9 +357,17 @@ function emitNode(node: TemplateNode, builder: Builder): void {
357
357
  unterminated (a script ending in a call with no trailing semicolon,
358
358
  e.g. `effect(() => …)`) merges across the newline into `effect(…)(…)`
359
359
  — a spurious "not callable" on the author's last statement. */
360
- builder.raw(
361
- `;((__prop: Parameters<typeof ${node.name}>[0][${JSON.stringify(prop.name)}]) => {})(`,
362
- )
360
+ if (prop.spread) {
361
+ /* A `{...expr}` spread contributes a SUBSET of the props (required ones
362
+ may come from another spread/explicit prop), so check it against
363
+ `Partial<Props>` — every key it does carry must match the child's
364
+ declared type, without demanding completeness. */
365
+ builder.raw(`;((__spread: Partial<Parameters<typeof ${node.name}>[0]>) => {})(`)
366
+ } else {
367
+ builder.raw(
368
+ `;((__prop: Parameters<typeof ${node.name}>[0][${JSON.stringify(prop.name)}]) => {})(`,
369
+ )
370
+ }
363
371
  builder.expr(prop.code, prop.loc)
364
372
  builder.raw(');\n')
365
373
  }
@@ -373,19 +381,26 @@ function emitNode(node: TemplateNode, builder: Builder): void {
373
381
  narrowing — emitting it inside the `if` block (as a plain child) instead
374
382
  gave it the positive narrowing, so a literal-union compare read as a
375
383
  "no overlap" and a typeof-narrowed branch saw the wrong member. */
376
- const elseChild = node.children.find(
377
- (child): child is Extract<TemplateNode, { kind: 'case' }> =>
378
- child.kind === 'case' && child.match === undefined,
384
+ const branches = node.children.filter(
385
+ (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
379
386
  )
380
- const thenChildren = node.children.filter((child) => child !== elseChild)
387
+ const thenChildren = node.children.filter((child) => child.kind !== 'case')
381
388
  builder.raw('if ')
382
389
  builder.expr(node.condition, node.loc)
383
390
  builder.raw(' {\n')
384
391
  emitNodes(thenChildren, builder)
385
392
  builder.raw('}')
386
- if (elseChild !== undefined) {
387
- builder.raw(' else {\n')
388
- emitNodes(elseChild.children, builder)
393
+ /* `elseif` → a real `else if` so its body inherits the prior conditions' negative
394
+ narrowing plus its own positive; `else` → the trailing block. */
395
+ for (const branch of branches) {
396
+ if (branch.condition !== undefined) {
397
+ builder.raw(' else if ')
398
+ builder.expr(branch.condition, branch.loc)
399
+ builder.raw(' {\n')
400
+ } else {
401
+ builder.raw(' else {\n')
402
+ }
403
+ emitNodes(branch.children, builder)
389
404
  builder.raw('}')
390
405
  }
391
406
  builder.raw('\n')
@@ -0,0 +1,53 @@
1
+ import type { TemplateNode } from './types/TemplateNode.ts'
2
+
3
+ /* The authored props of a child-component node (each attribute lowered to a prop;
4
+ a `spread` entry carries no `name`). */
5
+ type ComponentProps = Extract<TemplateNode, { kind: 'component' }>['props']
6
+
7
+ /*
8
+ The props-bag source expression a child mount/render receives, shared by the build
9
+ and SSR back-ends so their last-wins layering stays byte-identical — the invariant
10
+ SSR/client prop congruence rests on. No spread → the plain object literal of named
11
+ value thunks (+ the trailing slot). With a `{...expr}` spread → a `mergeProps` of
12
+ ordered layers — explicit-prop runs, `spreadProps(expr)` spreads, the slot —
13
+ resolved last-wins per key, so source order decides overrides (like JSX).
14
+ `lowerExpression` is the caller's expression lowering; `slotPart` is its `$children`
15
+ layer (a host-taking builder for the client, a string-returning thunk for SSR) or
16
+ undefined when the component has no slotted children.
17
+ */
18
+ export function composeProps(
19
+ props: ComponentProps,
20
+ lowerExpression: (code: string) => string,
21
+ slotPart: string | undefined,
22
+ ): string {
23
+ const propThunk = (prop: { name: string; code: string }): string =>
24
+ `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`
25
+ if (!props.some((prop) => prop.spread)) {
26
+ const parts = props.map(propThunk)
27
+ if (slotPart !== undefined) {
28
+ parts.push(slotPart)
29
+ }
30
+ return `{ ${parts.join(', ')} }`
31
+ }
32
+ const layers: string[] = []
33
+ let run: string[] = []
34
+ const flushRun = (): void => {
35
+ if (run.length > 0) {
36
+ layers.push(`{ ${run.join(', ')} }`)
37
+ run = []
38
+ }
39
+ }
40
+ for (const prop of props) {
41
+ if (prop.spread) {
42
+ flushRun()
43
+ layers.push(`spreadProps(() => (${lowerExpression(prop.code)}))`)
44
+ } else {
45
+ run.push(propThunk(prop))
46
+ }
47
+ }
48
+ flushRun()
49
+ if (slotPart !== undefined) {
50
+ layers.push(`{ ${slotPart} }`)
51
+ }
52
+ return `mergeProps([${layers.join(', ')}])`
53
+ }
@@ -60,6 +60,9 @@ export function desugarSignals(scriptBody: string): {
60
60
  const stateNames = new Set<string>()
61
61
  const derivedNames = new Set<string>()
62
62
  const computedNames = new Set<string>()
63
+ /* A `props()` destructure must be lowered even when it declares no reactive binding
64
+ (a rest-only `const { ...rest } = props()`), so track its presence on its own. */
65
+ let hasPropsDestructure = false
63
66
  for (const statement of source.statements) {
64
67
  if (!ts.isVariableStatement(statement)) {
65
68
  continue
@@ -67,14 +70,16 @@ export function desugarSignals(scriptBody: string): {
67
70
  for (const declaration of statement.declarationList.declarations) {
68
71
  const callee = signalCallee(declaration)
69
72
  if (callee === 'props') {
70
- /* `const {} = props()` — each destructured binding is a read-only
71
- computed over the parent thunk, read as `name()`. */
73
+ /* `const {…, ...rest} = props()` — each named binding is a read-only computed
74
+ over the parent thunk (read as `name()`); a `...rest` binding stays a plain
75
+ const (a `restProps` bag), so it joins no reactive set. */
72
76
  if (!ts.isObjectBindingPattern(declaration.name)) {
73
77
  throw new Error(
74
78
  'abide: `props()` must be destructured — `const { a, b } = props()`',
75
79
  )
76
80
  }
77
- for (const binding of propsBindings(declaration)) {
81
+ hasPropsDestructure = true
82
+ for (const binding of propsDestructure(declaration).bindings) {
78
83
  computedNames.add(binding.local)
79
84
  }
80
85
  continue
@@ -98,7 +103,12 @@ export function desugarSignals(scriptBody: string): {
98
103
  }
99
104
  }
100
105
  }
101
- if (stateNames.size === 0 && derivedNames.size === 0 && computedNames.size === 0) {
106
+ if (
107
+ stateNames.size === 0 &&
108
+ derivedNames.size === 0 &&
109
+ computedNames.size === 0 &&
110
+ !hasPropsDestructure
111
+ ) {
102
112
  return { code: scriptBody, stateNames, derivedNames, computedNames }
103
113
  }
104
114
 
@@ -249,23 +259,34 @@ function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
249
259
  the optional `= default` expression (the fallback when the prop is absent). */
250
260
  type PropsBinding = { local: string; key: string; initializer: ts.Expression | undefined }
251
261
 
252
- /* The bindings of a `const {} = props()` pattern. Rest (`...rest`) and nested
253
- destructuring have no single prop key, so they throw a legible compile error. */
254
- function propsBindings(declaration: ts.VariableDeclaration): PropsBinding[] {
262
+ /* The destructure of a `const {…, ...rest} = props()` pattern its named bindings
263
+ plus an optional rest binding (the unconsumed props, gathered by `restProps`).
264
+ Nested destructuring has no single prop key, so it throws a legible compile error. */
265
+ function propsDestructure(declaration: ts.VariableDeclaration): {
266
+ bindings: PropsBinding[]
267
+ rest: string | undefined
268
+ } {
255
269
  const pattern = declaration.name as ts.ObjectBindingPattern
256
- return pattern.elements.map((element) => {
270
+ const bindings: PropsBinding[] = []
271
+ let rest: string | undefined
272
+ for (const element of pattern.elements) {
257
273
  if (element.dotDotDotToken !== undefined) {
258
- throw new Error('abide: `...rest` in `props()` destructuring is not supported')
274
+ if (!ts.isIdentifier(element.name)) {
275
+ throw new Error('abide: `...rest` in `props()` must bind a plain name')
276
+ }
277
+ rest = element.name.text
278
+ continue
259
279
  }
260
280
  if (!ts.isIdentifier(element.name)) {
261
281
  throw new Error('abide: nested destructuring in `props()` is not supported')
262
282
  }
263
- return {
283
+ bindings.push({
264
284
  local: element.name.text,
265
285
  key: propsBindingKey(element),
266
286
  initializer: element.initializer,
267
- }
268
- })
287
+ })
288
+ }
289
+ return { bindings, rest }
269
290
  }
270
291
 
271
292
  /* The parent prop key a binding element reads — its rename source (`name: alias` →
@@ -285,10 +306,11 @@ function propsBindingKey(element: ts.BindingElement): string {
285
306
  throw new Error('abide: computed prop keys in `props()` destructuring are not supported')
286
307
  }
287
308
 
288
- /* If `statement` is a `const {} = props()` destructure, returns one reactive
289
- computed per binding — `scope().derive("name", () => $props["key"]?.() ?? default)`
290
- read as `name()`; otherwise undefined. The `?? default` applies the binding's
291
- `= default` fallback when the prop is absent. */
309
+ /* If `statement` is a `const {…, ...rest} = props()` destructure, returns one reactive
310
+ computed per named binding — `scope().derive("name", () => $props["key"]?.() ?? default)`,
311
+ read as `name()` plus a `const rest = restProps($props, [consumed])` line for a rest
312
+ binding; otherwise undefined. The `?? default` applies the binding's `= default` fallback
313
+ when the prop is absent. */
292
314
  function propsDestructureLines(
293
315
  statement: ts.Statement,
294
316
  printer: ts.Printer,
@@ -302,7 +324,8 @@ function propsDestructureLines(
302
324
  if (signalCallee(declaration) !== 'props' || !ts.isObjectBindingPattern(declaration.name)) {
303
325
  return undefined
304
326
  }
305
- for (const { local, key, initializer } of propsBindings(declaration)) {
327
+ const { bindings, rest } = propsDestructure(declaration)
328
+ for (const { local, key, initializer } of bindings) {
306
329
  const fallback =
307
330
  initializer === undefined
308
331
  ? ''
@@ -311,6 +334,11 @@ function propsDestructureLines(
311
334
  `const ${local} = scope().derive(${JSON.stringify(local)}, () => $props[${JSON.stringify(key)}]?.()${fallback})`,
312
335
  )
313
336
  }
337
+ /* The rest bag gathers every prop not named above (and not `$children`). */
338
+ if (rest !== undefined) {
339
+ const consumed = bindings.map((binding) => binding.key)
340
+ lines.push(`const ${rest} = restProps($props, ${JSON.stringify(consumed)})`)
341
+ }
314
342
  }
315
343
  return lines
316
344
  }