@praxisjs/runtime 0.7.0 → 0.8.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.
Files changed (138) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/children.d.ts.map +1 -1
  3. package/dist/children.js +2 -1
  4. package/dist/children.js.map +1 -1
  5. package/dist/component.d.ts +2 -3
  6. package/dist/component.d.ts.map +1 -1
  7. package/dist/component.js +133 -60
  8. package/dist/component.js.map +1 -1
  9. package/dist/context.d.ts +13 -0
  10. package/dist/context.d.ts.map +1 -1
  11. package/dist/context.js +24 -2
  12. package/dist/context.js.map +1 -1
  13. package/dist/dom/constants.d.ts +1 -1
  14. package/dist/dom/constants.d.ts.map +1 -1
  15. package/dist/dom/constants.js +24 -83
  16. package/dist/dom/constants.js.map +1 -1
  17. package/dist/dom/create.d.ts +1 -1
  18. package/dist/dom/create.d.ts.map +1 -1
  19. package/dist/dom/create.js +3 -3
  20. package/dist/dom/create.js.map +1 -1
  21. package/dist/dom/events.d.ts +29 -1
  22. package/dist/dom/events.d.ts.map +1 -1
  23. package/dist/dom/events.js +167 -17
  24. package/dist/dom/events.js.map +1 -1
  25. package/dist/dom/props.d.ts +4 -0
  26. package/dist/dom/props.d.ts.map +1 -1
  27. package/dist/dom/props.js +57 -8
  28. package/dist/dom/props.js.map +1 -1
  29. package/dist/dom/range.d.ts +4 -0
  30. package/dist/dom/range.d.ts.map +1 -0
  31. package/dist/dom/range.js +43 -0
  32. package/dist/dom/range.js.map +1 -0
  33. package/dist/dom/remove.d.ts.map +1 -1
  34. package/dist/dom/remove.js +2 -8
  35. package/dist/dom/remove.js.map +1 -1
  36. package/dist/element.d.ts.map +1 -1
  37. package/dist/element.js +7 -5
  38. package/dist/element.js.map +1 -1
  39. package/dist/hmr.d.ts +35 -0
  40. package/dist/hmr.d.ts.map +1 -0
  41. package/dist/hmr.js +85 -0
  42. package/dist/hmr.js.map +1 -0
  43. package/dist/hydration-context.d.ts +9 -1
  44. package/dist/hydration-context.d.ts.map +1 -1
  45. package/dist/hydration-context.js +34 -13
  46. package/dist/hydration-context.js.map +1 -1
  47. package/dist/hydration.d.ts.map +1 -1
  48. package/dist/hydration.js +4 -11
  49. package/dist/hydration.js.map +1 -1
  50. package/dist/index.d.ts +16 -5
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +31 -7
  53. package/dist/index.js.map +1 -1
  54. package/dist/list.d.ts +72 -0
  55. package/dist/list.d.ts.map +1 -0
  56. package/dist/list.js +786 -0
  57. package/dist/list.js.map +1 -0
  58. package/dist/portal.d.ts.map +1 -1
  59. package/dist/portal.js +3 -0
  60. package/dist/portal.js.map +1 -1
  61. package/dist/reactive.d.ts +2 -2
  62. package/dist/reactive.d.ts.map +1 -1
  63. package/dist/reactive.js +109 -34
  64. package/dist/reactive.js.map +1 -1
  65. package/dist/scope.d.ts +14 -4
  66. package/dist/scope.d.ts.map +1 -1
  67. package/dist/scope.js +55 -15
  68. package/dist/scope.js.map +1 -1
  69. package/dist/template.d.ts +48 -0
  70. package/dist/template.d.ts.map +1 -0
  71. package/dist/template.js +270 -0
  72. package/dist/template.js.map +1 -0
  73. package/package.json +15 -6
  74. package/src/children.ts +2 -1
  75. package/src/component.ts +135 -63
  76. package/src/context.ts +35 -4
  77. package/src/dom/constants.ts +25 -82
  78. package/src/dom/create.ts +3 -3
  79. package/src/dom/events.ts +188 -17
  80. package/src/dom/props.ts +66 -9
  81. package/src/dom/range.ts +45 -0
  82. package/src/dom/remove.ts +3 -7
  83. package/src/element.ts +8 -6
  84. package/src/hmr.ts +110 -0
  85. package/src/hydration-context.ts +34 -13
  86. package/src/hydration.ts +4 -9
  87. package/src/index.ts +54 -7
  88. package/src/list.ts +886 -0
  89. package/src/portal.ts +3 -0
  90. package/src/reactive.ts +147 -40
  91. package/src/scope.ts +63 -16
  92. package/src/template.ts +271 -0
  93. package/dist/__tests__/children.test.d.ts +0 -2
  94. package/dist/__tests__/children.test.d.ts.map +0 -1
  95. package/dist/__tests__/children.test.js +0 -241
  96. package/dist/__tests__/children.test.js.map +0 -1
  97. package/dist/__tests__/component.test.d.ts +0 -2
  98. package/dist/__tests__/component.test.d.ts.map +0 -1
  99. package/dist/__tests__/component.test.js +0 -331
  100. package/dist/__tests__/component.test.js.map +0 -1
  101. package/dist/__tests__/context.test.d.ts +0 -2
  102. package/dist/__tests__/context.test.d.ts.map +0 -1
  103. package/dist/__tests__/context.test.js +0 -111
  104. package/dist/__tests__/context.test.js.map +0 -1
  105. package/dist/__tests__/dom.test.d.ts +0 -2
  106. package/dist/__tests__/dom.test.d.ts.map +0 -1
  107. package/dist/__tests__/dom.test.js +0 -385
  108. package/dist/__tests__/dom.test.js.map +0 -1
  109. package/dist/__tests__/hydration.test.d.ts +0 -2
  110. package/dist/__tests__/hydration.test.d.ts.map +0 -1
  111. package/dist/__tests__/hydration.test.js +0 -299
  112. package/dist/__tests__/hydration.test.js.map +0 -1
  113. package/dist/__tests__/portal.test.d.ts +0 -2
  114. package/dist/__tests__/portal.test.d.ts.map +0 -1
  115. package/dist/__tests__/portal.test.js +0 -157
  116. package/dist/__tests__/portal.test.js.map +0 -1
  117. package/dist/__tests__/remove.test.d.ts +0 -2
  118. package/dist/__tests__/remove.test.d.ts.map +0 -1
  119. package/dist/__tests__/remove.test.js +0 -45
  120. package/dist/__tests__/remove.test.js.map +0 -1
  121. package/dist/__tests__/render.test.d.ts +0 -2
  122. package/dist/__tests__/render.test.d.ts.map +0 -1
  123. package/dist/__tests__/render.test.js +0 -97
  124. package/dist/__tests__/render.test.js.map +0 -1
  125. package/dist/__tests__/scope.test.d.ts +0 -2
  126. package/dist/__tests__/scope.test.d.ts.map +0 -1
  127. package/dist/__tests__/scope.test.js +0 -110
  128. package/dist/__tests__/scope.test.js.map +0 -1
  129. package/src/__tests__/children.test.ts +0 -276
  130. package/src/__tests__/component.test.ts +0 -363
  131. package/src/__tests__/context.test.ts +0 -128
  132. package/src/__tests__/dom.test.ts +0 -434
  133. package/src/__tests__/hydration.test.ts +0 -360
  134. package/src/__tests__/portal.test.ts +0 -177
  135. package/src/__tests__/remove.test.ts +0 -51
  136. package/src/__tests__/render.test.ts +0 -119
  137. package/src/__tests__/scope.test.ts +0 -120
  138. 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 { runInScope } from "./context";
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 { isRecording, registerReactiveNode } from "./hydration-context";
4
- import { Scope } from "./scope";
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
- export function mountReactive(
24
- parent: Node,
25
- fn: () => unknown,
26
- parentScope: Scope,
27
- ): void {
28
- const end = document.createComment("");
29
- parent.appendChild(end);
30
-
31
- let currentNodes: Node[] = [];
32
- let childScope = new Scope();
33
-
34
- parentScope.effect(() => {
35
- childScope.dispose();
36
- childScope = new Scope();
37
-
38
- const result = runInScope(childScope, fn);
39
- const newNodes: Node[] = [];
40
- collectNodes(result, newNodes);
41
-
42
- // During a hydration build pass, remember that each of these fresh nodes
43
- // belongs to *this* array — reconcile() mutates `newNodes` in place (by
44
- // reference, same array `currentNodes` ends up pointing at) if it decides
45
- // to keep a real element instead of one of them, so future diffs here
46
- // stay correct.
47
- if (isRecording()) {
48
- for (const n of newNodes) registerReactiveNode(n, newNodes);
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
- const anchor = end.parentNode ?? parent;
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
- if (currentNodes.length === 1) {
54
- (anchor as Element).removeChild(currentNodes[0]);
55
- } else if (currentNodes.length > 1) {
56
- removeNodes(currentNodes);
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
- if (newNodes.length > 0) {
60
- anchor.insertBefore(nodesToFragment(newNodes), end);
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
- currentNodes = newNodes;
64
- });
145
+ if (newNodes.length > 0) {
146
+ anchor.insertBefore(nodesToFragment(newNodes), end);
147
+ }
65
148
 
66
- parentScope.add(() => { childScope.dispose(); });
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 { effect as coreEffect } from "@praxisjs/core/internal";
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
- #cleanups: Cleanup[] = [];
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(fn: Parameters<typeof coreEffect>[0]): void {
8
- this.#cleanups.push(coreEffect(fn));
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.#cleanups.push(cleanup);
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.#cleanups.push(() => { child.dispose(); });
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
- const cleanups = this.#cleanups;
23
- this.#cleanups = [];
24
- const errors: unknown[] = [];
25
- for (const fn of cleanups) {
26
- try {
27
- fn();
28
- } catch (e) {
29
- errors.push(e);
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
- if (errors.length > 1) throw new AggregateError(errors, "[PraxisJS] Multiple errors during dispose()");
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() });
@@ -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,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=children.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"children.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/children.test.ts"],"names":[],"mappings":""}