@abide/abide 0.38.1 → 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.
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +16 -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/readBodyWithinLimit.ts +3 -0
- 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 +7 -0
- package/src/lib/shared/createRemoteFunction.ts +20 -11
- 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/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 +11 -3
- 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 +46 -18
- package/src/lib/ui/compile/generateSSR.ts +196 -52
- package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
- package/src/lib/ui/compile/parseTemplate.ts +44 -1
- 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 +3 -1
- package/src/lib/ui/dom/mergeProps.ts +32 -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/remoteProxy.ts +68 -36
- package/src/lib/ui/renderChain.ts +39 -37
- package/src/lib/ui/renderToStream.ts +69 -61
- package/src/lib/ui/resumeSeedScript.ts +17 -0
- package/src/lib/ui/router.ts +81 -51
- package/src/lib/ui/runtime/createEffectNode.ts +5 -0
- package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
- 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/compile/escapeHtml.ts +0 -15
- /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> = ((
|
|
12
|
-
|
|
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,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
|
}
|
|
@@ -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
|
}
|
|
@@ -2,11 +2,13 @@ import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
|
|
|
2
2
|
import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
3
3
|
import { asOutlet } from './asOutlet.ts'
|
|
4
4
|
import { bindListenEvent } from './bindListenEvent.ts'
|
|
5
|
+
import { composeProps } from './composeProps.ts'
|
|
5
6
|
import { groupBindParts } from './groupBindParts.ts'
|
|
6
7
|
import { isControlFlow } from './isControlFlow.ts'
|
|
7
8
|
import { lowerContext } from './lowerContext.ts'
|
|
8
9
|
import { scopeAttr } from './scopeAttr.ts'
|
|
9
10
|
import { skeletonContext } from './skeletonContext.ts'
|
|
11
|
+
import { spreadExcludedNames } from './spreadExcludedNames.ts'
|
|
10
12
|
import { staticAttr } from './staticAttr.ts'
|
|
11
13
|
import { staticTextPart } from './staticTextPart.ts'
|
|
12
14
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
@@ -21,6 +23,25 @@ surface (`count` → `model.count`) and then lowered to the doc patch/read API
|
|
|
21
23
|
`hostVar` and expects the dom bindings, `doc`, `effect`, and the component's
|
|
22
24
|
`model` in scope — the body the component compiler wraps and hoists cells into.
|
|
23
25
|
*/
|
|
26
|
+
|
|
27
|
+
/* A JS-identifier-safe frame name from an authored construct label (an attribute name
|
|
28
|
+
like `aria-label`, a bound property). Non-identifier chars → `_`; a leading digit gets
|
|
29
|
+
an `_` prefix; empty falls back to `thunk`. Callers prefix the label (`attr_`/`bind_`),
|
|
30
|
+
so the result is never a bare reserved word. */
|
|
31
|
+
function thunkName(label: string): string {
|
|
32
|
+
const safe = label.replace(/[^A-Za-z0-9_$]/g, '_').replace(/^(?=\d)/, '_')
|
|
33
|
+
return safe === '' ? 'thunk' : safe
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Names a reactive thunk so a stack frame reads `name@File.abide:line` instead of
|
|
37
|
+
`(anonymous)` — disambiguating which binding a frame is when several share a line. Emits
|
|
38
|
+
a named function expression (the only form whose name a debugger displays); the named
|
|
39
|
+
bodies never reference `this`/`arguments`, so the arrow→function swap is behaviour-safe,
|
|
40
|
+
and minify strips the name, so it costs nothing in production. */
|
|
41
|
+
function namedThunk(name: string, body: string): string {
|
|
42
|
+
return `function ${thunkName(name)}() { ${body} }`
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
export function generateBuild(
|
|
25
46
|
nodes: TemplateNode[],
|
|
26
47
|
hostVar: string,
|
|
@@ -76,7 +97,7 @@ export function generateBuild(
|
|
|
76
97
|
varName: string,
|
|
77
98
|
): string {
|
|
78
99
|
if (attr.kind === 'expression') {
|
|
79
|
-
return `attr(${varName}, ${JSON.stringify(attr.name)}, (
|
|
100
|
+
return `attr(${varName}, ${JSON.stringify(attr.name)}, ${namedThunk(`attr_${attr.name}`, `return (${lowerExpression(attr.code)})`)});\n`
|
|
80
101
|
}
|
|
81
102
|
if (attr.kind === 'event') {
|
|
82
103
|
return `on(${varName}, ${JSON.stringify(attr.event)}, (${lowerExpression(attr.code)}));\n`
|
|
@@ -95,12 +116,12 @@ export function generateBuild(
|
|
|
95
116
|
const value = lowerExpression(valueCode)
|
|
96
117
|
if (isRadio) {
|
|
97
118
|
return (
|
|
98
|
-
`effect((
|
|
119
|
+
`effect(${namedThunk('bind_group', `${varName}.checked = (${lowerExpression(attr.code)}) === (${value});`)});\n` +
|
|
99
120
|
`on(${varName}, "change", () => { if (${varName}.checked) { ${lowerStatement(`${attr.code} = ${valueCode}`)} } });\n`
|
|
100
121
|
)
|
|
101
122
|
}
|
|
102
123
|
return (
|
|
103
|
-
`effect((
|
|
124
|
+
`effect(${namedThunk('bind_group', `${varName}.checked = (${lowerExpression(attr.code)}).includes(${value});`)});\n` +
|
|
104
125
|
`on(${varName}, "change", () => { const $groupValue = ${value}; if (${varName}.checked) { if (!(${lowerExpression(attr.code)}).includes($groupValue)) { ${lowerStatement(`${attr.code}.push($groupValue)`)} } } else { const $groupIndex = (${lowerExpression(attr.code)}).indexOf($groupValue); if ($groupIndex !== -1) { ${lowerStatement(`delete ${attr.code}[$groupIndex]`)} } } });\n`
|
|
105
126
|
)
|
|
106
127
|
}
|
|
@@ -111,7 +132,7 @@ export function generateBuild(
|
|
|
111
132
|
`.get()` and writes via `.set(v)` — see `bindRead`/`bindWrite`. */
|
|
112
133
|
const event = bindListenEvent(attr.property, node.tag)
|
|
113
134
|
return (
|
|
114
|
-
`effect((
|
|
135
|
+
`effect(${namedThunk(`bind_${attr.property}`, `${varName}.${attr.property} = ${bindRead(attr.code)};`)});\n` +
|
|
115
136
|
`on(${varName}, ${JSON.stringify(event)}, () => { ${bindWrite(attr.code, `${varName}.${attr.property}`)} });\n`
|
|
116
137
|
)
|
|
117
138
|
}
|
|
@@ -146,7 +167,7 @@ export function generateBuild(
|
|
|
146
167
|
return staticTextPart(part.value)
|
|
147
168
|
}
|
|
148
169
|
binds.push(
|
|
149
|
-
`appendTextAt(${skVar}.an[${holeIndex(anIndex, part)}], (
|
|
170
|
+
`appendTextAt(${skVar}.an[${holeIndex(anIndex, part)}], ${namedThunk('text', `return (${lowerExpression(part.code)})`)});\n`,
|
|
150
171
|
)
|
|
151
172
|
return '<!--a-->'
|
|
152
173
|
})
|
|
@@ -219,7 +240,14 @@ export function generateBuild(
|
|
|
219
240
|
binds.push(`const ${elVar} = ${skVar}.el[${holeIndex(elIndex, node)}];\n`)
|
|
220
241
|
openTag += ` ${HOLE_ATTRIBUTE}`
|
|
221
242
|
for (const attr of node.attrs) {
|
|
222
|
-
if (attr.kind
|
|
243
|
+
if (attr.kind === 'spread') {
|
|
244
|
+
/* `{...expr}` onto the element: each key binds as a reactive attribute
|
|
245
|
+
(or an `on<event>` function as a listener) via `spreadAttrs`, skipping
|
|
246
|
+
any key explicitly named on the element (the explicit attr wins). */
|
|
247
|
+
binds.push(
|
|
248
|
+
`spreadAttrs(${elVar}, ${namedThunk('spread', `return (${lowerExpression(attr.code)})`)}, ${JSON.stringify(spreadExcludedNames(node.attrs))});\n`,
|
|
249
|
+
)
|
|
250
|
+
} else if (attr.kind !== 'static') {
|
|
223
251
|
binds.push(dynamicAttr(node, attr, elVar))
|
|
224
252
|
}
|
|
225
253
|
}
|
|
@@ -287,7 +315,7 @@ export function generateBuild(
|
|
|
287
315
|
const splitAlways = index < consumers.length - 1 ? ', true' : ''
|
|
288
316
|
return part.kind === 'static'
|
|
289
317
|
? `appendStatic(${parentVar}, ${JSON.stringify(part.value)}${splitAlways});\n`
|
|
290
|
-
: `appendText(${parentVar}, (
|
|
318
|
+
: `appendText(${parentVar}, ${namedThunk('text', `return (${lowerExpression(part.code)})`)}${splitAlways});\n`
|
|
291
319
|
})
|
|
292
320
|
.join('')
|
|
293
321
|
}
|
|
@@ -415,17 +443,17 @@ export function generateBuild(
|
|
|
415
443
|
return `if ($props && $props.$children) { ${invoke}; } else {\n${fallback}}\n`
|
|
416
444
|
}
|
|
417
445
|
|
|
418
|
-
/* The
|
|
419
|
-
|
|
420
|
-
function
|
|
421
|
-
const parts = node.props.map(
|
|
422
|
-
(prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
|
|
423
|
-
)
|
|
446
|
+
/* The child's slot content as a host-taking builder (`$children`), or undefined when
|
|
447
|
+
the component has no slotted children. */
|
|
448
|
+
function slotPart(node: Extract<TemplateNode, { kind: 'component' }>): string | undefined {
|
|
424
449
|
const slotCode = generateChildren(node.children, '$slot')
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
450
|
+
return slotCode.trim() === '' ? undefined : `"$children": ($slot) => {\n${slotCode}}`
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/* The props bag a child mount receives — composed by the shared `composeProps` so the
|
|
454
|
+
build and SSR back-ends emit the same last-wins layering. */
|
|
455
|
+
function propsArg(node: Extract<TemplateNode, { kind: 'component' }>): string {
|
|
456
|
+
return composeProps(node.props, lowerExpression, slotPart(node))
|
|
429
457
|
}
|
|
430
458
|
|
|
431
459
|
/* Mounts a child component as a marker-bounded range on `parentVar`, positioned at
|
|
@@ -439,7 +467,7 @@ export function generateBuild(
|
|
|
439
467
|
parentVar: string,
|
|
440
468
|
before: string,
|
|
441
469
|
): string {
|
|
442
|
-
return `mountChild(${parentVar}, ${node.name},
|
|
470
|
+
return `mountChild(${parentVar}, ${node.name}, ${propsArg(node)}, ${before}, ${JSON.stringify(node.name)});\n`
|
|
443
471
|
}
|
|
444
472
|
|
|
445
473
|
/* An await block: pending → resolved(value) / error branches. Each branch is a
|