@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.
- package/AGENTS.md +5 -5
- package/CHANGELOG.md +63 -0
- package/package.json +9 -1
- package/src/build.ts +14 -0
- package/src/lib/bundle/exitWithParent.ts +4 -2
- package/src/lib/server/rpc/parseArgs.ts +10 -1
- package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
- package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
- package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
- package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
- package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
- package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
- package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
- package/src/lib/server/sockets/createSocketDispatcher.ts +19 -3
- package/src/lib/server/sockets/defineSocket.ts +3 -1
- package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
- package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
- package/src/lib/shared/buildRpcRequest.ts +8 -1
- package/src/lib/shared/createRemoteFunction.ts +20 -11
- package/src/lib/shared/decodeRefJson.ts +110 -0
- package/src/lib/shared/encodeRefJson.ts +106 -0
- package/src/lib/shared/escapeHtml.ts +15 -0
- package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
- package/src/lib/shared/streamResponse.ts +8 -1
- package/src/lib/shared/types/RemoteCallable.ts +12 -3
- package/src/lib/shared/types/RpcOptions.ts +22 -0
- package/src/lib/shared/types/SourceMap.ts +14 -0
- package/src/lib/test/createTestSocketChannel.ts +6 -2
- package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
- package/src/lib/ui/compile/compileModule.ts +5 -2
- package/src/lib/ui/compile/compileSSR.ts +32 -9
- package/src/lib/ui/compile/compileShadow.ts +25 -10
- package/src/lib/ui/compile/composeProps.ts +53 -0
- package/src/lib/ui/compile/desugarSignals.ts +45 -17
- package/src/lib/ui/compile/generateBuild.ts +87 -76
- package/src/lib/ui/compile/generateSSR.ts +217 -71
- package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
- package/src/lib/ui/compile/parseTemplate.ts +118 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +12 -0
- package/src/lib/ui/compile/resolveBranches.ts +21 -0
- package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
- package/src/lib/ui/compile/staticAttr.ts +1 -1
- package/src/lib/ui/compile/staticTextPart.ts +1 -1
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +15 -3
- package/src/lib/ui/createScope.ts +14 -0
- package/src/lib/ui/dom/appendText.ts +2 -3
- package/src/lib/ui/dom/appendTextAt.ts +2 -3
- package/src/lib/ui/dom/applyResolved.ts +5 -8
- package/src/lib/ui/dom/awaitBlock.ts +14 -1
- package/src/lib/ui/dom/mergeProps.ts +32 -0
- package/src/lib/ui/dom/on.ts +7 -0
- package/src/lib/ui/dom/parseRawNodes.ts +17 -0
- package/src/lib/ui/dom/readCall.ts +27 -0
- package/src/lib/ui/dom/restProps.ts +32 -0
- package/src/lib/ui/dom/spreadAttrs.ts +34 -0
- package/src/lib/ui/dom/spreadProps.ts +32 -0
- package/src/lib/ui/installHotBridge.ts +10 -0
- package/src/lib/ui/navigate.ts +28 -3
- package/src/lib/ui/remoteProxy.ts +68 -36
- package/src/lib/ui/renderChain.ts +39 -37
- package/src/lib/ui/renderToStream.ts +84 -68
- package/src/lib/ui/resumeSeedScript.ts +27 -0
- package/src/lib/ui/router.ts +81 -51
- package/src/lib/ui/runtime/RESUME.ts +13 -6
- package/src/lib/ui/runtime/createEffectNode.ts +5 -0
- package/src/lib/ui/runtime/localStoragePersistence.ts +21 -8
- package/src/lib/ui/runtime/toTeardown.ts +10 -5
- package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
- package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
- package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
- package/src/lib/ui/socketChannel.ts +5 -2
- package/src/lib/ui/tryEncodeResume.ts +20 -0
- package/src/lib/ui/types/Scope.ts +9 -3
- package/src/lib/ui/compile/escapeHtml.ts +0 -15
- /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
|
|
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"`. `$
|
|
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
|
"({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[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 }`
|
|
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
|
-
|
|
20
|
-
`
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
41
|
-
`
|
|
42
|
-
`return { html: $out.join(''), state: (typeof model !== 'undefined' ? model.snapshot() : {}), awaits: $awaits };\n` +
|
|
43
|
-
`} finally { exitScope($scope);
|
|
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
|
-
|
|
361
|
-
|
|
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
|
|
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 !==
|
|
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
|
|
387
|
-
|
|
388
|
-
|
|
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 {
|
|
71
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
270
|
+
const bindings: PropsBinding[] = []
|
|
271
|
+
let rest: string | undefined
|
|
272
|
+
for (const element of pattern.elements) {
|
|
257
273
|
if (element.dotDotDotToken !== undefined) {
|
|
258
|
-
|
|
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
|
-
|
|
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 {
|
|
289
|
-
computed per binding — `scope().derive("name", () => $props["key"]?.() ?? default)
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
}
|