@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 { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
2
|
+
import { isAnchorPositioned } from './isAnchorPositioned.ts'
|
|
1
3
|
import { isControlFlow } from './isControlFlow.ts'
|
|
2
4
|
import { isTextLeaf } from './isTextLeaf.ts'
|
|
3
5
|
import { skeletonable } from './skeletonable.ts'
|
|
@@ -13,9 +15,9 @@ that decide `<!--a-->` anchor placement — and assigns each hole its `el`/`an`
|
|
|
13
15
|
document-order walk, so the numbering cannot drift from the decisions: one walk owns both.
|
|
14
16
|
|
|
15
17
|
The index assignment is scoped per skeleton root (a `skeletonable` element not already in a
|
|
16
|
-
skeleton — the unit `generateSkeleton` instantiates with `{ el: 0, an: 0 }
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
skeleton — the unit `generateSkeleton` instantiates with `{ el: 0, an: 0 }`). `el` numbers
|
|
19
|
+
element holes in pre-order; `an` numbers anchor holes (interleaved reactive text PARTS,
|
|
20
|
+
control-flow blocks, child components, `<slot>` outlets) in
|
|
19
21
|
document order — the orders the runtime's `indexElementHoles`/`scanAnchors` re-derive from
|
|
20
22
|
the realized DOM, so the compile-time numbers and the runtime positions line up.
|
|
21
23
|
|
|
@@ -26,8 +28,8 @@ content, a `<slot>`'s fallback, a snippet's body) — never cloned by the enclos
|
|
|
26
28
|
export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
|
|
27
29
|
const inSkeleton = new WeakMap<TemplateNode, boolean>()
|
|
28
30
|
const markText = new WeakMap<TemplateNode, boolean>()
|
|
29
|
-
/* Element holes keyed by node; anchor holes keyed by node (control-flow/slot)
|
|
30
|
-
reactive text PART object (a text node carries one anchor per reactive part). */
|
|
31
|
+
/* Element holes keyed by node; anchor holes keyed by node (control-flow/component/slot)
|
|
32
|
+
OR by the reactive text PART object (a text node carries one anchor per reactive part). */
|
|
31
33
|
const elIndex = new WeakMap<TemplateNode, number>()
|
|
32
34
|
const anIndex = new WeakMap<object, number>()
|
|
33
35
|
|
|
@@ -45,21 +47,16 @@ export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
|
|
|
45
47
|
markText.set(node, nodeMarkText)
|
|
46
48
|
|
|
47
49
|
/* Control-flow blocks, components, and snippets are fresh build contexts. The node
|
|
48
|
-
ITSELF is a hole in the enclosing skeleton (an anchor for a block
|
|
49
|
-
|
|
50
|
+
ITSELF is a hole in the enclosing skeleton (an `<!--a-->` anchor for a block OR a
|
|
51
|
+
component — both mount as a marker-bounded range at that anchor); its children
|
|
52
|
+
re-enter the skeleton only via their own roots. */
|
|
50
53
|
if (isControlFlow(node) || node.kind === 'component' || node.kind === 'snippet') {
|
|
51
|
-
/* A
|
|
52
|
-
standalone
|
|
53
|
-
|
|
54
|
+
/* A block or a component takes an anchor only inside an enclosing skeleton (a
|
|
55
|
+
standalone one routes through `generateIf`/`mountChild`, not the skeleton path);
|
|
56
|
+
a snippet declares a builder and is never anchor-positioned (`isAnchorPositioned`). */
|
|
57
|
+
if (counter !== undefined && isAnchorPositioned(node)) {
|
|
54
58
|
anIndex.set(node, counter.an++)
|
|
55
59
|
}
|
|
56
|
-
/* A component is its OWN element hole either way: in the enclosing skeleton's
|
|
57
|
-
counter when nested, else as the root (index 0) of its own standalone skeleton
|
|
58
|
-
(`generateChild` routes a lone component through `generateSkeleton` too). */
|
|
59
|
-
if (node.kind === 'component') {
|
|
60
|
-
const componentCounter = counter ?? { el: 0, an: 0 }
|
|
61
|
-
elIndex.set(node, componentCounter.el++)
|
|
62
|
-
}
|
|
63
60
|
for (const child of childrenOf(node)) {
|
|
64
61
|
visit(child, false, false, undefined)
|
|
65
62
|
}
|
|
@@ -90,9 +87,11 @@ export function skeletonContext(nodes: TemplateNode[]): SkeletonContext {
|
|
|
90
87
|
if (node.kind !== 'element') {
|
|
91
88
|
return // script / style carry no skeleton children and no hole
|
|
92
89
|
}
|
|
93
|
-
if (node.tag === 'slot') {
|
|
94
|
-
/*
|
|
95
|
-
|
|
90
|
+
if (node.tag === 'slot' || node.tag === OUTLET_TAG) {
|
|
91
|
+
/* A component `<slot>` content fill OR a layout's `OUTLET_TAG` router fill point
|
|
92
|
+
(`asOutlet`) is an anchor hole in the enclosing skeleton — both mount a marker
|
|
93
|
+
range at the anchor (`mountSlot` / `outlet`). A `<slot>`'s children are its
|
|
94
|
+
fallback (a fresh context); an outlet has none. */
|
|
96
95
|
if (counter !== undefined) {
|
|
97
96
|
anIndex.set(node, counter.an++)
|
|
98
97
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
|
|
1
2
|
import { isControlFlow } from './isControlFlow.ts'
|
|
2
3
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
3
4
|
|
|
@@ -22,7 +23,7 @@ function skeletonStructure(node: TemplateNode): boolean {
|
|
|
22
23
|
if (node.kind !== 'element') {
|
|
23
24
|
return false // standalone branch|case
|
|
24
25
|
}
|
|
25
|
-
if (node.tag === 'slot') {
|
|
26
|
+
if (node.tag === 'slot' || node.tag === OUTLET_TAG) {
|
|
26
27
|
return true
|
|
27
28
|
}
|
|
28
29
|
return node.children.every(skeletonStructure)
|
|
@@ -43,7 +44,7 @@ function hasHole(node: TemplateNode): boolean {
|
|
|
43
44
|
return node.parts.some((part) => part.kind !== 'static')
|
|
44
45
|
}
|
|
45
46
|
if (node.kind === 'element') {
|
|
46
|
-
if (node.tag === 'slot') {
|
|
47
|
+
if (node.tag === 'slot' || node.tag === OUTLET_TAG) {
|
|
47
48
|
return true
|
|
48
49
|
}
|
|
49
50
|
return node.attrs.some((attr) => attr.kind !== 'static') || node.children.some(hasHole)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { TemplateAttr } from './types/TemplateAttr.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The explicitly-authored attribute names on a native element that also carries a
|
|
5
|
+
`{...spread}`. An explicit attribute wins over a spread key of the same name, so
|
|
6
|
+
both back-ends pass this set to skip those keys from the spread — keeping the
|
|
7
|
+
server string and the client DOM congruent (no duplicate attribute, and no
|
|
8
|
+
SSR-first-wins vs client-spread-wins divergence). Excluding by NAME (not by a
|
|
9
|
+
runtime present/absent check) means the two sides agree regardless of a dynamic
|
|
10
|
+
attribute's runtime value. Static and dynamic (`{expr}`) attrs contribute their
|
|
11
|
+
name, a `bind:` its property, an `on<event>` its `on…` form; `attach`/`spread`
|
|
12
|
+
carry no attribute name.
|
|
13
|
+
*/
|
|
14
|
+
export function spreadExcludedNames(attrs: TemplateAttr[]): string[] {
|
|
15
|
+
return attrs.flatMap((attr) => {
|
|
16
|
+
if (attr.kind === 'static' || attr.kind === 'expression') {
|
|
17
|
+
return [attr.name]
|
|
18
|
+
}
|
|
19
|
+
if (attr.kind === 'bind') {
|
|
20
|
+
return [attr.property]
|
|
21
|
+
}
|
|
22
|
+
if (attr.kind === 'event') {
|
|
23
|
+
return [`on${attr.event}`]
|
|
24
|
+
}
|
|
25
|
+
return []
|
|
26
|
+
})
|
|
27
|
+
}
|
|
@@ -6,7 +6,9 @@ reactively; `event` is `on<event>={code}` where `code` evaluates to the handler;
|
|
|
6
6
|
`bind` is `bind:<property>={lvalue}`, a two-way binding whose `code` is the
|
|
7
7
|
writable doc path read into the property and written back on input; `attach` is
|
|
8
8
|
`attach={code}` where `code` evaluates to an attachment `(node) => teardown` run
|
|
9
|
-
at build with node-lifetime teardown
|
|
9
|
+
at build with node-lifetime teardown; `spread` is `{...code}` where `code`
|
|
10
|
+
evaluates to an object whose own keys each become a prop on a component or an
|
|
11
|
+
attribute on a native element. `loc` is the
|
|
10
12
|
absolute offset of `code` in the original `.abide` source (see TextPart) —
|
|
11
13
|
optional, set only when the parser tracks positions for the type-checking shadow.
|
|
12
14
|
*/
|
|
@@ -16,3 +18,4 @@ export type TemplateAttr =
|
|
|
16
18
|
| { kind: 'event'; event: string; code: string; loc?: number }
|
|
17
19
|
| { kind: 'bind'; property: string; code: string; loc?: number }
|
|
18
20
|
| { kind: 'attach'; code: string; loc?: number }
|
|
21
|
+
| { kind: 'spread'; code: string; loc?: number }
|
|
@@ -61,7 +61,9 @@ export type TemplateNode =
|
|
|
61
61
|
| {
|
|
62
62
|
kind: 'component'
|
|
63
63
|
name: string
|
|
64
|
-
|
|
64
|
+
/* Each authored attribute as a prop. A `spread` entry (`{...code}`) carries no
|
|
65
|
+
`name`; its keys merge in at runtime (`mergeProps`/`spreadProps`). */
|
|
66
|
+
props: { name: string; code: string; loc?: number; spread?: boolean }[]
|
|
65
67
|
children: TemplateNode[]
|
|
66
68
|
}
|
|
67
69
|
| { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* A comment node's data, or undefined for elements/text. A comment is a node that is
|
|
2
|
+
neither an element (`hasAttribute`) nor a text node (`splitText`); the mini-dom
|
|
3
|
+
exposes no `nodeType`, so detect by method. Shared by every marker-range scan
|
|
4
|
+
(`skeleton`'s anchor walk, `outlet`'s close-marker skip) so the convention is probed
|
|
5
|
+
one way everywhere. */
|
|
6
|
+
export function commentData(node: Node): string | undefined {
|
|
7
|
+
if (
|
|
8
|
+
typeof (node as Element).hasAttribute === 'function' ||
|
|
9
|
+
typeof (node as Text).splitText === 'function'
|
|
10
|
+
) {
|
|
11
|
+
return undefined
|
|
12
|
+
}
|
|
13
|
+
return (node as Comment).data
|
|
14
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Scope } from '../types/Scope.ts'
|
|
2
|
+
import { clearBetween } from './clearBetween.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The standard teardown for a marker-bounded range (component, layout/page boundary,
|
|
6
|
+
slot): stop the content's reactivity, dispose its lexical scope, and clear the nodes
|
|
7
|
+
between the markers — leaving the markers in place so a hot swap rebuilds the range.
|
|
8
|
+
Shared by every range mount (`fillRange`, `mountRange`, `fillBoundary`) so the one
|
|
9
|
+
disposer contract lives in a single place.
|
|
10
|
+
*/
|
|
11
|
+
export function disposeRange(
|
|
12
|
+
scoped: { stop: () => void; lexical: Scope },
|
|
13
|
+
start: Comment,
|
|
14
|
+
end: Comment,
|
|
15
|
+
): () => void {
|
|
16
|
+
return () => {
|
|
17
|
+
scoped.stop()
|
|
18
|
+
scoped.lexical.dispose()
|
|
19
|
+
clearBetween(start, end)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { RENDER } from '../runtime/RENDER.ts'
|
|
2
|
+
import { scope } from '../runtime/scope.ts'
|
|
3
|
+
import type { UiProps } from '../runtime/types/UiProps.ts'
|
|
4
|
+
import { disposeRange } from './disposeRange.ts'
|
|
5
|
+
import { fillRange } from './fillRange.ts'
|
|
6
|
+
import { withScope } from './withScope.ts'
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
Mounts a chain layer (layout or page) into an EXISTING outlet boundary — the markers
|
|
10
|
+
a parent layout's `<slot/>` left (`outlet`), or the router's root boundary in `#app`.
|
|
11
|
+
The router fills these boundaries instead of mounting into a host element, so the whole
|
|
12
|
+
page/layout chain composes through one range model (no `<abide-outlet>` wrapper).
|
|
13
|
+
|
|
14
|
+
Create: build the layer's content into a fragment that lands just before `close`
|
|
15
|
+
(`fillRange`). Hydrate: claim the server content in place — park the parent cursor at
|
|
16
|
+
`open.nextSibling` and let the build adopt the existing nodes (the outlet markers
|
|
17
|
+
themselves are located via `PENDING_OUTLET`, not claimed through the cursor). The disposer
|
|
18
|
+
stops the layer's reactivity and clears the boundary — the router calls it to tear a
|
|
19
|
+
divergent layer down before rebuilding the same boundary.
|
|
20
|
+
*/
|
|
21
|
+
// @documentation plumbing
|
|
22
|
+
export function fillBoundary(
|
|
23
|
+
open: Comment,
|
|
24
|
+
close: Comment,
|
|
25
|
+
build: (host: Node, props?: UiProps) => void,
|
|
26
|
+
props: UiProps | undefined,
|
|
27
|
+
label: string | undefined,
|
|
28
|
+
): { dispose: () => void } {
|
|
29
|
+
const hydration = RENDER.hydration
|
|
30
|
+
if (hydration === undefined) {
|
|
31
|
+
return fillRange(open, close, build, props, label)
|
|
32
|
+
}
|
|
33
|
+
/* Hydrate: adopt the server content between the markers in place. */
|
|
34
|
+
const parent = open.parentNode as Node
|
|
35
|
+
hydration.next.set(parent, open.nextSibling)
|
|
36
|
+
const scoped = withScope(label, () => scope(() => build(parent, props)))
|
|
37
|
+
return { dispose: disposeRange(scoped, open, close) }
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { UiProps } from '../runtime/types/UiProps.ts'
|
|
2
|
+
import { disposeRange } from './disposeRange.ts'
|
|
3
|
+
import { fillBefore } from './fillBefore.ts'
|
|
4
|
+
import { withScope } from './withScope.ts'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Builds a component's content fresh between two existing range markers (the create
|
|
8
|
+
path), under the component's own lexical scope and render pass — the range analog of
|
|
9
|
+
`mount` for a nested child. `build` appends into a fragment (via `fillBefore`) that
|
|
10
|
+
lands just before `end`, so the content sits in the `[ … ]` range the child mounts
|
|
11
|
+
into rather than at the parent's tail; that range is what makes a component
|
|
12
|
+
selector-transparent (a true direct child of its parent, no `<abide-name>` wrapper).
|
|
13
|
+
|
|
14
|
+
Brackets a render pass (a nested child continues the parent's block-id counter) and
|
|
15
|
+
establishes the child's lexical scope in `awaiting` mode so it adopts the model doc
|
|
16
|
+
its first `doc()` creates. The disposer stops the content's reactivity, disposes the
|
|
17
|
+
lexical scope, and clears the range — leaving the markers, so a hot swap rebuilds in
|
|
18
|
+
place. Shared by `mountRange` (create branch) and `hotReplace` (re-fill on edit).
|
|
19
|
+
*/
|
|
20
|
+
// @documentation plumbing
|
|
21
|
+
export function fillRange(
|
|
22
|
+
start: Comment,
|
|
23
|
+
end: Comment,
|
|
24
|
+
build: (host: Node, props?: UiProps) => void,
|
|
25
|
+
props: UiProps | undefined,
|
|
26
|
+
label: string | undefined,
|
|
27
|
+
): { start: Comment; end: Comment; dispose: () => void } {
|
|
28
|
+
const scoped = withScope(label, () => fillBefore(end, (fragment) => build(fragment, props)))
|
|
29
|
+
return { start, end, dispose: disposeRange(scoped, start, end) }
|
|
30
|
+
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import { createScope } from '../createScope.ts'
|
|
2
|
-
import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
|
|
3
|
-
import { enterRenderPass } from '../runtime/enterRenderPass.ts'
|
|
4
|
-
import { exitRenderPass } from '../runtime/exitRenderPass.ts'
|
|
5
1
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
6
2
|
import { scope } from '../runtime/scope.ts'
|
|
7
3
|
import { scopeLabel } from './scopeLabel.ts'
|
|
4
|
+
import { withScope } from './withScope.ts'
|
|
8
5
|
|
|
9
6
|
/*
|
|
10
7
|
Adopts existing server-rendered DOM instead of rebuilding it. Runs `build(host)`
|
|
@@ -15,31 +12,24 @@ focus/scroll). Returns a disposer.
|
|
|
15
12
|
|
|
16
13
|
Adopts the server DOM in place across the framework: static structure (elements
|
|
17
14
|
+ text + bindings), `if`/`else`, keyed `each`, `switch`, `try`, and child
|
|
18
|
-
components (with slots) — they hydrate automatically because
|
|
19
|
-
claimed while hydration is still active. `await` adopts too when it can resume
|
|
15
|
+
components (with slots) — they hydrate automatically because a child's marker range
|
|
16
|
+
is claimed while hydration is still active (see `mountRange`). `await` adopts too when it can resume
|
|
20
17
|
the value (a streamed `RESUME[id]` or a warm-sync/cache read); only a genuinely-
|
|
21
18
|
pending `await` — no resume, not cache-warm — discards its boundary and builds
|
|
22
19
|
the pending branch fresh (see `awaitBlock`).
|
|
23
20
|
*/
|
|
24
21
|
// @documentation plumbing
|
|
25
|
-
export function hydrate(
|
|
22
|
+
export function hydrate(
|
|
23
|
+
host: Element,
|
|
24
|
+
build: (host: Element, props: unknown) => void,
|
|
25
|
+
props?: unknown,
|
|
26
|
+
): () => void {
|
|
26
27
|
const previous = RENDER.hydration
|
|
27
28
|
RENDER.hydration = { next: new Map() }
|
|
28
|
-
enterRenderPass()
|
|
29
|
-
/* Same lexical scope establishment as `mount` — a hydrated component owns a scope
|
|
30
|
-
too, adopting the model its build adopts. */
|
|
31
|
-
const parentScope = CURRENT_SCOPE.current
|
|
32
|
-
const lexical = createScope({}, parentScope, true, scopeLabel(host))
|
|
33
|
-
CURRENT_SCOPE.current = lexical
|
|
34
29
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
} finally {
|
|
39
|
-
exitRenderPass()
|
|
40
|
-
CURRENT_SCOPE.current = parentScope
|
|
41
|
-
}
|
|
42
|
-
})
|
|
30
|
+
/* Same shared mount core as `mount` (see `withScope`) — a hydrated component owns a
|
|
31
|
+
scope too, adopting the model its build adopts — run with the claim cursor active. */
|
|
32
|
+
const { stop, lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
|
|
43
33
|
return () => {
|
|
44
34
|
stop()
|
|
45
35
|
lexical.dispose()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { UiProps } from '../runtime/types/UiProps.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Composes a child's props from ordered layers — explicit prop runs (`{ name: () =>
|
|
5
|
+
value }` thunk maps), `{...spread}` layers (`spreadProps`), and a trailing slot
|
|
6
|
+
layer (`$children`) — into one prop bag, last layer wins per key (source order, like
|
|
7
|
+
JSX). A key resolves by scanning layers in reverse for the first that carries it, so
|
|
8
|
+
an explicit prop after a spread overrides it and vice-versa. Emitted only when a
|
|
9
|
+
component carries a spread; the plain object literal stays the path otherwise.
|
|
10
|
+
*/
|
|
11
|
+
// @documentation plumbing
|
|
12
|
+
export function mergeProps(layers: Record<string | symbol, unknown>[]): UiProps {
|
|
13
|
+
return new Proxy(Object.create(null), {
|
|
14
|
+
get(_target, key) {
|
|
15
|
+
for (let index = layers.length - 1; index >= 0; index -= 1) {
|
|
16
|
+
const layer = layers[index]
|
|
17
|
+
if (layer !== undefined && key in layer) {
|
|
18
|
+
return layer[key]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return undefined
|
|
22
|
+
},
|
|
23
|
+
has: (_target, key) => layers.some((layer) => key in layer),
|
|
24
|
+
/* Enumerable across every layer (deduped), so `restProps` can collect a
|
|
25
|
+
parent-spread bag's keys. */
|
|
26
|
+
ownKeys: () => [...new Set(layers.flatMap((layer) => Reflect.ownKeys(layer)))],
|
|
27
|
+
getOwnPropertyDescriptor: (_target, key) =>
|
|
28
|
+
layers.some((layer) => key in layer)
|
|
29
|
+
? { enumerable: true, configurable: true }
|
|
30
|
+
: undefined,
|
|
31
|
+
}) as UiProps
|
|
32
|
+
}
|
package/src/lib/ui/dom/mount.ts
CHANGED
|
@@ -1,38 +1,29 @@
|
|
|
1
|
-
import { createScope } from '../createScope.ts'
|
|
2
|
-
import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
|
|
3
|
-
import { enterRenderPass } from '../runtime/enterRenderPass.ts'
|
|
4
|
-
import { exitRenderPass } from '../runtime/exitRenderPass.ts'
|
|
5
1
|
import { scope } from '../runtime/scope.ts'
|
|
6
2
|
import { scopeLabel } from './scopeLabel.ts'
|
|
3
|
+
import { withScope } from './withScope.ts'
|
|
7
4
|
|
|
8
5
|
/*
|
|
9
|
-
Mounts a
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
bindings below). This is the runtime
|
|
6
|
+
Mounts a top-level page/layout into `host` (the router's outlet/root element): runs
|
|
7
|
+
`build(host, props)` under an ownership scope so every binding it creates is
|
|
8
|
+
collected, and returns a disposer that stops all reactivity and clears the host.
|
|
9
|
+
`build` appends its nodes to `host` (via the dom bindings below). This is the runtime
|
|
10
|
+
entry the router calls; a NESTED child instead mounts as a marker range (see
|
|
11
|
+
`mountRange`), so it leaves no wrapper element.
|
|
13
12
|
|
|
14
13
|
Brackets a render pass so the outermost mount resets the block-id counter and an
|
|
15
14
|
inlined child component's mount continues it — keeping await/try ids aligned with
|
|
16
15
|
the SSR stream (see `enterRenderPass`).
|
|
17
16
|
*/
|
|
18
17
|
// @documentation plumbing
|
|
19
|
-
export function mount(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const stop = scope(() => {
|
|
29
|
-
try {
|
|
30
|
-
build(host)
|
|
31
|
-
} finally {
|
|
32
|
-
exitRenderPass()
|
|
33
|
-
CURRENT_SCOPE.current = parentScope
|
|
34
|
-
}
|
|
35
|
-
})
|
|
18
|
+
export function mount(
|
|
19
|
+
host: Element,
|
|
20
|
+
build: (host: Element, props: unknown) => void,
|
|
21
|
+
props?: unknown,
|
|
22
|
+
): () => void {
|
|
23
|
+
/* Establish this component's lexical scope (nested, `awaiting` so it adopts the model
|
|
24
|
+
doc the build's first `doc()` creates) and render pass, run the build under it, and
|
|
25
|
+
restore the previous scope — the shared mount core (see `withScope`). */
|
|
26
|
+
const { stop, lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
|
|
36
27
|
return () => {
|
|
37
28
|
stop()
|
|
38
29
|
lexical.dispose()
|
|
@@ -3,32 +3,45 @@ import { hotReloadEnabled } from '../runtime/hotReloadEnabled.ts'
|
|
|
3
3
|
import { OWNER } from '../runtime/OWNER.ts'
|
|
4
4
|
import { registerHotInstance } from '../runtime/registerHotInstance.ts'
|
|
5
5
|
import type { UiComponent } from '../runtime/types/UiComponent.ts'
|
|
6
|
+
import { mountRange } from './mountRange.ts'
|
|
6
7
|
|
|
7
8
|
/*
|
|
8
|
-
Mounts a child component
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
mount
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
Mounts a child component as a marker-bounded range at `before` in `parent` — no
|
|
10
|
+
wrapper element, so the child's root is a true direct child of the parent (see
|
|
11
|
+
`mountRange`). Plain path (production, and dev without the hot bridge): just run the
|
|
12
|
+
range mount with the component's own `build`. Hot path (hotReloadEnabled and the
|
|
13
|
+
factory carries a module id): keep the mount handle (its range markers + disposer)
|
|
14
|
+
and record the instance so an edit can re-fill the same range in place (see
|
|
15
|
+
`hotReplace`), and file a cleanup with the mounting owner so the record and its scope
|
|
16
|
+
leave together when the parent (or branch/row) tears down.
|
|
16
17
|
*/
|
|
17
18
|
// @documentation plumbing
|
|
18
19
|
export function mountChild(
|
|
19
|
-
|
|
20
|
+
parent: Node,
|
|
20
21
|
factory: UiComponent,
|
|
21
22
|
props: Parameters<UiComponent>[1],
|
|
23
|
+
before: Node | null = null,
|
|
24
|
+
label?: string,
|
|
22
25
|
): void {
|
|
23
26
|
const moduleId = factory.__abideId
|
|
24
27
|
if (!hotReloadEnabled.current || moduleId === undefined) {
|
|
25
|
-
|
|
28
|
+
mountRange(parent, factory.build, props, before, label)
|
|
26
29
|
return
|
|
27
30
|
}
|
|
28
|
-
/* Capture the component's model alongside its
|
|
29
|
-
its state across (see `hotReplace`). */
|
|
30
|
-
const {
|
|
31
|
-
|
|
31
|
+
/* Capture the component's model alongside its mount handle, so a later swap can
|
|
32
|
+
carry its state across (see `hotReplace`). */
|
|
33
|
+
const { value: handle, model } = captureModelDoc(() =>
|
|
34
|
+
mountRange(parent, factory.build, props, before, label),
|
|
35
|
+
)
|
|
36
|
+
const instance = {
|
|
37
|
+
factory,
|
|
38
|
+
props,
|
|
39
|
+
label,
|
|
40
|
+
start: handle.start,
|
|
41
|
+
end: handle.end,
|
|
42
|
+
dispose: handle.dispose,
|
|
43
|
+
model,
|
|
44
|
+
}
|
|
32
45
|
const remove = registerHotInstance(moduleId, instance)
|
|
33
46
|
OWNER.current?.push(() => {
|
|
34
47
|
instance.dispose()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { RENDER } from '../runtime/RENDER.ts'
|
|
2
|
+
import { scope } from '../runtime/scope.ts'
|
|
3
|
+
import type { UiProps } from '../runtime/types/UiProps.ts'
|
|
4
|
+
import { disposeRange } from './disposeRange.ts'
|
|
5
|
+
import { fillRange } from './fillRange.ts'
|
|
6
|
+
import { openMarker } from './openMarker.ts'
|
|
7
|
+
import { withScope } from './withScope.ts'
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
Mounts a nested child component as a marker-bounded range — the wrapper-free
|
|
11
|
+
replacement for the old `<abide-name display:contents>` host. A component positions
|
|
12
|
+
and hydrates exactly like a control-flow block: its content lives in a `[ … ]` range,
|
|
13
|
+
so the child's real root is a TRUE direct child of the parent and structural CSS
|
|
14
|
+
(`>`, `:first-child`, `space-x`, grid placement) reaches it with no indirection —
|
|
15
|
+
which `display:contents` could never give, since it hides the wrapper from layout but
|
|
16
|
+
not from the selector tree.
|
|
17
|
+
|
|
18
|
+
Create fills the range before the end marker (`fillRange`); hydrate claims the server
|
|
19
|
+
range in place (claim the start marker, build claims the content, claim the end
|
|
20
|
+
marker — mirrors `mountSlot`/`when`). `before` (a skeleton-located node, the block's
|
|
21
|
+
`anchorCursor`) places the range among static siblings on create; hydrate ignores it
|
|
22
|
+
(the claim cursor drives placement). Returns the markers + a disposer so the hot path
|
|
23
|
+
can rebuild in place.
|
|
24
|
+
*/
|
|
25
|
+
// @documentation plumbing
|
|
26
|
+
export function mountRange(
|
|
27
|
+
parent: Node,
|
|
28
|
+
build: (host: Node, props?: UiProps) => void,
|
|
29
|
+
props: UiProps | undefined,
|
|
30
|
+
before: Node | null = null,
|
|
31
|
+
label: string | undefined = undefined,
|
|
32
|
+
): { start: Comment; end: Comment; dispose: () => void } {
|
|
33
|
+
const hydration = RENDER.hydration
|
|
34
|
+
const start = openMarker(parent, '[', before)
|
|
35
|
+
if (hydration === undefined) {
|
|
36
|
+
const end = openMarker(parent, ']', before)
|
|
37
|
+
return fillRange(start, end, build, props, label)
|
|
38
|
+
}
|
|
39
|
+
/* Hydrate: adopt the server range in place. Establish the child's lexical scope
|
|
40
|
+
and render pass (same as `fillRange`), build claiming the existing nodes, then
|
|
41
|
+
claim the end marker the build's content stops before. */
|
|
42
|
+
const scoped = withScope(label, () => scope(() => build(parent, props)))
|
|
43
|
+
const end = openMarker(parent, ']')
|
|
44
|
+
return { start, end, dispose: disposeRange(scoped, start, end) }
|
|
45
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { claimExpected } from '../runtime/claimExpected.ts'
|
|
2
|
+
import { OUTLET_CLOSE, OUTLET_OPEN } from '../runtime/OUTLET_MARKER.ts'
|
|
3
|
+
import { PENDING_OUTLET } from '../runtime/PENDING_OUTLET.ts'
|
|
4
|
+
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
|
+
import { commentData } from './commentData.ts'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
A layout's `<slot/>` outlet: an empty `<!--abide:outlet-->`…`<!--/abide:outlet-->`
|
|
9
|
+
boundary the router fills with the next chain layer (`fillBoundary`). No wrapper
|
|
10
|
+
element, so the filled child lays out as a direct child of the slot's parent.
|
|
11
|
+
|
|
12
|
+
Create: insert the empty boundary at `before` (the anchor's cursor). Hydrate: claim the
|
|
13
|
+
open marker, then SKIP the server-rendered child content — the router re-claims it when
|
|
14
|
+
it fills the boundary — by advancing the parent's claim cursor past the MATCHING close
|
|
15
|
+
(depth-counting nested outlets), so the layout's own claims after the slot stay aligned.
|
|
16
|
+
Records the boundary in `PENDING_OUTLET` (and returns it) so the router learns where the
|
|
17
|
+
next chain layer mounts without scanning the DOM.
|
|
18
|
+
*/
|
|
19
|
+
// @documentation plumbing
|
|
20
|
+
export function outlet(
|
|
21
|
+
parent: Node,
|
|
22
|
+
before: Node | null = null,
|
|
23
|
+
): { open: Comment; close: Comment } {
|
|
24
|
+
const hydration = RENDER.hydration
|
|
25
|
+
if (hydration === undefined) {
|
|
26
|
+
const open = document.createComment(OUTLET_OPEN)
|
|
27
|
+
const close = document.createComment(OUTLET_CLOSE)
|
|
28
|
+
parent.insertBefore(open, before)
|
|
29
|
+
parent.insertBefore(close, before)
|
|
30
|
+
PENDING_OUTLET.current = { open, close }
|
|
31
|
+
return { open, close }
|
|
32
|
+
}
|
|
33
|
+
const open = claimExpected(hydration, parent, 'outlet open marker') as Comment
|
|
34
|
+
/* Skip to the matching close: depth-count outlet markers (nested child-layer slots
|
|
35
|
+
are balanced), so a layout wrapping another layout skips its WHOLE subtree. */
|
|
36
|
+
let depth = 1
|
|
37
|
+
let node: Node | null = open.nextSibling
|
|
38
|
+
while (node !== null) {
|
|
39
|
+
const data = commentData(node)
|
|
40
|
+
if (data === OUTLET_OPEN) {
|
|
41
|
+
depth += 1
|
|
42
|
+
} else if (data === OUTLET_CLOSE) {
|
|
43
|
+
depth -= 1
|
|
44
|
+
if (depth === 0) {
|
|
45
|
+
break
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
node = node.nextSibling
|
|
49
|
+
}
|
|
50
|
+
/* No matching close — the server stream ended inside the slot (a mid-render error
|
|
51
|
+
or truncation). Throw AT the divergence like `claimExpected`, rather than store a
|
|
52
|
+
null close that a later `clearBetween(open, null)` would over-clear to end-of-parent. */
|
|
53
|
+
if (node === null) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'[abide] hydration desync: outlet open marker has no matching close — the server DOM is truncated inside a layout slot.',
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
const close = node as Comment
|
|
59
|
+
hydration.next.set(parent, close.nextSibling)
|
|
60
|
+
PENDING_OUTLET.current = { open, close }
|
|
61
|
+
return { open, close }
|
|
62
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Guarded method call on a reactive-document read. The doc-access lowering rewrites
|
|
3
|
+
`model.draft.trim()` into `model.read("draft").trim()`; when that read is nullish
|
|
4
|
+
the bare form throws the engine's opaque `undefined is not an object (evaluating
|
|
5
|
+
'…read("draft").trim()')`, naming only the desugared call. This wraps the
|
|
6
|
+
non-optional call so the throw names the authored scope path and member instead —
|
|
7
|
+
the source-mapped stack frame still resolves to the `.abide` line, so message and
|
|
8
|
+
location together read in authored terms. Both opaque engine errors are covered: a
|
|
9
|
+
nullish receiver (`undefined is not an object`) AND a present receiver whose member
|
|
10
|
+
is not callable (`… is not a function`, the misspelled/missing-method case).
|
|
11
|
+
Optional-chained calls are left bare: `?.` means skip-if-absent, not throw, so
|
|
12
|
+
guarding them would change semantics. `.apply(target, …)` preserves the receiver, so
|
|
13
|
+
the method sees the same `this` the bare `target.member(…)` would.
|
|
14
|
+
*/
|
|
15
|
+
// @documentation plumbing
|
|
16
|
+
export function readCall(target: unknown, path: string, member: string, args: unknown[]): unknown {
|
|
17
|
+
if (target === undefined || target === null) {
|
|
18
|
+
throw new TypeError(`abide: cannot call .${member}() — scope value "${path}" is ${target}`)
|
|
19
|
+
}
|
|
20
|
+
const method = (target as Record<string, unknown>)[member]
|
|
21
|
+
if (typeof method !== 'function') {
|
|
22
|
+
throw new TypeError(
|
|
23
|
+
`abide: cannot call .${member}() — "${path}".${member} is not a function (got ${typeof method})`,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
return (method as (...args: unknown[]) => unknown).apply(target, args)
|
|
27
|
+
}
|