@praxisjs/runtime 0.7.1 → 0.8.1
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/CHANGELOG.md +39 -0
- package/dist/children.d.ts.map +1 -1
- package/dist/children.js +2 -1
- package/dist/children.js.map +1 -1
- package/dist/component.d.ts +2 -3
- package/dist/component.d.ts.map +1 -1
- package/dist/component.js +133 -60
- package/dist/component.js.map +1 -1
- package/dist/context.d.ts +13 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +24 -2
- package/dist/context.js.map +1 -1
- package/dist/dom/constants.d.ts +1 -1
- package/dist/dom/constants.d.ts.map +1 -1
- package/dist/dom/constants.js +24 -83
- package/dist/dom/constants.js.map +1 -1
- package/dist/dom/create.d.ts +1 -1
- package/dist/dom/create.d.ts.map +1 -1
- package/dist/dom/create.js +3 -3
- package/dist/dom/create.js.map +1 -1
- package/dist/dom/events.d.ts +28 -0
- package/dist/dom/events.d.ts.map +1 -1
- package/dist/dom/events.js +163 -17
- package/dist/dom/events.js.map +1 -1
- package/dist/dom/props.d.ts +4 -0
- package/dist/dom/props.d.ts.map +1 -1
- package/dist/dom/props.js +61 -8
- package/dist/dom/props.js.map +1 -1
- package/dist/dom/range.d.ts +4 -0
- package/dist/dom/range.d.ts.map +1 -0
- package/dist/dom/range.js +43 -0
- package/dist/dom/range.js.map +1 -0
- package/dist/dom/remove.d.ts.map +1 -1
- package/dist/dom/remove.js +2 -8
- package/dist/dom/remove.js.map +1 -1
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +7 -5
- package/dist/element.js.map +1 -1
- package/dist/hmr.d.ts +35 -0
- package/dist/hmr.d.ts.map +1 -0
- package/dist/hmr.js +85 -0
- package/dist/hmr.js.map +1 -0
- package/dist/hydration-context.d.ts +9 -1
- package/dist/hydration-context.d.ts.map +1 -1
- package/dist/hydration-context.js +34 -13
- package/dist/hydration-context.js.map +1 -1
- package/dist/hydration.d.ts.map +1 -1
- package/dist/hydration.js +4 -11
- package/dist/hydration.js.map +1 -1
- package/dist/index.d.ts +16 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -7
- package/dist/index.js.map +1 -1
- package/dist/list.d.ts +72 -0
- package/dist/list.d.ts.map +1 -0
- package/dist/list.js +786 -0
- package/dist/list.js.map +1 -0
- package/dist/portal.d.ts.map +1 -1
- package/dist/portal.js +3 -0
- package/dist/portal.js.map +1 -1
- package/dist/reactive.d.ts +2 -2
- package/dist/reactive.d.ts.map +1 -1
- package/dist/reactive.js +109 -34
- package/dist/reactive.js.map +1 -1
- package/dist/scope.d.ts +14 -4
- package/dist/scope.d.ts.map +1 -1
- package/dist/scope.js +55 -15
- package/dist/scope.js.map +1 -1
- package/dist/template.d.ts +48 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/template.js +270 -0
- package/dist/template.js.map +1 -0
- package/package.json +15 -6
- package/src/children.ts +2 -1
- package/src/component.ts +135 -63
- package/src/context.ts +35 -4
- package/src/dom/constants.ts +25 -82
- package/src/dom/create.ts +3 -3
- package/src/dom/events.ts +184 -16
- package/src/dom/props.ts +70 -9
- package/src/dom/range.ts +45 -0
- package/src/dom/remove.ts +3 -7
- package/src/element.ts +8 -6
- package/src/hmr.ts +110 -0
- package/src/hydration-context.ts +34 -13
- package/src/hydration.ts +4 -9
- package/src/index.ts +54 -7
- package/src/list.ts +886 -0
- package/src/portal.ts +3 -0
- package/src/reactive.ts +147 -40
- package/src/scope.ts +63 -16
- package/src/template.ts +271 -0
- package/dist/__tests__/children.test.d.ts +0 -2
- package/dist/__tests__/children.test.d.ts.map +0 -1
- package/dist/__tests__/children.test.js +0 -241
- package/dist/__tests__/children.test.js.map +0 -1
- package/dist/__tests__/component.test.d.ts +0 -2
- package/dist/__tests__/component.test.d.ts.map +0 -1
- package/dist/__tests__/component.test.js +0 -347
- package/dist/__tests__/component.test.js.map +0 -1
- package/dist/__tests__/context.test.d.ts +0 -2
- package/dist/__tests__/context.test.d.ts.map +0 -1
- package/dist/__tests__/context.test.js +0 -111
- package/dist/__tests__/context.test.js.map +0 -1
- package/dist/__tests__/dom.test.d.ts +0 -2
- package/dist/__tests__/dom.test.d.ts.map +0 -1
- package/dist/__tests__/dom.test.js +0 -429
- package/dist/__tests__/dom.test.js.map +0 -1
- package/dist/__tests__/hydration.test.d.ts +0 -2
- package/dist/__tests__/hydration.test.d.ts.map +0 -1
- package/dist/__tests__/hydration.test.js +0 -299
- package/dist/__tests__/hydration.test.js.map +0 -1
- package/dist/__tests__/portal.test.d.ts +0 -2
- package/dist/__tests__/portal.test.d.ts.map +0 -1
- package/dist/__tests__/portal.test.js +0 -157
- package/dist/__tests__/portal.test.js.map +0 -1
- package/dist/__tests__/remove.test.d.ts +0 -2
- package/dist/__tests__/remove.test.d.ts.map +0 -1
- package/dist/__tests__/remove.test.js +0 -45
- package/dist/__tests__/remove.test.js.map +0 -1
- package/dist/__tests__/render.test.d.ts +0 -2
- package/dist/__tests__/render.test.d.ts.map +0 -1
- package/dist/__tests__/render.test.js +0 -97
- package/dist/__tests__/render.test.js.map +0 -1
- package/dist/__tests__/scope.test.d.ts +0 -2
- package/dist/__tests__/scope.test.d.ts.map +0 -1
- package/dist/__tests__/scope.test.js +0 -110
- package/dist/__tests__/scope.test.js.map +0 -1
- package/src/__tests__/children.test.ts +0 -276
- package/src/__tests__/component.test.ts +0 -381
- package/src/__tests__/context.test.ts +0 -128
- package/src/__tests__/dom.test.ts +0 -483
- package/src/__tests__/hydration.test.ts +0 -360
- package/src/__tests__/portal.test.ts +0 -177
- package/src/__tests__/remove.test.ts +0 -51
- package/src/__tests__/render.test.ts +0 -119
- package/src/__tests__/scope.test.ts +0 -120
- package/tsconfig.json +0 -8
package/src/portal.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { isServerRenderPass } from "@praxisjs/core/internal";
|
|
|
3
3
|
|
|
4
4
|
import { mountChildren } from "./children";
|
|
5
5
|
import { getCurrentScope } from "./context";
|
|
6
|
+
import { addEventRoot } from "./dom/events";
|
|
6
7
|
import { removeNodes } from "./dom/remove";
|
|
7
8
|
|
|
8
9
|
export interface PortalProps {
|
|
@@ -34,6 +35,8 @@ export class Portal extends StatelessComponent<PortalProps> {
|
|
|
34
35
|
mountChildren(fragment, this.props.children, scope);
|
|
35
36
|
fragment.appendChild(end);
|
|
36
37
|
target.appendChild(fragment);
|
|
38
|
+
// The portal's content is outside the render container, so its target relays delegated events.
|
|
39
|
+
scope.add(addEventRoot(target));
|
|
37
40
|
|
|
38
41
|
scope.add(() => {
|
|
39
42
|
if (!start.parentNode || start.parentNode !== end.parentNode) return;
|
package/src/reactive.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { EffectNode } from "@praxisjs/core/internal";
|
|
2
|
+
|
|
3
|
+
import { swapLazyScope, swapScope, type LazyScopeHost } from "./context";
|
|
2
4
|
import { removeNodes } from "./dom/remove";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
+
import { onHydrationReplace, recording, registerReactiveNode } from "./hydration-context";
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import type { Scope } from "./scope";
|
|
9
|
+
|
|
10
|
+
const NO_NODES: Node[] = [];
|
|
5
11
|
|
|
6
12
|
function collectNodes(value: unknown, out: Node[]): void {
|
|
7
13
|
if (value === null || value === undefined || value === false) return;
|
|
@@ -20,48 +26,149 @@ function nodesToFragment(nodes: Node[]): Node {
|
|
|
20
26
|
return fragment;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
parent: Node
|
|
25
|
-
fn: () => unknown
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
29
|
+
class Region implements LazyScopeHost {
|
|
30
|
+
declare parent: Node;
|
|
31
|
+
declare fn: () => unknown;
|
|
32
|
+
/** Null when the region is all of `parent`'s content and appends to it. */
|
|
33
|
+
declare end: Node | null;
|
|
34
|
+
/**
|
|
35
|
+
* What the region shows. A text node the region built itself is held by `text` alone (`nodes`
|
|
36
|
+
* empty), so a text-only region never builds an array.
|
|
37
|
+
*/
|
|
38
|
+
declare nodes: Node[];
|
|
39
|
+
/** Set while the region shows exactly the text node built from a string/number result, so the
|
|
40
|
+
* next string/number result rewrites it in place instead of swapping nodes. */
|
|
41
|
+
declare text: Text | null;
|
|
42
|
+
declare textValue: string;
|
|
43
|
+
/**
|
|
44
|
+
* Set when a text result went in through `parent.textContent` or `end.before()`, which build the
|
|
45
|
+
* node natively, without a JS wrapper for it: the node is looked up only if a later run has to
|
|
46
|
+
* change or remove it. Nothing else inserts right before `end` once the region is mounted, so it
|
|
47
|
+
* is still `end`'s previous sibling (or the only child of `parent`) then.
|
|
48
|
+
*/
|
|
49
|
+
declare lazyText: boolean;
|
|
50
|
+
declare parentScope: Scope;
|
|
51
|
+
/**
|
|
52
|
+
* Forked the first time a run asks for the current scope, then reused across runs: disposing
|
|
53
|
+
* empties it, so a re-run allocates no new scope. A region whose runs register nothing has none.
|
|
54
|
+
*/
|
|
55
|
+
declare scope: Scope | null;
|
|
50
56
|
|
|
51
|
-
|
|
57
|
+
constructor(parent: Node, fn: () => unknown, end: Node | null, placeholder: Text | null, parentScope: Scope) {
|
|
58
|
+
this.parent = parent;
|
|
59
|
+
this.fn = fn;
|
|
60
|
+
this.end = end;
|
|
61
|
+
this.nodes = NO_NODES;
|
|
62
|
+
this.text = placeholder;
|
|
63
|
+
this.textValue = placeholder === null ? "" : placeholder.data;
|
|
64
|
+
this.lazyText = false;
|
|
65
|
+
this.parentScope = parentScope;
|
|
66
|
+
this.scope = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
52
69
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
70
|
+
// Keeps the region shape alive across full GCs; see the keeper in scope.ts. Built without a DOM so
|
|
71
|
+
// the runtime stays importable before one exists.
|
|
72
|
+
Object.defineProperty(Region, "shapeKeeper", {
|
|
73
|
+
value: new Region(null as unknown as Node, () => undefined, null, null, null as unknown as Scope),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function runRegion(this: EffectNode<Region>): void {
|
|
77
|
+
const region = this.ctx;
|
|
78
|
+
const { parent, end } = region;
|
|
79
|
+
region.scope?.dispose();
|
|
80
|
+
const prevScope = swapScope(region.scope);
|
|
81
|
+
const prevHost = swapLazyScope(region);
|
|
82
|
+
let result: unknown;
|
|
83
|
+
try {
|
|
84
|
+
result = region.fn();
|
|
85
|
+
} finally {
|
|
86
|
+
swapScope(prevScope);
|
|
87
|
+
swapLazyScope(prevHost);
|
|
88
|
+
}
|
|
89
|
+
const isText = typeof result === "string" || typeof result === "number";
|
|
90
|
+
|
|
91
|
+
if (region.lazyText) {
|
|
92
|
+
region.lazyText = false;
|
|
93
|
+
region.text = (end === null ? parent.firstChild : end.previousSibling) as Text;
|
|
94
|
+
}
|
|
95
|
+
const text = region.text;
|
|
96
|
+
|
|
97
|
+
if (isText && text !== null) {
|
|
98
|
+
const value = String(result);
|
|
99
|
+
if (value !== region.textValue) {
|
|
100
|
+
text.data = value;
|
|
101
|
+
region.textValue = value;
|
|
57
102
|
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const anchor = end === null ? parent : (end.parentNode ?? parent);
|
|
107
|
+
const current = region.nodes;
|
|
58
108
|
|
|
59
|
-
|
|
60
|
-
|
|
109
|
+
if (isText && current.length === 0 && !recording) {
|
|
110
|
+
const value = String(result);
|
|
111
|
+
region.textValue = value;
|
|
112
|
+
// A null `end` with nothing shown means `parent` is empty. An empty string builds no node there.
|
|
113
|
+
if (end === null ? value !== "" : end.parentNode !== null) {
|
|
114
|
+
if (end === null) parent.textContent = value;
|
|
115
|
+
else (end as ChildNode).before(value);
|
|
116
|
+
region.lazyText = true;
|
|
117
|
+
return;
|
|
61
118
|
}
|
|
119
|
+
const node = document.createTextNode(value);
|
|
120
|
+
anchor.insertBefore(node, end);
|
|
121
|
+
region.text = node;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const newNodes: Node[] = [];
|
|
126
|
+
collectNodes(result, newNodes);
|
|
127
|
+
|
|
128
|
+
// During a hydration build pass, remember that each of these fresh nodes
|
|
129
|
+
// belongs to *this* array — reconcile() mutates `newNodes` in place (by
|
|
130
|
+
// reference, same array `region.nodes` ends up pointing at) if it decides
|
|
131
|
+
// to keep a real element instead of one of them, so future diffs here
|
|
132
|
+
// stay correct.
|
|
133
|
+
if (recording) {
|
|
134
|
+
for (const n of newNodes) registerReactiveNode(n, newNodes);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (current.length === 1) {
|
|
138
|
+
anchor.removeChild(current[0]);
|
|
139
|
+
} else if (current.length > 1) {
|
|
140
|
+
removeNodes(current);
|
|
141
|
+
} else if (text !== null) {
|
|
142
|
+
anchor.removeChild(text);
|
|
143
|
+
}
|
|
62
144
|
|
|
63
|
-
|
|
64
|
-
|
|
145
|
+
if (newNodes.length > 0) {
|
|
146
|
+
anchor.insertBefore(nodesToFragment(newNodes), end);
|
|
147
|
+
}
|
|
65
148
|
|
|
66
|
-
|
|
149
|
+
// Kept even for a lone text node: a hydration pass may swap a real node into this very array.
|
|
150
|
+
region.nodes = newNodes;
|
|
151
|
+
if (isText) {
|
|
152
|
+
region.text = newNodes[0] as Text;
|
|
153
|
+
region.textValue = region.text.data;
|
|
154
|
+
} else {
|
|
155
|
+
region.text = null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function mountReactive(
|
|
160
|
+
parent: Node,
|
|
161
|
+
fn: () => unknown,
|
|
162
|
+
parentScope: Scope,
|
|
163
|
+
end: Node | null = parent.appendChild(document.createComment("")),
|
|
164
|
+
placeholder: Text | null = null,
|
|
165
|
+
): void {
|
|
166
|
+
// `placeholder` is an empty text node already in place (from a compiled template) that a text
|
|
167
|
+
// result takes over.
|
|
168
|
+
const region = new Region(parent, fn, end, placeholder, parentScope);
|
|
169
|
+
// A hydration build pass mounts into a scratch tree. A region with an end node finds the real
|
|
170
|
+
// parent through it once reconcile() moves it there; one without must be told which real element
|
|
171
|
+
// reconcile() kept in place of `parent`.
|
|
172
|
+
if (end === null && recording) onHydrationReplace(parent, (real) => { region.parent = real; });
|
|
173
|
+
parentScope.contextEffect(runRegion, region);
|
|
67
174
|
}
|
package/src/scope.ts
CHANGED
|
@@ -1,35 +1,82 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createContextEffect, createEffect, disposeEffect, EffectNode, type EffectCleanup } from "@praxisjs/core/internal";
|
|
2
2
|
import type { Cleanup } from "@praxisjs/shared/internal";
|
|
3
3
|
|
|
4
|
+
import { removeListener } from "./dom/events";
|
|
5
|
+
|
|
6
|
+
type Disposable = Cleanup | { dispose(): void } | EffectNode;
|
|
7
|
+
|
|
8
|
+
// Thousands of scopes are created per list render (one per row and per reactive child), so a scope
|
|
9
|
+
// holds effects and child scopes directly instead of wrapping each in a closure, allocates its
|
|
10
|
+
// arrays only when something is registered, and records event listeners as flat triples.
|
|
4
11
|
export class Scope {
|
|
5
|
-
|
|
12
|
+
private declare items: Disposable[] | null;
|
|
13
|
+
private declare listeners: unknown[] | null;
|
|
14
|
+
/** Bumped by every dispose(): delegated handlers bound in an earlier generation stop firing. */
|
|
15
|
+
declare gen: number;
|
|
16
|
+
|
|
17
|
+
// Assigned here rather than as field initializers, which make scopes noticeably slower to create.
|
|
18
|
+
constructor() {
|
|
19
|
+
this.items = null;
|
|
20
|
+
this.listeners = null;
|
|
21
|
+
this.gen = 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
effect(fn: () => EffectCleanup): void {
|
|
25
|
+
(this.items ??= []).push(createEffect(fn));
|
|
26
|
+
}
|
|
6
27
|
|
|
7
|
-
effect
|
|
8
|
-
|
|
28
|
+
/** Runs an effect whose shared `fn` keeps its state in `ctx` (see createContextEffect). */
|
|
29
|
+
contextEffect<Ctx>(fn: (this: EffectNode<Ctx>) => EffectCleanup, ctx: Ctx): void {
|
|
30
|
+
(this.items ??= []).push(createContextEffect(fn, ctx));
|
|
9
31
|
}
|
|
10
32
|
|
|
11
|
-
add(cleanup: Cleanup): void {
|
|
12
|
-
this
|
|
33
|
+
add(cleanup: Cleanup | { dispose(): void }): void {
|
|
34
|
+
(this.items ??= []).push(cleanup);
|
|
13
35
|
}
|
|
14
36
|
|
|
15
37
|
fork(): Scope {
|
|
16
38
|
const child = new Scope();
|
|
17
|
-
this
|
|
39
|
+
(this.items ??= []).push(child);
|
|
18
40
|
return child;
|
|
19
41
|
}
|
|
20
42
|
|
|
43
|
+
/** Records a listener that `removeListener` takes off `el` when the scope is disposed. */
|
|
44
|
+
listen(el: Element, type: string, handler: unknown): void {
|
|
45
|
+
(this.listeners ??= []).push(el, type, handler);
|
|
46
|
+
}
|
|
47
|
+
|
|
21
48
|
dispose(): void {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
49
|
+
this.gen++;
|
|
50
|
+
const items = this.items;
|
|
51
|
+
const listeners = this.listeners;
|
|
52
|
+
if (items === null && listeners === null) return;
|
|
53
|
+
this.items = null;
|
|
54
|
+
this.listeners = null;
|
|
55
|
+
let errors: unknown[] | null = null;
|
|
56
|
+
if (listeners !== null) {
|
|
57
|
+
for (let i = 0; i < listeners.length; i += 3) {
|
|
58
|
+
removeListener(listeners[i] as Element, listeners[i + 1] as string, listeners[i + 2]);
|
|
30
59
|
}
|
|
31
60
|
}
|
|
61
|
+
if (items !== null) {
|
|
62
|
+
for (const item of items) {
|
|
63
|
+
try {
|
|
64
|
+
if (typeof item === "function") item();
|
|
65
|
+
else if (item instanceof EffectNode) disposeEffect(item);
|
|
66
|
+
else item.dispose();
|
|
67
|
+
} catch (e) {
|
|
68
|
+
(errors ??= []).push(e);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (errors === null) return;
|
|
32
73
|
if (errors.length === 1) throw errors[0];
|
|
33
|
-
|
|
74
|
+
throw new AggregateError(errors, "[PraxisJS] Multiple errors during dispose()");
|
|
34
75
|
}
|
|
35
76
|
}
|
|
77
|
+
|
|
78
|
+
// V8 holds map transitions weakly: when every scope dies (a list cleared before a full GC), the
|
|
79
|
+
// hidden class its constructor builds dies too, and the optimized code that embedded it is thrown
|
|
80
|
+
// away. An unused instance keeps the shape alive; Object.defineProperty keeps bundlers from
|
|
81
|
+
// dropping it.
|
|
82
|
+
Object.defineProperty(Scope, "shapeKeeper", { value: new Scope() });
|
package/src/template.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { mountChildren } from "./children";
|
|
2
|
+
import { getCurrentScope } from "./context";
|
|
3
|
+
import { createElement } from "./dom/create";
|
|
4
|
+
import { addDelegatedEvent, delegatedProp, type DelegatedType } from "./dom/events";
|
|
5
|
+
import { applyProp, bindClass, hasWritableProperty, setProp } from "./dom/props";
|
|
6
|
+
import { recording, recordProp } from "./hydration-context";
|
|
7
|
+
import { mountReactive } from "./reactive";
|
|
8
|
+
|
|
9
|
+
import type { Scope } from "./scope";
|
|
10
|
+
|
|
11
|
+
// Runtime half of the compiled templates emitted by @praxisjs/vite-plugin. The static part of a JSX
|
|
12
|
+
// tree is built once, then every instance is a single cloneNode(true) plus the bindings for its
|
|
13
|
+
// dynamic parts. template() builds it with the same DOM calls a plain render makes; html() parses
|
|
14
|
+
// markup the compiler emits only where the parser builds that same tree.
|
|
15
|
+
|
|
16
|
+
type Patch = [path: number[], key: string, value: unknown];
|
|
17
|
+
|
|
18
|
+
/** A reactive child's position: the comment it inserts its nodes before. */
|
|
19
|
+
export const MARKER = 0;
|
|
20
|
+
|
|
21
|
+
let pendingPatches: Array<[Element, string, unknown]> | null = null;
|
|
22
|
+
|
|
23
|
+
// Templates are built in the inert document that <template> contents live in: cloning there and
|
|
24
|
+
// letting the insertion adopt the clone is cheaper than cloning inside the live document. Custom
|
|
25
|
+
// elements are the exception, since only the live document upgrades them, so a template that
|
|
26
|
+
// contains one is built (and cloned) there instead.
|
|
27
|
+
let inertDocument: Document | null = null;
|
|
28
|
+
// null is the live document, looked up when used: the module is imported before any DOM exists
|
|
29
|
+
// when a server renderer installs one afterwards.
|
|
30
|
+
let buildDocument: Document | null = null;
|
|
31
|
+
let sawCustomElement = false;
|
|
32
|
+
// The global document the cached templates were built for, and a counter that tells every template
|
|
33
|
+
// to build again. A server renderer may install a new window (a new global document) for each run:
|
|
34
|
+
// nodes cloned from another window's templates fail every `instanceof Node` check and are dropped.
|
|
35
|
+
let templatesDocument: Document | null = null;
|
|
36
|
+
let generation = 0;
|
|
37
|
+
|
|
38
|
+
function inert(): Document {
|
|
39
|
+
templatesDocument ??= document;
|
|
40
|
+
return (inertDocument ??= document.createElement("template").content.ownerDocument);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Called by render(): templates built for another global document are built again on next use. */
|
|
44
|
+
export function syncTemplateDocument(): void {
|
|
45
|
+
if (templatesDocument === null || templatesDocument === document) return;
|
|
46
|
+
templatesDocument = null;
|
|
47
|
+
inertDocument = null;
|
|
48
|
+
generation++;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Attribute-level props survive cloneNode, so they are applied to the template itself. A prop that
|
|
52
|
+
// lives only in a property (an expando, a custom element's field) would be lost by cloning, so it is
|
|
53
|
+
// replayed on every clone instead. Which is which is checked on a probe element, not guessed.
|
|
54
|
+
function bakeProp(el: Element, key: string, value: unknown): void {
|
|
55
|
+
const name = key === "htmlFor" ? "for" : key;
|
|
56
|
+
if (name === "class" || name === "className" || name === "style" || value === null || value === undefined) {
|
|
57
|
+
setProp(el, name, value);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Deep, so state held by the children (a <select>'s chosen option) is part of the check.
|
|
61
|
+
const probe = el.cloneNode(true) as Element & Record<string, unknown>;
|
|
62
|
+
setProp(probe, name, value);
|
|
63
|
+
const survives =
|
|
64
|
+
!hasWritableProperty(probe, name) ||
|
|
65
|
+
Object.is((probe.cloneNode(true) as Element & Record<string, unknown>)[name], probe[name]);
|
|
66
|
+
if (survives) setProp(el, name, value);
|
|
67
|
+
else (pendingPatches as Array<[Element, string, unknown]>).push([el, name, value]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function appendStatic(parent: Node, child: unknown): void {
|
|
71
|
+
if (child === MARKER) {
|
|
72
|
+
parent.appendChild((buildDocument ?? document).createComment(""));
|
|
73
|
+
} else if (typeof child === "string") {
|
|
74
|
+
parent.appendChild((buildDocument ?? document).createTextNode(child));
|
|
75
|
+
} else if (Array.isArray(child)) {
|
|
76
|
+
for (const c of child) appendStatic(parent, c);
|
|
77
|
+
} else if (child instanceof Node) {
|
|
78
|
+
parent.appendChild(child);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Builds one static element of a template. */
|
|
83
|
+
export function element(tag: string, props: Record<string, unknown> | null, children: unknown[] | null): Element {
|
|
84
|
+
if (tag.includes("-")) sawCustomElement = true;
|
|
85
|
+
const el = createElement(tag, buildDocument ?? document);
|
|
86
|
+
// Children first, as mountElement does, so a prop that depends on them (a <select> value) sees them.
|
|
87
|
+
if (children !== null) for (const child of children) appendStatic(el, child);
|
|
88
|
+
if (props !== null) for (const key in props) bakeProp(el, key, props[key]);
|
|
89
|
+
return el;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pathOf(root: Node, node: Node): number[] {
|
|
93
|
+
const path: number[] = [];
|
|
94
|
+
let current = node;
|
|
95
|
+
while (current !== root) {
|
|
96
|
+
let index = 0;
|
|
97
|
+
let sibling = current.previousSibling;
|
|
98
|
+
while (sibling !== null) {
|
|
99
|
+
index++;
|
|
100
|
+
sibling = sibling.previousSibling;
|
|
101
|
+
}
|
|
102
|
+
path.unshift(index);
|
|
103
|
+
current = current.parentNode as Node;
|
|
104
|
+
}
|
|
105
|
+
return path;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function nodeAt(root: Node, path: number[]): Element {
|
|
109
|
+
let node = root;
|
|
110
|
+
for (const index of path) node = node.childNodes[index];
|
|
111
|
+
return node as Element;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Builds the template root in `doc` (null: the live document) and lists the props to replay on
|
|
115
|
+
// every clone.
|
|
116
|
+
function buildIn(doc: Document | null, build: () => Element): [Element, Patch[] | null] {
|
|
117
|
+
const pending: Array<[Element, string, unknown]> = [];
|
|
118
|
+
pendingPatches = pending;
|
|
119
|
+
buildDocument = doc;
|
|
120
|
+
sawCustomElement = false;
|
|
121
|
+
const built = build();
|
|
122
|
+
return [built, pending.length > 0 ? pending.map(([el, key, value]): Patch => [pathOf(built, el), key, value]) : null];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Returns a function that clones the template `build` creates (built on first use). */
|
|
126
|
+
export function template(build: () => Element): () => Element {
|
|
127
|
+
let root: Element | null = null;
|
|
128
|
+
let patches: Patch[] | null = null;
|
|
129
|
+
let live = false;
|
|
130
|
+
let built = -1;
|
|
131
|
+
return () => {
|
|
132
|
+
if (built !== generation) {
|
|
133
|
+
const outerPatches = pendingPatches;
|
|
134
|
+
const outerDocument = buildDocument;
|
|
135
|
+
const outerSaw = sawCustomElement;
|
|
136
|
+
try {
|
|
137
|
+
let result = buildIn(inert(), build);
|
|
138
|
+
live = sawCustomElement;
|
|
139
|
+
if (live) result = buildIn(null, build);
|
|
140
|
+
[root, patches] = result;
|
|
141
|
+
built = generation;
|
|
142
|
+
} finally {
|
|
143
|
+
pendingPatches = outerPatches;
|
|
144
|
+
buildDocument = outerDocument;
|
|
145
|
+
sawCustomElement = outerSaw;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// A clone of a live-document template taken while another template builds forces that one
|
|
149
|
+
// into the live document too, or its own clones would hold never-upgraded custom elements.
|
|
150
|
+
if (live) sawCustomElement = true;
|
|
151
|
+
const node = (root as Element).cloneNode(true) as Element;
|
|
152
|
+
if (patches !== null) {
|
|
153
|
+
for (const [path, key, value] of patches) {
|
|
154
|
+
const el = nodeAt(node, path);
|
|
155
|
+
setProp(el, key, value);
|
|
156
|
+
// Cloned while another template is being built: that template's clones need it too.
|
|
157
|
+
if (pendingPatches !== null) pendingPatches.push([el, key, value]);
|
|
158
|
+
if (recording) recordProp(el, key, value, getCurrentScope());
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return node;
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Returns a function that clones the tree `markup` parses to (parsed on first use). The compiler
|
|
167
|
+
* emits it only for markup the parser reads back as the tree element() would build, with no custom
|
|
168
|
+
* element in it, so it is parsed and cloned in the inert document like a built template.
|
|
169
|
+
*/
|
|
170
|
+
export function html(markup: string): () => Element {
|
|
171
|
+
let root: Element | null = null;
|
|
172
|
+
let built = -1;
|
|
173
|
+
return () => {
|
|
174
|
+
if (built !== generation) {
|
|
175
|
+
const parser = inert().createElement("template");
|
|
176
|
+
parser.innerHTML = markup;
|
|
177
|
+
root = parser.content.firstChild as Element;
|
|
178
|
+
built = generation;
|
|
179
|
+
}
|
|
180
|
+
return (root as Element).cloneNode(true) as Element;
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Applies a dynamic prop to a cloned element. */
|
|
185
|
+
export function prop(el: Element, key: string, value: unknown, scope: Scope): void {
|
|
186
|
+
if (recording) recordProp(el, key, value, scope);
|
|
187
|
+
applyProp(el, key, value, scope);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Binds an event prop of a compiled template element through the delegated path (dom/events). */
|
|
191
|
+
export function on(el: Element, key: string, value: unknown, scope: Scope): void {
|
|
192
|
+
if (recording) recordProp(el, key, value, scope);
|
|
193
|
+
const entry = delegatedProp(key);
|
|
194
|
+
if (entry !== null) addDelegatedEvent(el, entry, value, scope);
|
|
195
|
+
else applyProp(el, key, value, scope);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The binding target of an event prop, resolved once per module by the compiler: the delegated
|
|
200
|
+
* entry, or the key itself when `key` is not a known event (it is then bound as a plain prop).
|
|
201
|
+
*/
|
|
202
|
+
export function event(key: string): DelegatedType | string {
|
|
203
|
+
return delegatedProp(key) ?? key;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Binds an event prop through the target `event()` resolved, with no lookup per binding. */
|
|
207
|
+
export function onEvent(el: Element, target: DelegatedType | string, value: unknown, scope: Scope): void {
|
|
208
|
+
if (typeof target === "string") {
|
|
209
|
+
prop(el, target, value, scope);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (recording) recordProp(el, target.key, value, scope);
|
|
213
|
+
addDelegatedEvent(el, target, value, scope);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Binds a reactive `class` (or `className`) prop: the compiler emits it for a function literal
|
|
218
|
+
* value, which applyProp would bind the same way after its key checks.
|
|
219
|
+
*/
|
|
220
|
+
export function className(el: Element, read: () => unknown, scope: Scope): void {
|
|
221
|
+
if (recording) recordProp(el, "class", read, scope);
|
|
222
|
+
bindClass(el, read, scope);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Mounts a reactive child before its marker comment from the template. */
|
|
226
|
+
export function reactive(marker: Node, fn: () => unknown, scope: Scope): void {
|
|
227
|
+
mountReactive(marker.parentNode as Node, fn, scope, marker);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Binds a reactive child that is the only content of `parent`, which the template leaves empty: no
|
|
232
|
+
* anchor comment is needed. Templates compiled by earlier versions hold an empty text node there,
|
|
233
|
+
* which a text result takes over; a hydration build pass gets one too, as mountReactive registers
|
|
234
|
+
* fresh nodes of other results only.
|
|
235
|
+
*/
|
|
236
|
+
export function reactiveOnly(parent: Node, fn: () => unknown, scope: Scope): void {
|
|
237
|
+
let placeholder: Text | null = null;
|
|
238
|
+
if (parent.hasChildNodes()) placeholder = parent.firstChild as Text;
|
|
239
|
+
else if (recording) placeholder = parent.appendChild(document.createTextNode(""));
|
|
240
|
+
mountReactive(parent, fn, scope, null, placeholder);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Inserts the only child of `parent`, which the template leaves empty. */
|
|
244
|
+
export function insertOnly(parent: Node, value: unknown, scope: Scope): void {
|
|
245
|
+
// textContent builds the text node without a JS wrapper for it. An empty string still gets its
|
|
246
|
+
// (empty) text node, as mountChildren gives it one.
|
|
247
|
+
if ((typeof value === "string" && value !== "") || typeof value === "number") {
|
|
248
|
+
parent.textContent = value as string;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
mountChildren(parent, value, scope);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Inserts a dynamic child before `before`, or at the end of `parent` when it is null. */
|
|
255
|
+
export function insert(parent: Node, value: unknown, before: Node | null, scope: Scope): void {
|
|
256
|
+
if (before === null) {
|
|
257
|
+
mountChildren(parent, value, scope);
|
|
258
|
+
} else if (typeof value === "function") {
|
|
259
|
+
const end = parent.insertBefore(document.createComment(""), before);
|
|
260
|
+
mountReactive(parent, value as () => unknown, scope, end);
|
|
261
|
+
} else if (value instanceof Node) {
|
|
262
|
+
parent.insertBefore(value, before);
|
|
263
|
+
} else if (typeof value === "string" || typeof value === "number") {
|
|
264
|
+
(before as ChildNode).before(String(value));
|
|
265
|
+
} else if (Array.isArray(value)) {
|
|
266
|
+
// Each item goes straight in front of `before`, in order: no fragment to fill and empty again.
|
|
267
|
+
for (const item of value) insert(parent, item, before, scope);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export { getCurrentScope as scope };
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"children.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/children.test.ts"],"names":[],"mappings":""}
|