@praxisjs/runtime 0.5.5 → 0.7.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 (68) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/__tests__/children.test.js +54 -0
  3. package/dist/__tests__/children.test.js.map +1 -1
  4. package/dist/__tests__/component.test.js +60 -1
  5. package/dist/__tests__/component.test.js.map +1 -1
  6. package/dist/__tests__/dom.test.js +100 -0
  7. package/dist/__tests__/dom.test.js.map +1 -1
  8. package/dist/__tests__/hydration.test.d.ts +2 -0
  9. package/dist/__tests__/hydration.test.d.ts.map +1 -0
  10. package/dist/__tests__/hydration.test.js +299 -0
  11. package/dist/__tests__/hydration.test.js.map +1 -0
  12. package/dist/__tests__/portal.test.js +22 -0
  13. package/dist/__tests__/portal.test.js.map +1 -1
  14. package/dist/__tests__/remove.test.d.ts +2 -0
  15. package/dist/__tests__/remove.test.d.ts.map +1 -0
  16. package/dist/__tests__/remove.test.js +45 -0
  17. package/dist/__tests__/remove.test.js.map +1 -0
  18. package/dist/component.d.ts.map +1 -1
  19. package/dist/component.js +22 -1
  20. package/dist/component.js.map +1 -1
  21. package/dist/dom/events.d.ts.map +1 -1
  22. package/dist/dom/events.js +18 -2
  23. package/dist/dom/events.js.map +1 -1
  24. package/dist/dom/props.d.ts.map +1 -1
  25. package/dist/dom/props.js +27 -5
  26. package/dist/dom/props.js.map +1 -1
  27. package/dist/dom/remove.d.ts +2 -0
  28. package/dist/dom/remove.d.ts.map +1 -0
  29. package/dist/dom/remove.js +23 -0
  30. package/dist/dom/remove.js.map +1 -0
  31. package/dist/element.d.ts.map +1 -1
  32. package/dist/element.js +5 -0
  33. package/dist/element.js.map +1 -1
  34. package/dist/hydration-context.d.ts +21 -0
  35. package/dist/hydration-context.d.ts.map +1 -0
  36. package/dist/hydration-context.js +40 -0
  37. package/dist/hydration-context.js.map +1 -0
  38. package/dist/hydration.d.ts +18 -0
  39. package/dist/hydration.d.ts.map +1 -0
  40. package/dist/hydration.js +67 -0
  41. package/dist/hydration.js.map +1 -0
  42. package/dist/index.d.ts +6 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +45 -10
  45. package/dist/index.js.map +1 -1
  46. package/dist/portal.d.ts.map +1 -1
  47. package/dist/portal.js +16 -7
  48. package/dist/portal.js.map +1 -1
  49. package/dist/reactive.d.ts.map +1 -1
  50. package/dist/reactive.js +16 -10
  51. package/dist/reactive.js.map +1 -1
  52. package/package.json +4 -4
  53. package/src/__tests__/children.test.ts +58 -0
  54. package/src/__tests__/component.test.ts +67 -1
  55. package/src/__tests__/dom.test.ts +108 -0
  56. package/src/__tests__/hydration.test.ts +360 -0
  57. package/src/__tests__/portal.test.ts +24 -0
  58. package/src/__tests__/remove.test.ts +51 -0
  59. package/src/component.ts +23 -1
  60. package/src/dom/events.ts +21 -2
  61. package/src/dom/props.ts +30 -5
  62. package/src/dom/remove.ts +22 -0
  63. package/src/element.ts +5 -0
  64. package/src/hydration-context.ts +53 -0
  65. package/src/hydration.ts +70 -0
  66. package/src/index.ts +57 -11
  67. package/src/portal.ts +16 -7
  68. package/src/reactive.ts +15 -9
@@ -323,4 +323,112 @@ describe("addEvent", () => {
323
323
  expect(fn).toHaveBeenCalledOnce();
324
324
  scope.dispose();
325
325
  });
326
+
327
+ it("batches signal writes made by a handler so effects run once", () => {
328
+ const el = document.createElement("button");
329
+ const scope = new Scope();
330
+ const a = signal(0);
331
+ const b = signal(0);
332
+ const runs = vi.fn();
333
+ scope.effect(() => {
334
+ void a();
335
+ void b();
336
+ runs();
337
+ });
338
+ runs.mockClear();
339
+ addEvent(el, "click", () => {
340
+ a.set(1);
341
+ b.set(1);
342
+ }, scope);
343
+ el.dispatchEvent(new MouseEvent("click"));
344
+ expect(runs).toHaveBeenCalledOnce();
345
+ scope.dispose();
346
+ });
347
+
348
+ it("calls the handler with the element as this and forwards the event", () => {
349
+ const el = document.createElement("button");
350
+ const scope = new Scope();
351
+ let seenThis: unknown;
352
+ let seenEvent: Event | undefined;
353
+ addEvent(el, "click", function (this: unknown, e: Event) {
354
+ seenThis = this;
355
+ seenEvent = e;
356
+ }, scope);
357
+ const event = new MouseEvent("click");
358
+ el.dispatchEvent(event);
359
+ expect(seenThis).toBe(el);
360
+ expect(seenEvent).toBe(event);
361
+ scope.dispose();
362
+ });
363
+
364
+ it("registers the same handler on different elements independently", () => {
365
+ const a = document.createElement("button");
366
+ const b = document.createElement("button");
367
+ const scope = new Scope();
368
+ const fn = vi.fn();
369
+ addEvent(a, "click", fn, scope);
370
+ addEvent(b, "click", fn, scope);
371
+ a.dispatchEvent(new MouseEvent("click"));
372
+ b.dispatchEvent(new MouseEvent("click"));
373
+ expect(fn).toHaveBeenCalledTimes(2);
374
+ scope.dispose();
375
+ a.dispatchEvent(new MouseEvent("click"));
376
+ expect(fn).toHaveBeenCalledTimes(2);
377
+ });
378
+ });
379
+
380
+ describe("applyProp writable-property cache", () => {
381
+ it("assigns prototype properties and keeps doing so for later elements of the same tag", () => {
382
+ const scope = new Scope();
383
+ const first = document.createElement("input");
384
+ const second = document.createElement("input");
385
+ applyProp(first, "value", "a", scope);
386
+ applyProp(second, "value", "b", scope);
387
+ expect(first.value).toBe("a");
388
+ expect(second.value).toBe("b");
389
+ });
390
+
391
+ it("falls back to attributes for unknown and read-only properties, consistently across elements", () => {
392
+ const scope = new Scope();
393
+ for (let i = 0; i < 2; i++) {
394
+ const input = document.createElement("input");
395
+ applyProp(input, "data-x", "1", scope);
396
+ applyProp(input, "list", "dl", scope);
397
+ expect(input.getAttribute("data-x")).toBe("1");
398
+ expect(input.getAttribute("list")).toBe("dl");
399
+ }
400
+ });
401
+
402
+ it("honours own properties: writable ones are assigned, getter-only ones become attributes", () => {
403
+ const scope = new Scope();
404
+ const el = document.createElement("div") as unknown as HTMLElement & Record<string, unknown>;
405
+ el.expando = "old";
406
+ Object.defineProperty(el, "readonlyThing", { get: () => "fixed", configurable: true });
407
+ applyProp(el, "expando", "new", scope);
408
+ applyProp(el, "readonlyThing", "attr", scope);
409
+ expect(el.expando).toBe("new");
410
+ expect(el.readonlyThing).toBe("fixed");
411
+ expect(el.getAttribute("readonlyThing")).toBe("attr");
412
+ });
413
+
414
+ it("an own property with a setter is assigned", () => {
415
+ const scope = new Scope();
416
+ const el = document.createElement("div") as unknown as HTMLElement & Record<string, unknown>;
417
+ let stored = "";
418
+ Object.defineProperty(el, "custom", { get: () => stored, set: (v: string) => { stored = v; }, configurable: true });
419
+ applyProp(el, "custom", "set", scope);
420
+ expect(stored).toBe("set");
421
+ });
422
+
423
+ it("runs ref callbacks untracked so they do not subscribe the enclosing effect", () => {
424
+ const scope = new Scope();
425
+ const s = signal(0);
426
+ const runs = vi.fn();
427
+ scope.effect(() => {
428
+ runs();
429
+ applyProp(document.createElement("div"), "ref", () => { void s(); }, scope);
430
+ });
431
+ s.set(1);
432
+ expect(runs).toHaveBeenCalledOnce();
433
+ });
326
434
  });
@@ -0,0 +1,360 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, afterEach } from "vitest";
3
+
4
+ import { StatefulComponent } from "@praxisjs/core";
5
+ import { isServerRenderPass, setServerRenderPass, signal } from "@praxisjs/core/internal";
6
+
7
+ import { mountComponent } from "../component";
8
+ import { getCurrentScope } from "../context";
9
+ import { mountElement } from "../element";
10
+ import { reconcile } from "../hydration";
11
+ import { registerReactiveNode } from "../hydration-context";
12
+ import { render, Portal } from "../index";
13
+ import { Scope } from "../scope";
14
+
15
+ const SSG_MARKER = "data-praxis-ssg";
16
+
17
+ /** Renders `factory` into a throwaway container and returns the resulting markup, simulating SSR output. */
18
+ function ssrHTML(factory: () => Node | Node[] | null): string {
19
+ const serverContainer = document.createElement("div");
20
+ const unmount = render(factory, serverContainer);
21
+ const html = serverContainer.innerHTML;
22
+ unmount();
23
+ return html;
24
+ }
25
+
26
+ function hydratable(factory: () => Node | Node[] | null): HTMLElement {
27
+ const container = document.createElement("div");
28
+ container.innerHTML = ssrHTML(factory);
29
+ container.setAttribute(SSG_MARKER, "1");
30
+ document.body.appendChild(container);
31
+ return container;
32
+ }
33
+
34
+ afterEach(() => { setServerRenderPass(false); });
35
+
36
+ describe("render() — hydration (build + reconcile)", () => {
37
+ it("adopts a flat element instead of recreating it", () => {
38
+ const factory = () => mountElement("p", { id: "greet", children: "hello" }, new Scope());
39
+ const container = hydratable(factory);
40
+ const serverEl = container.querySelector("#greet");
41
+ expect(serverEl).not.toBeNull();
42
+
43
+ render(factory, container);
44
+
45
+ expect(container.querySelector("#greet")).toBe(serverEl);
46
+ document.body.removeChild(container);
47
+ });
48
+
49
+ it("adopts nested elements at every level — the case that broke the ambient-cursor attempt", () => {
50
+ const factory = () => mountElement("div", {
51
+ id: "outer",
52
+ children: mountElement("span", { id: "inner", children: "text" }, new Scope()),
53
+ }, new Scope());
54
+ const container = hydratable(factory);
55
+ const outerBefore = container.querySelector("#outer");
56
+ const innerBefore = container.querySelector("#inner");
57
+
58
+ render(factory, container);
59
+
60
+ expect(container.querySelector("#outer")).toBe(outerBefore);
61
+ expect(container.querySelector("#inner")).toBe(innerBefore);
62
+ expect(container.textContent).toBe("text");
63
+ document.body.removeChild(container);
64
+ });
65
+
66
+ it("adopts three levels deep with siblings at each level", () => {
67
+ const factory = () => mountElement("ul", {
68
+ id: "list",
69
+ children: [
70
+ mountElement("li", { id: "a", children: mountElement("b", { children: "1" }, new Scope()) }, new Scope()),
71
+ mountElement("li", { id: "b", children: "2" }, new Scope()),
72
+ ],
73
+ }, new Scope());
74
+ const container = hydratable(factory);
75
+ const before = {
76
+ list: container.querySelector("#list"),
77
+ a: container.querySelector("#a"),
78
+ b: container.querySelector("#b"),
79
+ bold: container.querySelector("b"),
80
+ };
81
+
82
+ render(factory, container);
83
+
84
+ expect(container.querySelector("#list")).toBe(before.list);
85
+ expect(container.querySelector("#a")).toBe(before.a);
86
+ expect(container.querySelector("#b")).toBe(before.b);
87
+ expect(container.querySelector("b")).toBe(before.bold);
88
+ document.body.removeChild(container);
89
+ });
90
+
91
+ it("wires event listeners onto the adopted (real) element via replay", () => {
92
+ let clicks = 0;
93
+ const factory = () => mountElement("div", {
94
+ children: mountElement("button", { onClick: () => { clicks++; }, children: "Click" }, new Scope()),
95
+ }, new Scope());
96
+ const container = hydratable(factory);
97
+ const buttonBefore = container.querySelector("button");
98
+
99
+ render(factory, container);
100
+
101
+ expect(container.querySelector("button")).toBe(buttonBefore);
102
+ container.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
103
+ expect(clicks).toBe(1);
104
+ document.body.removeChild(container);
105
+ });
106
+
107
+ it("hydrates reactive text and keeps reacting to signal changes afterwards", () => {
108
+ const text = signal("initial");
109
+ const factory = () => mountElement("span", { children: () => text() }, new Scope());
110
+ const container = hydratable(factory);
111
+ expect(container.textContent).toBe("initial");
112
+
113
+ render(factory, container);
114
+
115
+ text.set("updated");
116
+ expect(container.textContent).toBe("updated");
117
+ text.set("initial");
118
+ document.body.removeChild(container);
119
+ });
120
+
121
+ it("hydrates a reactive list of elements, keeping real nodes, and stays correct after a later update", () => {
122
+ const items = signal(["a", "b", "c"]);
123
+ const factory = () => mountElement("ul", {
124
+ id: "list",
125
+ children: () => items().map((item) => mountElement("li", { children: item }, new Scope())),
126
+ }, new Scope());
127
+ const container = hydratable(factory);
128
+ const list = container.querySelector("#list") as HTMLElement;
129
+ const firstLiBefore = list.querySelector("li");
130
+ expect(container.textContent).toBe("abc");
131
+
132
+ render(factory, container);
133
+
134
+ expect(container.querySelector("#list")?.querySelector("li")).toBe(firstLiBefore);
135
+ expect(container.textContent).toBe("abc");
136
+
137
+ // A subsequent reactive update must diff correctly — this only works if
138
+ // reconcile() rewrote mountReactive's `currentNodes` bookkeeping in place
139
+ // when it kept the real <li> elements instead of the fresh ones.
140
+ items.set(["x", "y"]);
141
+ expect(container.textContent).toBe("xy");
142
+ items.set(["z"]);
143
+ expect(container.textContent).toBe("z");
144
+ document.body.removeChild(container);
145
+ });
146
+
147
+ it("hydrates a component nested inside another component, firing onMount/onUnmount correctly", async () => {
148
+ class Inner extends StatefulComponent {
149
+ static __isComponent = true as const;
150
+ static __isStateless = false;
151
+ onMount() {}
152
+ onUnmount() {}
153
+ render() { return mountElement("em", { children: "inner" }, getCurrentScope()); }
154
+ }
155
+ class Outer extends StatefulComponent {
156
+ static __isComponent = true as const;
157
+ static __isStateless = false;
158
+ onMount() {}
159
+ render() {
160
+ return mountElement("section", {
161
+ children: mountComponent(Inner, {}, getCurrentScope()),
162
+ }, getCurrentScope());
163
+ }
164
+ }
165
+
166
+ const factory = () => mountComponent(Outer, {}, getCurrentScope());
167
+ const container = hydratable(factory);
168
+ const emBefore = container.querySelector("em");
169
+
170
+ const outerMount = vi.spyOn(Outer.prototype, "onMount");
171
+ const innerMount = vi.spyOn(Inner.prototype, "onMount");
172
+ const innerUnmount = vi.spyOn(Inner.prototype, "onUnmount");
173
+ const dispose = render(factory, container);
174
+ await Promise.resolve();
175
+
176
+ expect(outerMount).toHaveBeenCalledOnce();
177
+ expect(innerMount).toHaveBeenCalledOnce();
178
+ expect(container.querySelector("em")).toBe(emBefore);
179
+ expect(container.textContent).toBe("inner");
180
+
181
+ dispose();
182
+ expect(innerUnmount).toHaveBeenCalledOnce();
183
+
184
+ outerMount.mockRestore();
185
+ innerMount.mockRestore();
186
+ innerUnmount.mockRestore();
187
+ document.body.removeChild(container);
188
+ });
189
+
190
+ it("a tag mismatch at one node replaces only that node — siblings stay adopted", () => {
191
+ const factory = () => mountElement("div", {
192
+ id: "wrap",
193
+ children: [
194
+ mountElement("p", { id: "before", children: "before" }, new Scope()),
195
+ mountElement("span", { id: "target", children: "new" }, new Scope()),
196
+ mountElement("p", { id: "after", children: "after" }, new Scope()),
197
+ ],
198
+ }, new Scope());
199
+
200
+ const container = document.createElement("div");
201
+ container.innerHTML =
202
+ '<div id="wrap"><p id="before">before</p><strong id="target">old</strong><p id="after">after</p></div>';
203
+ container.setAttribute(SSG_MARKER, "1");
204
+ document.body.appendChild(container);
205
+
206
+ const beforeEl = container.querySelector("#before");
207
+ const afterEl = container.querySelector("#after");
208
+
209
+ render(factory, container);
210
+
211
+ expect(container.querySelector("#before")).toBe(beforeEl);
212
+ expect(container.querySelector("#after")).toBe(afterEl);
213
+ expect(container.querySelector("#target")?.tagName.toLowerCase()).toBe("span");
214
+ expect(container.textContent).toBe("beforenewafter");
215
+ document.body.removeChild(container);
216
+ });
217
+
218
+ it("appends extra fresh nodes when the real DOM has fewer children than the fresh tree", () => {
219
+ // Client factory produces three items; the server only rendered two —
220
+ // exercises reconcile()'s mismatch branch once realCursor has run out.
221
+ const factory = () => mountElement("ul", {
222
+ id: "list",
223
+ children: [
224
+ mountElement("li", { id: "a", children: "a" }, new Scope()),
225
+ mountElement("li", { id: "b", children: "b" }, new Scope()),
226
+ mountElement("li", { id: "c", children: "c" }, new Scope()),
227
+ ],
228
+ }, new Scope());
229
+
230
+ const container = document.createElement("div");
231
+ container.innerHTML = '<ul id="list"><li id="a">a</li><li id="b">b</li></ul>';
232
+ container.setAttribute(SSG_MARKER, "1");
233
+ document.body.appendChild(container);
234
+
235
+ const aBefore = container.querySelector("#a");
236
+ const bBefore = container.querySelector("#b");
237
+
238
+ render(factory, container);
239
+
240
+ expect(container.querySelector("#a")).toBe(aBefore);
241
+ expect(container.querySelector("#b")).toBe(bBefore);
242
+ expect(container.querySelector("#c")?.textContent).toBe("c");
243
+ expect(container.querySelectorAll("#list > li")).toHaveLength(3);
244
+ document.body.removeChild(container);
245
+ });
246
+
247
+ it("keeps the real element even when its fresh counterpart bypassed mountElement (nothing recorded to replay)", () => {
248
+ class RawComp extends StatefulComponent {
249
+ static __isComponent = true as const;
250
+ static __isStateless = false;
251
+ render() {
252
+ const el = document.createElement("mark");
253
+ el.id = "raw";
254
+ el.textContent = "raw content";
255
+ return el;
256
+ }
257
+ }
258
+
259
+ const factory = () => mountComponent(RawComp, {}, getCurrentScope());
260
+ const container = hydratable(factory);
261
+ const markBefore = container.querySelector("#raw");
262
+
263
+ render(factory, container);
264
+
265
+ expect(container.querySelector("#raw")).toBe(markBefore);
266
+ expect(container.textContent).toBe("raw content");
267
+ document.body.removeChild(container);
268
+ });
269
+
270
+ it("removes trailing real nodes the fresh tree no longer produces", () => {
271
+ // Server rendered three items; the client factory only produces two —
272
+ // exercises reconcile()'s cleanup loop for leftover real siblings past
273
+ // the end of freshChildren.
274
+ const factory = () => mountElement("ul", {
275
+ id: "list",
276
+ children: [
277
+ mountElement("li", { id: "a", children: "a" }, new Scope()),
278
+ mountElement("li", { id: "b", children: "b" }, new Scope()),
279
+ ],
280
+ }, new Scope());
281
+
282
+ const container = document.createElement("div");
283
+ container.innerHTML =
284
+ '<ul id="list"><li id="a">a</li><li id="b">b</li><li id="c">c</li></ul>';
285
+ container.setAttribute(SSG_MARKER, "1");
286
+ document.body.appendChild(container);
287
+
288
+ const aBefore = container.querySelector("#a");
289
+ const bBefore = container.querySelector("#b");
290
+
291
+ render(factory, container);
292
+
293
+ expect(container.querySelector("#a")).toBe(aBefore);
294
+ expect(container.querySelector("#b")).toBe(bBefore);
295
+ expect(container.querySelector("#c")).toBeNull();
296
+ expect(container.querySelectorAll("#list > li")).toHaveLength(2);
297
+ document.body.removeChild(container);
298
+ });
299
+
300
+ it("reconcile() is a safe no-op when a fresh node's registered reactive owner doesn't actually contain it", () => {
301
+ // Defensive guard in notifyReplacement(): registerReactiveNode() always
302
+ // registers a node into the exact array collectNodes() just built it
303
+ // into, so indexOf() finding nothing shouldn't happen through normal
304
+ // mounting — this exercises that branch directly, at the unit level, to
305
+ // confirm the guard degrades safely (no throw, no corruption) rather
306
+ // than silently trusting it can never occur.
307
+ const fresh = document.createElement("span");
308
+ const bogusOwner: Node[] = [];
309
+ registerReactiveNode(fresh, bogusOwner);
310
+
311
+ const container = document.createElement("div");
312
+ const real = document.createElement("span");
313
+ container.appendChild(real);
314
+
315
+ expect(() => { reconcile(container, [fresh]); }).not.toThrow();
316
+ expect(bogusOwner).toEqual([]);
317
+ expect(container.firstChild).toBe(real);
318
+ });
319
+
320
+ it("plain client render (no SSG marker) is completely unaffected", () => {
321
+ const factory = () => mountElement("p", { children: "hello" }, new Scope());
322
+ const container = document.createElement("div");
323
+ document.body.appendChild(container);
324
+
325
+ render(factory, container);
326
+
327
+ expect(container.textContent).toBe("hello");
328
+ document.body.removeChild(container);
329
+ });
330
+ });
331
+
332
+ describe("Portal — server render pass", () => {
333
+ it("renders nothing into the target during a server render pass", () => {
334
+ setServerRenderPass(true);
335
+ expect(isServerRenderPass()).toBe(true);
336
+
337
+ const target = document.createElement("div");
338
+ document.body.appendChild(target);
339
+ const scope = new Scope();
340
+ const nodes = mountComponent(Portal, { to: target, children: document.createTextNode("hi") }, scope);
341
+
342
+ expect(target.textContent).toBe("");
343
+ expect(nodes.every((n) => n.nodeType === Node.COMMENT_NODE)).toBe(true);
344
+ scope.dispose();
345
+ document.body.removeChild(target);
346
+ });
347
+
348
+ it("mounts normally on the client once the server pass is over", () => {
349
+ setServerRenderPass(false);
350
+ const target = document.createElement("div");
351
+ document.body.appendChild(target);
352
+ const scope = new Scope();
353
+
354
+ mountComponent(Portal, { to: target, children: document.createTextNode("hi") }, scope);
355
+
356
+ expect(target.textContent).toBe("hi");
357
+ scope.dispose();
358
+ document.body.removeChild(target);
359
+ });
360
+ });
@@ -150,4 +150,28 @@ describe("resolvePortalTarget", () => {
150
150
  expect(resolvePortalTarget("#resolve-test")).toBe(el);
151
151
  document.body.removeChild(el);
152
152
  });
153
+
154
+ it("attaches the whole subtree to the target in one operation", () => {
155
+ const target = document.createElement("div");
156
+ document.body.appendChild(target);
157
+ const spy = vi.spyOn(target, "appendChild");
158
+ const children = [document.createElement("p"), document.createElement("span")];
159
+ const { scope } = mount({ to: target, children });
160
+ expect(spy).toHaveBeenCalledOnce();
161
+ expect(target.querySelector("p")).not.toBeNull();
162
+ expect(target.querySelector("span")).not.toBeNull();
163
+ scope.dispose();
164
+ });
165
+
166
+ it("reactive children mounted through the portal keep updating after attach", () => {
167
+ const target = document.createElement("div");
168
+ document.body.appendChild(target);
169
+ const s = signal("one");
170
+ const { scope } = mount({ to: target, children: () => s() });
171
+ expect(target.textContent).toContain("one");
172
+ s.set("two");
173
+ expect(target.textContent).toContain("two");
174
+ expect(target.textContent).not.toContain("one");
175
+ scope.dispose();
176
+ });
153
177
  });
@@ -0,0 +1,51 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from "vitest";
3
+
4
+ import { removeNodes } from "../dom/remove";
5
+
6
+ function siblings(...names: string[]): { parent: HTMLElement; nodes: HTMLElement[] } {
7
+ const parent = document.createElement("div");
8
+ const nodes = names.map((name) => {
9
+ const el = document.createElement(name);
10
+ parent.appendChild(el);
11
+ return el;
12
+ });
13
+ return { parent, nodes };
14
+ }
15
+
16
+ describe("removeNodes", () => {
17
+ it("removes the first-to-last span and leaves the surrounding siblings", () => {
18
+ const { parent, nodes } = siblings("a", "b", "c", "d", "e");
19
+ removeNodes(nodes.slice(1, 4));
20
+ expect([...parent.children].map((c) => c.localName)).toEqual(["a", "e"]);
21
+ });
22
+
23
+ it("also removes nodes inserted between first and last after the list was captured", () => {
24
+ const { parent, nodes } = siblings("a", "b");
25
+ const late = document.createElement("late");
26
+ parent.insertBefore(late, nodes[1]);
27
+ removeNodes(nodes);
28
+ expect(parent.childNodes).toHaveLength(0);
29
+ });
30
+
31
+ it("removes a single node", () => {
32
+ const { parent, nodes } = siblings("a", "b");
33
+ removeNodes([nodes[0]]);
34
+ expect([...parent.children]).toEqual([nodes[1]]);
35
+ });
36
+
37
+ it("removes the attached nodes individually when the first one is detached", () => {
38
+ const { parent, nodes } = siblings("a", "b", "c");
39
+ parent.removeChild(nodes[0]);
40
+ expect(() => { removeNodes(nodes); }).not.toThrow();
41
+ expect(parent.childNodes).toHaveLength(0);
42
+ });
43
+
44
+ it("removes each node from its own parent when first and last have different parents", () => {
45
+ const one = siblings("a", "b");
46
+ const two = siblings("c", "d");
47
+ removeNodes([one.nodes[0], one.nodes[1], two.nodes[0]]);
48
+ expect(one.parent.childNodes).toHaveLength(0);
49
+ expect([...two.parent.children]).toEqual([two.nodes[1]]);
50
+ });
51
+ });
package/src/component.ts CHANGED
@@ -11,6 +11,28 @@ import { runInScope } from "./context";
11
11
 
12
12
  import type { Scope } from "./scope";
13
13
 
14
+ let pendingMounts: Array<() => void> = [];
15
+
16
+ // One shared microtask drains every component mounted in the same synchronous pass, in mount
17
+ // order (children before parents). A throwing onMount is rethrown on its own microtask so it
18
+ // can't stop the others, as it couldn't when each component had a microtask of its own.
19
+ function scheduleMount(run: () => void): void {
20
+ if (pendingMounts.length === 0) {
21
+ queueMicrotask(() => {
22
+ const queue = pendingMounts;
23
+ pendingMounts = [];
24
+ for (const job of queue) {
25
+ try {
26
+ job();
27
+ } catch (e) {
28
+ queueMicrotask(() => { throw e; });
29
+ }
30
+ }
31
+ });
32
+ }
33
+ pendingMounts.push(run);
34
+ }
35
+
14
36
  export function mountComponent(
15
37
  ctor: ComponentConstructor,
16
38
  props: Record<string, unknown>,
@@ -56,7 +78,7 @@ export function mountComponent(
56
78
  mountChildren(container, dom, scope);
57
79
  container.appendChild(end);
58
80
 
59
- queueMicrotask(() => {
81
+ scheduleMount(() => {
60
82
  if (disposed) return;
61
83
  setComponentMounted(instance, true);
62
84
  instance.onMount?.();
package/src/dom/events.ts CHANGED
@@ -1,11 +1,30 @@
1
+ import { batch } from "@praxisjs/core/internal";
2
+
1
3
  import type { Scope } from "../scope";
2
4
 
5
+ // One wrapper per handler keeps addEventListener's dedupe of an identical listener intact.
6
+ const batchedHandlers = new WeakMap<EventListener, EventListener>();
7
+
8
+ // Handlers run inside batch() so several signal writes in one event flush their effects once,
9
+ // instead of re-running the same reactive subtree after every write.
10
+ function batched(handler: EventListener): EventListener {
11
+ let wrapper = batchedHandlers.get(handler);
12
+ if (!wrapper) {
13
+ wrapper = function (this: Element, event) {
14
+ batch(() => { handler.call(this, event); });
15
+ };
16
+ batchedHandlers.set(handler, wrapper);
17
+ }
18
+ return wrapper;
19
+ }
20
+
3
21
  export function addEvent(
4
22
  el: Element,
5
23
  eventName: string,
6
24
  handler: EventListener,
7
25
  scope: Scope,
8
26
  ): void {
9
- el.addEventListener(eventName, handler);
10
- scope.add(() => { el.removeEventListener(eventName, handler); });
27
+ const listener = batched(handler);
28
+ el.addEventListener(eventName, listener);
29
+ scope.add(() => { el.removeEventListener(eventName, listener); });
11
30
  }
package/src/dom/props.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { untrack } from "@praxisjs/core/internal";
2
+
1
3
  import { EVENT_MAP } from "./constants";
2
4
  import { addEvent } from "./events";
3
5
 
@@ -42,10 +44,10 @@ function applyAttr(el: Element, key: string, value: unknown): void {
42
44
  }
43
45
  }
44
46
 
45
- // Read-only accessors (SVG geometry, `list`, `form`, `part`, `classList`…)
46
- // must be excluded here or assignment below throws.
47
- function hasWritableProperty(el: Element, key: string): boolean {
48
- let target: object | null = el;
47
+ const writableCache = new WeakMap<object, Map<string, boolean>>();
48
+
49
+ function lookupWritable(proto: object, key: string): boolean {
50
+ let target: object | null = proto;
49
51
  while (target) {
50
52
  const descriptor = Object.getOwnPropertyDescriptor(target, key);
51
53
  if (descriptor) {
@@ -56,6 +58,28 @@ function hasWritableProperty(el: Element, key: string): boolean {
56
58
  return false;
57
59
  }
58
60
 
61
+ // Read-only accessors (SVG geometry, `list`, `form`, `part`, `classList`…)
62
+ // must be excluded here or assignment below throws. The prototype walk is cached per
63
+ // prototype because every element of a given tag repeats the same lookups; a property defined on
64
+ // a prototype after its first lookup (e.g. a late polyfill) is therefore not picked up.
65
+ function hasWritableProperty(el: Element, key: string): boolean {
66
+ const own = Object.getOwnPropertyDescriptor(el, key);
67
+ if (own) return own.set !== undefined || own.writable === true;
68
+
69
+ const proto = Object.getPrototypeOf(el) as object;
70
+ let keys = writableCache.get(proto);
71
+ if (!keys) {
72
+ keys = new Map();
73
+ writableCache.set(proto, keys);
74
+ }
75
+ let writable = keys.get(key);
76
+ if (writable === undefined) {
77
+ writable = lookupWritable(proto, key);
78
+ keys.set(key, writable);
79
+ }
80
+ return writable;
81
+ }
82
+
59
83
  function setProp(el: Element, key: string, value: unknown): void {
60
84
  if (key === "class" || key === "className") {
61
85
  applyClass(el, value);
@@ -81,7 +105,8 @@ export function applyProp(
81
105
  if (normalizedKey === "key" || normalizedKey === "children") return;
82
106
 
83
107
  if (normalizedKey === "ref") {
84
- (value as (el: Element) => void)(el);
108
+ // untracked: a ref callback that reads a signal must not subscribe the enclosing reactive child
109
+ untrack(() => { (value as (el: Element) => void)(el); });
85
110
  return;
86
111
  }
87
112