@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.
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +24 -0
- package/package.json +10 -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/COMPONENT_WRAPPER_PREFIX.ts +4 -3
- package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +6 -0
- package/src/lib/ui/compile/asOutlet.ts +6 -5
- package/src/lib/ui/compile/compileModule.ts +18 -11
- 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 +100 -61
- package/src/lib/ui/compile/generateSSR.ts +226 -73
- package/src/lib/ui/compile/isAnchorPositioned.ts +19 -0
- package/src/lib/ui/compile/isControlFlow.ts +3 -1
- package/src/lib/ui/compile/isTextLeaf.ts +1 -1
- package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
- package/src/lib/ui/compile/parseTemplate.ts +44 -1
- package/src/lib/ui/compile/skeletonContext.ts +19 -20
- package/src/lib/ui/compile/skeletonable.ts +3 -2
- 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/commentData.ts +14 -0
- package/src/lib/ui/dom/disposeRange.ts +21 -0
- package/src/lib/ui/dom/fillBoundary.ts +38 -0
- package/src/lib/ui/dom/fillRange.ts +30 -0
- package/src/lib/ui/dom/hydrate.ts +11 -21
- package/src/lib/ui/dom/mergeProps.ts +32 -0
- package/src/lib/ui/dom/mount.ts +16 -25
- package/src/lib/ui/dom/mountChild.ts +27 -14
- package/src/lib/ui/dom/mountRange.ts +45 -0
- package/src/lib/ui/dom/outlet.ts +62 -0
- package/src/lib/ui/dom/readCall.ts +27 -0
- package/src/lib/ui/dom/restProps.ts +32 -0
- package/src/lib/ui/dom/scopeLabel.ts +8 -6
- package/src/lib/ui/dom/skeleton.ts +11 -30
- package/src/lib/ui/dom/spreadAttrs.ts +34 -0
- package/src/lib/ui/dom/spreadProps.ts +32 -0
- package/src/lib/ui/dom/withScope.ts +33 -0
- package/src/lib/ui/installHotBridge.ts +12 -0
- package/src/lib/ui/remoteProxy.ts +68 -36
- package/src/lib/ui/renderChain.ts +49 -39
- package/src/lib/ui/renderToStream.ts +69 -61
- package/src/lib/ui/resumeSeedScript.ts +17 -0
- package/src/lib/ui/router.ts +155 -85
- package/src/lib/ui/runtime/OUTLET_MARKER.ts +10 -0
- package/src/lib/ui/runtime/OUTLET_TAG.ts +5 -6
- package/src/lib/ui/runtime/PENDING_OUTLET.ts +8 -0
- package/src/lib/ui/runtime/captureModelDoc.ts +12 -13
- package/src/lib/ui/runtime/createEffectNode.ts +5 -0
- package/src/lib/ui/runtime/hotReplace.ts +14 -10
- package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
- package/src/lib/ui/runtime/toTeardown.ts +10 -5
- package/src/lib/ui/runtime/types/HotInstance.ts +11 -7
- package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
- package/src/lib/ui/runtime/types/Route.ts +6 -5
- package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
- package/src/lib/ui/runtime/types/UiComponent.ts +12 -1
- package/src/lib/ui/compile/componentWrapperTag.ts +0 -15
- package/src/lib/ui/compile/escapeHtml.ts +0 -15
- package/src/lib/ui/runtime/firstOutlet.ts +0 -22
- /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,4 +1,5 @@
|
|
|
1
|
-
/* The tag prefix
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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' +
|
|
@@ -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
|
|
12
|
-
|
|
13
|
-
into (
|
|
14
|
-
the two back-ends agree:
|
|
15
|
-
the slot's annotated `scopes` and
|
|
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 }`
|
|
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
|
|
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 = `
|
|
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,
|
|
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
|
-
|
|
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
|
}
|