@abide/abide 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +24 -0
  3. package/package.json +10 -1
  4. package/src/build.ts +14 -0
  5. package/src/lib/bundle/exitWithParent.ts +4 -2
  6. package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
  7. package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
  8. package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
  9. package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
  10. package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
  11. package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
  12. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -0
  13. package/src/lib/shared/createRemoteFunction.ts +20 -11
  14. package/src/lib/shared/escapeHtml.ts +15 -0
  15. package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
  16. package/src/lib/shared/streamResponse.ts +8 -1
  17. package/src/lib/shared/types/RemoteCallable.ts +12 -3
  18. package/src/lib/shared/types/RpcOptions.ts +22 -0
  19. package/src/lib/shared/types/SourceMap.ts +14 -0
  20. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +4 -3
  21. package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
  22. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +6 -0
  23. package/src/lib/ui/compile/asOutlet.ts +6 -5
  24. package/src/lib/ui/compile/compileModule.ts +18 -11
  25. package/src/lib/ui/compile/compileSSR.ts +32 -9
  26. package/src/lib/ui/compile/compileShadow.ts +11 -3
  27. package/src/lib/ui/compile/composeProps.ts +53 -0
  28. package/src/lib/ui/compile/desugarSignals.ts +45 -17
  29. package/src/lib/ui/compile/generateBuild.ts +100 -61
  30. package/src/lib/ui/compile/generateSSR.ts +226 -73
  31. package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
  32. package/src/lib/ui/compile/isControlFlow.ts +3 -1
  33. package/src/lib/ui/compile/isTextLeaf.ts +1 -1
  34. package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
  35. package/src/lib/ui/compile/parseTemplate.ts +44 -1
  36. package/src/lib/ui/compile/skeletonContext.ts +19 -20
  37. package/src/lib/ui/compile/skeletonable.ts +3 -2
  38. package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
  39. package/src/lib/ui/compile/staticAttr.ts +1 -1
  40. package/src/lib/ui/compile/staticTextPart.ts +1 -1
  41. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
  42. package/src/lib/ui/compile/types/TemplateNode.ts +3 -1
  43. package/src/lib/ui/dom/commentData.ts +14 -0
  44. package/src/lib/ui/dom/disposeRange.ts +21 -0
  45. package/src/lib/ui/dom/fillBoundary.ts +38 -0
  46. package/src/lib/ui/dom/fillRange.ts +30 -0
  47. package/src/lib/ui/dom/hydrate.ts +11 -21
  48. package/src/lib/ui/dom/mergeProps.ts +32 -0
  49. package/src/lib/ui/dom/mount.ts +16 -25
  50. package/src/lib/ui/dom/mountChild.ts +27 -14
  51. package/src/lib/ui/dom/mountRange.ts +45 -0
  52. package/src/lib/ui/dom/outlet.ts +62 -0
  53. package/src/lib/ui/dom/readCall.ts +27 -0
  54. package/src/lib/ui/dom/restProps.ts +32 -0
  55. package/src/lib/ui/dom/scopeLabel.ts +8 -6
  56. package/src/lib/ui/dom/skeleton.ts +11 -30
  57. package/src/lib/ui/dom/spreadAttrs.ts +34 -0
  58. package/src/lib/ui/dom/spreadProps.ts +32 -0
  59. package/src/lib/ui/dom/withScope.ts +33 -0
  60. package/src/lib/ui/installHotBridge.ts +12 -0
  61. package/src/lib/ui/remoteProxy.ts +68 -36
  62. package/src/lib/ui/renderChain.ts +49 -39
  63. package/src/lib/ui/renderToStream.ts +69 -61
  64. package/src/lib/ui/resumeSeedScript.ts +17 -0
  65. package/src/lib/ui/router.ts +155 -85
  66. package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
  67. package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
  68. package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
  69. package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
  70. package/src/lib/ui/runtime/createEffectNode.ts +5 -0
  71. package/src/lib/ui/runtime/hotReplace.ts +14 -10
  72. package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
  73. package/src/lib/ui/runtime/toTeardown.ts +10 -5
  74. package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
  75. package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
  76. package/src/lib/ui/runtime/types/Route.ts +6 -5
  77. package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
  78. package/src/lib/ui/runtime/types/UiComponent.ts +12 -1
  79. package/src/lib/ui/compile/componentWrapperTag.ts +0 -15
  80. package/src/lib/ui/compile/escapeHtml.ts +0 -15
  81. package/src/lib/ui/runtime/firstOutlet.ts +0 -22
  82. /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
@@ -1,3 +1,5 @@
1
+ import type { RpcOptions } from './RpcOptions.ts'
2
+
1
3
  /*
2
4
  Call signature shared by RemoteFunction and RawRemoteFunction. The base
3
5
  signature keeps `args` required so a schema'd verb can't silently drop its
@@ -6,7 +8,14 @@ optional-arg signature lets call sites write `fn()` instead of
6
8
  `fn(undefined)`. Intersection rather than a bare conditional so the type
7
9
  stays callable while `Args` is still generic (cache() invokes producers
8
10
  before `Args` resolves). FormData is the multipart upload escape hatch —
9
- see RemoteFunction.
11
+ see RemoteFunction. The optional trailing `opts` carries per-call transport
12
+ options (signal/keepalive/priority/cache/headers); the server ignores them,
13
+ so the callable stays isomorphic.
10
14
  */
11
- export type RemoteCallable<Args, Resolved> = ((args: Args | FormData) => Promise<Resolved>) &
12
- (undefined extends Args ? (args?: Args | FormData) => Promise<Resolved> : unknown)
15
+ export type RemoteCallable<Args, Resolved> = ((
16
+ args: Args | FormData,
17
+ opts?: RpcOptions,
18
+ ) => Promise<Resolved>) &
19
+ (undefined extends Args
20
+ ? (args?: Args | FormData, opts?: RpcOptions) => Promise<Resolved>
21
+ : unknown)
@@ -0,0 +1,22 @@
1
+ /*
2
+ Per-call transport options for a remote function — a curated slice of RequestInit,
3
+ not the whole thing. Only fields the server handler never observes are exposed, so
4
+ the call stays isomorphic (same behaviour both sides) and a caller can't clobber the
5
+ method, body, or framework headers the RPC contract owns.
6
+
7
+ - `signal` merges with the scope abort + client timeout (AbortSignal.any), never
8
+ replacing them; under cache() it's ignored so one reader can't abort a coalesced
9
+ flight the others share.
10
+ - `keepalive` lets a small fire-and-forget write survive page unload (the browser
11
+ caps the total keepalive body at ~64KB).
12
+ - `priority` is a fetch scheduling hint; ignored where unsupported.
13
+ - `cache` is the browser HTTP cache mode (distinct from abide's own cache()).
14
+ - `headers` are MERGED onto the framework headers, which win — a caller adds
15
+ transport metadata (idempotency-key, authorization) but can't overwrite
16
+ traceparent/content-type/offline. Application data still belongs in `args` (the
17
+ only schema-validated channel); headers bypass validation.
18
+ */
19
+ export type RpcOptions = Pick<
20
+ RequestInit,
21
+ 'signal' | 'keepalive' | 'priority' | 'cache' | 'headers'
22
+ >
@@ -0,0 +1,14 @@
1
+ /*
2
+ A source-map JSON object, narrowed to the fields abide reads or writes when
3
+ post-processing emitted maps. `sources` is the list of original files (a null
4
+ entry means an unknown source); `ignoreList` (Source Map v3 / ECMA-426) and its
5
+ legacy Chrome alias `x_google_ignoreList` carry the indices into `sources` a
6
+ debugger should skip. Other standard fields (`version`, `mappings`, …) are passed
7
+ through untouched, so the type stays open.
8
+ */
9
+ export type SourceMap = {
10
+ sources?: (string | null)[]
11
+ ignoreList?: number[]
12
+ x_google_ignoreList?: number[]
13
+ [field: string]: unknown
14
+ }
@@ -1,4 +1,5 @@
1
- /* The tag prefix every component instance mounts into (`abide-<name>`). One source
2
- for the compiler (componentWrapperTag) and the runtime opacity checks (skeleton,
3
- scopeLabel) so the wrapper convention can't drift between build and hydrate. */
1
+ /* The `abide-` tag prefix on framework-owned custom elements (the router's
2
+ `abide-outlet`, the streaming `abide-resolve`/`abide-cache` fragments). `scopeLabel`
3
+ strips it to read the outlet host's bare name. Child components no longer mount into
4
+ an `abide-<name>` wrapper — they build as marker ranges (see `mountRange`). */
4
5
  export const COMPONENT_WRAPPER_PREFIX = 'abide-'
@@ -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' +
@@ -30,7 +30,13 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string }[] = [
30
30
  { name: 'tryBlock', specifier: 'ui/dom/tryBlock' },
31
31
  { name: 'switchBlock', specifier: 'ui/dom/switchBlock' },
32
32
  { name: 'mountSlot', specifier: 'ui/dom/mountSlot' },
33
+ { name: 'outlet', specifier: 'ui/dom/outlet' },
33
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' },
34
40
  { name: 'hydrate', specifier: 'ui/dom/hydrate' },
35
41
  { name: 'escapeKey', specifier: 'ui/runtime/escapeKey' },
36
42
  { name: 'nextBlockId', specifier: 'ui/runtime/nextBlockId' },
@@ -8,11 +8,12 @@ structure AND lets BOTH back-ends feed the same tree to `skeletonContext` — on
8
8
  for "a layout slot is an outlet, not an anchor", instead of the client running `asOutlet` while
9
9
  SSR mirrors it with an inline special-case.
10
10
 
11
- The outlet is a structural mount container, not styled content, so it carries NO attrs and NO
12
- style scope — keeping it byte-identical to the placeholder `renderChain` folds the child layer
13
- into (an exact `<abide-outlet></abide-outlet>` string match). Stripping `scopes` is what makes
14
- the two back-ends agree: the SSR special-case emitted the outlet bare, but the client clone read
15
- the slot's annotated `scopes` and stamped them — a hydration mismatch for any scoped layout.
11
+ The outlet is a structural mount point, not styled content, so it carries NO attrs and NO style
12
+ scope — both back-ends lower it to the bare `<!--abide:outlet-->`…`<!--/abide:outlet-->` boundary
13
+ `renderChain` folds the child layer into (matching its `OUTLET_PLACEHOLDER`). Stripping `scopes`
14
+ is what makes the two back-ends agree: SSR emits the boundary bare, but without this the client
15
+ clone would read the slot's annotated `scopes` and stamp them — a hydration mismatch for any
16
+ scoped layout.
16
17
 
17
18
  Control-flow children are fresh build contexts (their own runtime mounts a nested slot), so they
18
19
  are not descended into — a `<slot>` inside an `{#if}` stays a slot node, handled at its own
@@ -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
@@ -42,11 +45,13 @@ export function compileModule(
42
45
  const id = JSON.stringify(options.moduleId)
43
46
  return `const { ${names}, hotReplace } = window.__abide
44
47
  ${userImports}
45
- function component(host, $props) {
46
- return mount(host, (host) => {
48
+ function build(host, $props) {
47
49
  ${body}
48
- })
49
50
  }
51
+ function component(host, $props) {
52
+ return mount(host, build, $props)
53
+ }
54
+ component.build = build
50
55
  component.__abideId = ${id}
51
56
  if (!hotReplace(${id}, component)) location.reload()
52
57
  `
@@ -61,25 +66,27 @@ if (!hotReplace(${id}, component)) location.reload()
61
66
  exactly which names it emitted, so it filters here. A name absent from the body
62
67
  is unreferenced; erring toward inclusion (a stray match in user script) only
63
68
  keeps a harmless unused import, never drops a needed one. */
64
- const moduleBody = `export default function component(host, $props) {
65
- return mount(host, (host) => {
69
+ const moduleBody = `function build(host, $props) {
66
70
  ${body}
67
- })
71
+ }
72
+
73
+ export default function component(host, $props) {
74
+ return mount(host, build, $props)
68
75
  }
69
76
 
70
77
  /* Adopt the server-rendered DOM in place instead of rebuilding it. */
71
78
  export function hydrateInto(host, $props) {
72
- return hydrate(host, (host) => {
73
- ${body}
74
- })
79
+ return hydrate(host, build, $props)
75
80
  }
76
81
 
77
- export function render($props) {
82
+ export function render($props, $ctx) {
78
83
  ${ssrBody}
79
84
  }
80
85
 
81
86
  component.render = render
82
87
  component.hydrate = hydrateInto
88
+ /* The bare build, so a parent can range-mount this as a nested child (no wrapper). */
89
+ component.build = build
83
90
  component.hydratable = ${analyzed.hydratable}
84
91
  ${options.moduleId === undefined ? '' : `component.__abideId = ${JSON.stringify(options.moduleId)}\n`}`
85
92
  /* Scope each name to the surface that genuinely references it. The SSR body
@@ -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
  }
@@ -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
  }