electrobun 1.18.4-beta.18 → 1.18.4-beta.21

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 (55) hide show
  1. package/README.md +9 -0
  2. package/bin/electrobun.cjs +165 -0
  3. package/dist/api/browser/ui/__tests__/dom.test.ts +473 -0
  4. package/dist/api/browser/ui/__tests__/domStub.ts +218 -0
  5. package/dist/api/browser/ui/dom.ts +490 -0
  6. package/dist/api/browser/ui/index.ts +44 -0
  7. package/dist/api/browser/ui/jsx-dev-runtime.ts +16 -0
  8. package/dist/api/browser/ui/jsx-runtime.ts +56 -0
  9. package/dist/api/config/ElectrobunConfig.ts +33 -0
  10. package/dist/api/preload/.generated/compiled.ts +1 -1
  11. package/dist/api/preload/index.ts +2 -0
  12. package/dist/api/preload/uiTag.ts +45 -0
  13. package/dist/api/sdks/main/__tests__/utils-quit-exit-code.test.ts +44 -0
  14. package/dist/api/sdks/main/core/GpuWindow.ts +19 -0
  15. package/dist/api/sdks/main/core/Utils.ts +52 -4
  16. package/dist/api/sdks/main/core/WGPUView.ts +9 -0
  17. package/dist/api/sdks/main/entries/ui.ts +1 -0
  18. package/dist/api/sdks/main/proc/native.ts +187 -0
  19. package/dist/api/sdks/main/ui/__tests__/font.test.ts +49 -0
  20. package/dist/api/sdks/main/ui/__tests__/hit.test.ts +64 -0
  21. package/dist/api/sdks/main/ui/__tests__/jsx.test.ts +252 -0
  22. package/dist/api/sdks/main/ui/__tests__/layout.test.ts +159 -0
  23. package/dist/api/sdks/main/ui/__tests__/paint.test.ts +115 -0
  24. package/dist/api/sdks/main/ui/__tests__/reactive.test.ts +456 -0
  25. package/dist/api/sdks/main/ui/__tests__/scroll-focus-input.test.ts +298 -0
  26. package/dist/api/sdks/main/ui/__tests__/tree.test.ts +96 -0
  27. package/dist/api/sdks/main/ui/__tests__/ui.test.ts +170 -0
  28. package/dist/api/sdks/main/ui/elements.ts +135 -0
  29. package/dist/api/sdks/main/ui/font.ts +168 -0
  30. package/dist/api/sdks/main/ui/hit.ts +46 -0
  31. package/dist/api/sdks/main/ui/index.ts +71 -0
  32. package/dist/api/sdks/main/ui/input.ts +268 -0
  33. package/dist/api/sdks/main/ui/jsx-dev-runtime.ts +16 -0
  34. package/dist/api/sdks/main/ui/jsx-runtime.ts +136 -0
  35. package/dist/api/sdks/main/ui/keymap.ts +147 -0
  36. package/dist/api/sdks/main/ui/layout.ts +178 -0
  37. package/dist/api/sdks/main/ui/paint.ts +196 -0
  38. package/dist/api/sdks/main/ui/reactive.ts +4 -0
  39. package/dist/api/sdks/main/ui/renderer.ts +278 -0
  40. package/dist/api/sdks/main/ui/text.ts +175 -0
  41. package/dist/api/sdks/main/ui/textInput.ts +121 -0
  42. package/dist/api/sdks/main/ui/tree.ts +276 -0
  43. package/dist/api/sdks/main/ui/ui.ts +457 -0
  44. package/dist/api/sdks/main/ui/uiTagHost.ts +56 -0
  45. package/dist/api/sdks/main/ui/uiwindow.ts +330 -0
  46. package/dist/api/shared/build-dependencies.test.ts +1 -1
  47. package/dist/api/shared/build-dependencies.ts +4 -4
  48. package/dist/api/shared/linux-webkit-automation.test.ts +1 -1
  49. package/dist/api/shared/warren/jsx.ts +279 -0
  50. package/dist/api/shared/warren/reactive.ts +638 -0
  51. package/dist/api/shared/windows-unicode-ui.test.ts +4 -4
  52. package/dist/preload-full.js +35 -0
  53. package/dist/zig-sdk/electrobun.zig +197 -162
  54. package/{dash.config.ts → hutch.config.ts} +4 -3
  55. package/package.json +14 -2
package/README.md CHANGED
@@ -25,6 +25,15 @@ curl -fsSL https://hutch.blackboard.sh/hutch/install.sh | sh
25
25
  hutch electrobun init
26
26
  ```
27
27
 
28
+ Or bootstrap the same interactive initializer from npm or Bun. This installs
29
+ Hutch when it is not already available:
30
+
31
+ ```bash
32
+ npx electrobun init
33
+ # or
34
+ bunx electrobun init
35
+ ```
36
+
28
37
  Don't miss our:
29
38
  - self-extracting bundles that use Zstandard compression for compact distributables
30
39
  - a Zig-optimized BSDIFF implementation that can produce kilobyte-scale updates
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { existsSync, mkdtempSync, rmSync, writeFileSync } = require("node:fs");
5
+ const { get } = require("node:https");
6
+ const { homedir, tmpdir } = require("node:os");
7
+ const path = require("node:path");
8
+ const { spawnSync } = require("node:child_process");
9
+
10
+ const packageVersion = require("../package.json").version;
11
+ const installerBaseUrl = "https://hutch.blackboard.sh/hutch";
12
+ const maxInstallerBytes = 1024 * 1024;
13
+
14
+ function normalizeChannel(value) {
15
+ if (value === "stable") return "production";
16
+ if (value === "production" || value === "canary") return value;
17
+ return null;
18
+ }
19
+
20
+ function channelForVersion(version, environment) {
21
+ for (const key of ["ELECTROBUN_HUTCH_CHANNEL", "HUTCH_ACTIVE_CHANNEL"]) {
22
+ const selected = normalizeChannel(environment[key]);
23
+ if (selected) return selected;
24
+ }
25
+ return version.includes("-") ? "canary" : "production";
26
+ }
27
+
28
+ function hutchBinaryPath(channel, environment, platform, userHome) {
29
+ if (environment.ELECTROBUN_HUTCH_BINARY) {
30
+ return environment.ELECTROBUN_HUTCH_BINARY;
31
+ }
32
+ const dashHome = environment.DASH_HOME || path.join(userHome, ".dash");
33
+ const command = channel === "canary" ? "hutch-canary" : "hutch";
34
+ const pathApi = platform === "win32" ? path.win32 : path.posix;
35
+ return pathApi.join(dashHome, "bin", `${command}${platform === "win32" ? ".exe" : ""}`);
36
+ }
37
+
38
+ function download(url, redirects = 0) {
39
+ if (redirects > 5) return Promise.reject(new Error("too many installer redirects"));
40
+ return new Promise((resolve, reject) => {
41
+ const request = get(url, (response) => {
42
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
43
+ response.resume();
44
+ resolve(download(new URL(response.headers.location, url).href, redirects + 1));
45
+ return;
46
+ }
47
+ if (response.statusCode !== 200) {
48
+ response.resume();
49
+ reject(new Error(`installer download returned HTTP ${response.statusCode}`));
50
+ return;
51
+ }
52
+
53
+ const chunks = [];
54
+ let size = 0;
55
+ response.on("data", (chunk) => {
56
+ size += chunk.length;
57
+ if (size > maxInstallerBytes) {
58
+ request.destroy(new Error("installer download exceeded 1 MiB"));
59
+ return;
60
+ }
61
+ chunks.push(chunk);
62
+ });
63
+ response.on("end", () => resolve(Buffer.concat(chunks)));
64
+ });
65
+ request.on("error", reject);
66
+ });
67
+ }
68
+
69
+ function checkedSpawn(command, args, options) {
70
+ const result = spawnSync(command, args, options);
71
+ if (result.error) throw result.error;
72
+ if (result.status !== 0) {
73
+ throw new Error(`${command} exited with status ${result.status ?? "unknown"}`);
74
+ }
75
+ }
76
+
77
+ async function installHutch({ channel, environment, platform }) {
78
+ const temporary = mkdtempSync(path.join(tmpdir(), "electrobun-hutch-"));
79
+ try {
80
+ if (platform === "win32") {
81
+ const installer = path.join(temporary, "install.ps1");
82
+ writeFileSync(installer, await download(`${installerBaseUrl}/install.ps1`));
83
+ checkedSpawn(
84
+ "powershell.exe",
85
+ [
86
+ "-NoProfile",
87
+ "-NonInteractive",
88
+ "-ExecutionPolicy",
89
+ "Bypass",
90
+ "-File",
91
+ installer,
92
+ "-Channel",
93
+ channel,
94
+ ],
95
+ { env: environment, stdio: "inherit" },
96
+ );
97
+ } else {
98
+ const installer = path.join(temporary, "install.sh");
99
+ writeFileSync(installer, await download(`${installerBaseUrl}/install.sh`), {
100
+ mode: 0o700,
101
+ });
102
+ checkedSpawn("sh", [installer, "--channel", channel], {
103
+ env: environment,
104
+ stdio: "inherit",
105
+ });
106
+ }
107
+ } finally {
108
+ rmSync(temporary, { force: true, recursive: true });
109
+ }
110
+ }
111
+
112
+ function runHutch({ binary, args, environment }) {
113
+ const result = spawnSync(binary, ["electrobun", ...args], {
114
+ env: environment,
115
+ stdio: "inherit",
116
+ });
117
+ if (result.error) throw result.error;
118
+ if (result.status !== null) return result.status;
119
+ return result.signal === "SIGINT" ? 130 : result.signal === "SIGTERM" ? 143 : 1;
120
+ }
121
+
122
+ async function main(options = {}) {
123
+ const args = options.args || process.argv.slice(2);
124
+ if (args[0] !== "init") {
125
+ throw new Error(
126
+ "the npm entry point supports only `electrobun init`; use `hutch electrobun` for project commands",
127
+ );
128
+ }
129
+ const environment = options.environment || process.env;
130
+ const platform = options.platform || process.platform;
131
+ const version = options.version || packageVersion;
132
+ const userHome = options.userHome || homedir();
133
+ const fileExists = options.existsSync || existsSync;
134
+ const install = options.installHutch || installHutch;
135
+ const run = options.runHutch || runHutch;
136
+ const channel = channelForVersion(version, environment);
137
+ const binary = hutchBinaryPath(channel, environment, platform, userHome);
138
+
139
+ if (!fileExists(binary)) {
140
+ if (environment.ELECTROBUN_HUTCH_BINARY) {
141
+ throw new Error(`ELECTROBUN_HUTCH_BINARY does not exist: ${binary}`);
142
+ }
143
+ console.error(`Electrobun requires Hutch; installing the latest ${channel} release...`);
144
+ await install({ channel, environment, platform });
145
+ }
146
+ if (!fileExists(binary)) throw new Error(`Hutch was not installed at ${binary}`);
147
+ return run({ binary, args, environment });
148
+ }
149
+
150
+ if (require.main === module) {
151
+ main()
152
+ .then((status) => {
153
+ process.exitCode = status;
154
+ })
155
+ .catch((error) => {
156
+ console.error(`electrobun: ${error.message}`);
157
+ process.exitCode = 1;
158
+ });
159
+ }
160
+
161
+ module.exports = {
162
+ channelForVersion,
163
+ hutchBinaryPath,
164
+ main,
165
+ };
@@ -0,0 +1,473 @@
1
+ // Warren DOM renderer against the stub DOM: structure, explicit reactivity,
2
+ // control flow with keyed reconciliation, events, Portal, and teardown.
3
+
4
+ import { describe, expect, test } from "bun:test";
5
+ import { live, memo, setDevMode, signal, store } from "../../../shared/warren/reactive";
6
+ import {
7
+ For,
8
+ Fragment,
9
+ Match,
10
+ Portal,
11
+ Show,
12
+ Switch,
13
+ jsx,
14
+ render,
15
+ } from "../dom";
16
+ import { createStubRoot, StubComment, StubElement, StubText } from "./domStub";
17
+
18
+ function mount(app: () => unknown): {
19
+ container: StubElement;
20
+ document: ReturnType<typeof createStubRoot>["document"];
21
+ dispose: () => void;
22
+ } {
23
+ const { document, container } = createStubRoot();
24
+ const dispose = render(app as any, container as any);
25
+ return { container, document, dispose };
26
+ }
27
+
28
+ describe("DOM renderer", () => {
29
+ test("mounts an intrinsic tree with attributes, class, and style", () => {
30
+ const { container } = mount(() =>
31
+ jsx("div", {
32
+ id: "app",
33
+ class: "shell dark",
34
+ style: { color: "red", "--accent": "#fff" },
35
+ children: [
36
+ jsx("span", { children: "hello" }),
37
+ jsx("input", { type: "text", value: "abc", disabled: true }),
38
+ ],
39
+ }),
40
+ );
41
+ const [div] = container.children;
42
+ expect(div!.tagName).toBe("div");
43
+ expect(div!.getAttribute("id")).toBe("app");
44
+ expect(div!.className).toBe("shell dark");
45
+ expect(div!.style.color).toBe("red");
46
+ expect((div!.style as any)["--accent"]).toBe("#fff");
47
+ const [span, input] = div!.children;
48
+ expect(span!.textContent).toBe("hello");
49
+ expect(input!.value).toBe("abc"); // property, not attribute
50
+ expect(input!.hasAttribute("value")).toBe(false);
51
+ expect(input!.getAttribute("disabled")).toBe(""); // boolean attribute
52
+ });
53
+
54
+ test("static text and live text children", () => {
55
+ const [count, setCount] = signal(0);
56
+ const { container } = mount(() =>
57
+ jsx("p", { children: [live(() => `n=${count()}`), " (static)"] }),
58
+ );
59
+ const [p] = container.children;
60
+ expect(p!.textContent).toBe("n=0 (static)");
61
+ setCount(7);
62
+ expect(p!.textContent).toBe("n=7 (static)");
63
+ });
64
+
65
+ test("live props update in place; element identity is stable", () => {
66
+ const [cls, setCls] = signal("a");
67
+ const { container } = mount(() =>
68
+ jsx("div", { class: live(cls), title: live(() => cls().toUpperCase()) }),
69
+ );
70
+ const [div] = container.children;
71
+ expect(div!.className).toBe("a");
72
+ expect(div!.getAttribute("title")).toBe("A");
73
+ setCls("b");
74
+ expect(container.children[0]).toBe(div!);
75
+ expect(div!.className).toBe("b");
76
+ expect(div!.getAttribute("title")).toBe("B");
77
+ });
78
+
79
+ test("classList toggles classes from an object", () => {
80
+ const [active, setActive] = signal(false);
81
+ const { container } = mount(() =>
82
+ jsx("div", {
83
+ class: "base",
84
+ classList: live(() => ({ active: active(), muted: !active() })),
85
+ }),
86
+ );
87
+ const [div] = container.children;
88
+ expect(div!.classList.contains("base")).toBe(true);
89
+ expect(div!.classList.contains("muted")).toBe(true);
90
+ expect(div!.classList.contains("active")).toBe(false);
91
+ setActive(true);
92
+ expect(div!.classList.contains("active")).toBe(true);
93
+ expect(div!.classList.contains("muted")).toBe(false);
94
+ });
95
+
96
+ test("bare functions in value props throw loudly", () => {
97
+ const [x] = signal(1);
98
+ expect(() => mount(() => jsx("div", { title: x as any }))).toThrow(
99
+ /live\(/,
100
+ );
101
+ });
102
+
103
+ test("event handlers attach; ref sees the element", () => {
104
+ let clicks = 0;
105
+ let reffed: unknown = null;
106
+ const { container } = mount(() =>
107
+ jsx("button", {
108
+ ref: (el: unknown) => {
109
+ reffed = el;
110
+ },
111
+ onClick: () => clicks++,
112
+ children: "go",
113
+ }),
114
+ );
115
+ const [button] = container.children;
116
+ expect(reffed).toBe(button!);
117
+ button!.dispatch("click");
118
+ expect(clicks).toBe(1);
119
+ });
120
+
121
+ test("components run once; nested structure mounts", () => {
122
+ let calls = 0;
123
+ function Badge(props: Record<string, unknown>) {
124
+ calls++;
125
+ return jsx("em", { children: props.label as string });
126
+ }
127
+ const { container } = mount(() =>
128
+ jsx("div", {
129
+ children: [jsx(Badge, { label: "one" }), jsx(Badge, { label: "two" })],
130
+ }),
131
+ );
132
+ expect(calls).toBe(2);
133
+ expect(container.children[0]!.textContent).toBe("onetwo");
134
+ });
135
+
136
+ test("Show with live when swaps content and fallback", () => {
137
+ const [on, setOn] = signal(false);
138
+ const { container } = mount(() =>
139
+ jsx("div", {
140
+ children: Show({
141
+ when: live(on),
142
+ fallback: jsx("span", { class: "off", children: "off" }),
143
+ children: jsx("span", { class: "on", children: "on" }),
144
+ }),
145
+ }),
146
+ );
147
+ const [div] = container.children;
148
+ expect(div!.children[0]!.className).toBe("off");
149
+ setOn(true);
150
+ expect(div!.children[0]!.className).toBe("on");
151
+ setOn(false);
152
+ expect(div!.children[0]!.className).toBe("off");
153
+ });
154
+
155
+ test("Show with a plain value is a frozen snapshot", () => {
156
+ const [on, setOn] = signal(false);
157
+ const { container } = mount(() =>
158
+ jsx("div", {
159
+ children: Show({
160
+ when: on(), // evaluated once, not reactive
161
+ fallback: jsx("span", { children: "frozen-off" }),
162
+ children: jsx("span", { children: "frozen-on" }),
163
+ }),
164
+ }),
165
+ );
166
+ setOn(true);
167
+ expect(container.children[0]!.textContent).toBe("frozen-off");
168
+ });
169
+
170
+ test("For reconciles keyed rows: identity preserved across reorder", () => {
171
+ const [state, setState] = store({ items: ["a", "b", "c"] });
172
+ const { container } = mount(() =>
173
+ jsx("ul", {
174
+ children: For({
175
+ each: live(() => state.items.slice()),
176
+ key: (item: string) => item,
177
+ children: (item: string) => jsx("li", { children: item }),
178
+ }),
179
+ }),
180
+ );
181
+ const [ul] = container.children;
182
+ expect(ul!.children.map((li) => li.textContent)).toEqual(["a", "b", "c"]);
183
+ const [liA, liB, liC] = ul!.children;
184
+
185
+ setState((s) => s.items.reverse());
186
+ expect(ul!.children.map((li) => li.textContent)).toEqual(["c", "b", "a"]);
187
+ // Same DOM nodes, moved — not rebuilt.
188
+ expect(ul!.children[0]).toBe(liC!);
189
+ expect(ul!.children[1]).toBe(liB!);
190
+ expect(ul!.children[2]).toBe(liA!);
191
+ });
192
+
193
+ test("For adds and removes rows; fallback flips on empty", () => {
194
+ const [state, setState] = store({ items: ["x"] });
195
+ const { container } = mount(() =>
196
+ jsx("div", {
197
+ children: For({
198
+ each: live(() => state.items.slice()),
199
+ key: (item: string) => item,
200
+ fallback: jsx("i", { children: "empty" }),
201
+ children: (item: string) => jsx("b", { children: item }),
202
+ }),
203
+ }),
204
+ );
205
+ const [div] = container.children;
206
+ expect(div!.children[0]!.tagName).toBe("b");
207
+ setState((s) => {
208
+ s.items.length = 0;
209
+ });
210
+ expect(div!.children[0]!.tagName).toBe("i");
211
+ setState((s) => s.items.push("y", "z"));
212
+ expect(div!.children.map((el) => el.textContent)).toEqual(["y", "z"]);
213
+ });
214
+
215
+ test("For row index accessor is reactive", () => {
216
+ const [state, setState] = store({ items: ["a", "b"] });
217
+ const { container } = mount(() =>
218
+ jsx("div", {
219
+ children: For({
220
+ each: live(() => state.items.slice()),
221
+ key: (item: string) => item,
222
+ children: (item: string, index) =>
223
+ jsx("span", { children: live(() => `${item}${index()}`) }),
224
+ }),
225
+ }),
226
+ );
227
+ const [div] = container.children;
228
+ expect(div!.textContent).toBe("a0b1");
229
+ setState((s) => s.items.reverse());
230
+ expect(div!.textContent).toBe("b0a1");
231
+ });
232
+
233
+ test("JSX key attribute reaches For via the transform's third argument", () => {
234
+ // <For key={fn}> transpiles to jsx(For, props, fn) — key must land
235
+ // back in props or reconciliation silently degrades to item identity.
236
+ const [state, setState] = store({ items: [{ n: 1 }, { n: 2 }] });
237
+ const { container } = mount(() =>
238
+ jsx("div", {
239
+ children: jsx(
240
+ For as any,
241
+ {
242
+ each: live(() => state.items.map((it) => ({ ...it }))),
243
+ children: (item: { n: number }) =>
244
+ jsx("span", { children: String(item.n) }),
245
+ },
246
+ (item: { n: number }) => item.n, // key as third arg
247
+ ),
248
+ }),
249
+ );
250
+ const [div] = container.children;
251
+ const [span1] = div!.children;
252
+ setState((s) => s.items.reverse());
253
+ // Fresh objects each read: only a working key preserves identity.
254
+ expect(div!.children[1]).toBe(span1!);
255
+ });
256
+
257
+ test("Switch/Match picks the first live truthy branch", () => {
258
+ const [tab, setTab] = signal("home");
259
+ const { container } = mount(() =>
260
+ jsx("div", {
261
+ children: Switch({
262
+ fallback: jsx("span", { children: "none" }),
263
+ children: [
264
+ Match({
265
+ when: live(() => tab() === "home"),
266
+ children: jsx("span", { children: "home" }),
267
+ }),
268
+ Match({
269
+ when: live(() => tab() === "settings"),
270
+ children: jsx("span", { children: "settings" }),
271
+ }),
272
+ ],
273
+ }),
274
+ }),
275
+ );
276
+ const [div] = container.children;
277
+ expect(div!.textContent).toBe("home");
278
+ setTab("settings");
279
+ expect(div!.textContent).toBe("settings");
280
+ setTab("nope");
281
+ expect(div!.textContent).toBe("none");
282
+ });
283
+
284
+ test("memo feeds live bindings glitch-free", () => {
285
+ const [n, setN] = signal(2);
286
+ const double = memo(() => n() * 2);
287
+ const { container } = mount(() =>
288
+ jsx("output", { children: live(() => `${n()}:${double()}`) }),
289
+ );
290
+ const [output] = container.children;
291
+ expect(output!.textContent).toBe("2:4");
292
+ setN(5);
293
+ expect(output!.textContent).toBe("5:10");
294
+ });
295
+
296
+ test("live element children become dynamic regions (cond && <el/>)", () => {
297
+ const [open, setOpen] = signal(false);
298
+ const { container } = mount(() =>
299
+ jsx("div", {
300
+ children: live(() =>
301
+ open() ? jsx("span", { class: "panel", children: "open" }) : null,
302
+ ),
303
+ }),
304
+ );
305
+ const [div] = container.children;
306
+ expect(div!.children.length).toBe(0);
307
+ setOpen(true);
308
+ expect(div!.children[0]!.className).toBe("panel");
309
+ setOpen(false);
310
+ expect(div!.children.length).toBe(0);
311
+ });
312
+
313
+ test("live text children stay fine-grained text nodes", () => {
314
+ const [n, setN] = signal(1);
315
+ const { container } = mount(() =>
316
+ jsx("div", { children: live(() => `v${n()}`) }),
317
+ );
318
+ const [div] = container.children;
319
+ const textNode = div!.childNodes.find((c) => c instanceof StubText);
320
+ setN(2);
321
+ // Same text node updated in place — not a rebuilt region.
322
+ expect(div!.childNodes.find((c) => c instanceof StubText)).toBe(textNode!);
323
+ expect(div!.textContent).toBe("v2");
324
+ });
325
+
326
+ test("components created inside live regions keep inert bodies and deferred lives", () => {
327
+ const [open, setOpen] = signal(true);
328
+ const [label, setLabel] = signal("a");
329
+ let regionBuilds = 0;
330
+ let effectRuns = 0;
331
+ function Panel() {
332
+ // Statement live in a body mounted from a live region: must be
333
+ // deferred (declared-later variable is fine) and must NOT leak
334
+ // its reads into the region's dependencies.
335
+ live(() => {
336
+ effectRuns++;
337
+ laterDeclared();
338
+ });
339
+ const laterDeclared = () => label();
340
+ return jsx("span", { class: live(label), children: "panel" });
341
+ }
342
+ const { container } = mount(() =>
343
+ jsx("div", {
344
+ children: live(() => {
345
+ regionBuilds++;
346
+ return open() ? jsx(Panel, {}) : null;
347
+ }),
348
+ }),
349
+ );
350
+ const [div] = container.children;
351
+ expect(div!.children[0]!.className).toBe("a");
352
+ expect(effectRuns).toBe(1);
353
+ expect(regionBuilds).toBe(1);
354
+ // Inner signal change: value binding + effect update, region untouched.
355
+ setLabel("b");
356
+ expect(div!.children[0]!.className).toBe("b");
357
+ expect(effectRuns).toBe(2);
358
+ expect(regionBuilds).toBe(1);
359
+ // Region's own dependency still reconciles it.
360
+ setOpen(false);
361
+ expect(div!.children.length).toBe(0);
362
+ expect(regionBuilds).toBe(2);
363
+ });
364
+
365
+ test("Fragment and array children mount in order", () => {
366
+ const { container } = mount(() =>
367
+ Fragment({
368
+ children: [
369
+ jsx("i", { children: "1" }),
370
+ "mid",
371
+ jsx("b", { children: "2" }),
372
+ ],
373
+ }),
374
+ );
375
+ expect(container.textContent).toBe("1mid2");
376
+ });
377
+
378
+ test("bare function children run as escapes; returned elements mount", () => {
379
+ const { container } = mount(() =>
380
+ jsx("div", {
381
+ children: [() => jsx("span", { children: "escaped" }), () => {}],
382
+ }),
383
+ );
384
+ expect(container.children[0]!.textContent).toBe("escaped");
385
+ });
386
+
387
+ test("Portal renders into document.body and cleans up with its scope", () => {
388
+ const [open, setOpen] = signal(true);
389
+ const { container, document } = mount(() =>
390
+ jsx("div", {
391
+ children: Show({
392
+ when: live(open),
393
+ children: Portal({
394
+ children: jsx("dialog", { children: "modal" }),
395
+ }),
396
+ }),
397
+ }),
398
+ );
399
+ expect(container.textContent).toBe("");
400
+ expect(
401
+ document.body.children.some((el) => el.tagName === "dialog"),
402
+ ).toBe(true);
403
+ setOpen(false);
404
+ expect(
405
+ document.body.children.some((el) => el.tagName === "dialog"),
406
+ ).toBe(false);
407
+ setOpen(true);
408
+ expect(
409
+ document.body.children.some((el) => el.tagName === "dialog"),
410
+ ).toBe(true);
411
+ });
412
+
413
+ test("svg subtrees use the SVG namespace; class works via attribute", () => {
414
+ const { container } = mount(() =>
415
+ jsx("svg", {
416
+ viewBox: "0 0 10 10",
417
+ class: "icon",
418
+ children: jsx("path", { d: "M0 0L10 10" }),
419
+ }),
420
+ );
421
+ const [svg] = container.children;
422
+ expect(svg!.namespaceURI).toBe("http://www.w3.org/2000/svg");
423
+ expect(svg!.getAttribute("viewBox")).toBe("0 0 10 10");
424
+ expect(svg!.getAttribute("class")).toBe("icon");
425
+ expect(svg!.children[0]!.namespaceURI).toBe(
426
+ "http://www.w3.org/2000/svg",
427
+ );
428
+ });
429
+
430
+ test("dispose removes everything Warren created and stops updates", () => {
431
+ const [n, setN] = signal(0);
432
+ const { container, dispose } = mount(() =>
433
+ jsx("div", { children: live(() => `n=${n()}`) }),
434
+ );
435
+ expect(container.children.length).toBe(1);
436
+ dispose();
437
+ expect(container.childNodes.length).toBe(0);
438
+ // Writes after dispose must not throw or resurrect nodes.
439
+ setN(1);
440
+ expect(container.childNodes.length).toBe(0);
441
+ });
442
+
443
+ test("dynamic region rebuild tears down row state (no leaked comments)", () => {
444
+ setDevMode(false);
445
+ try {
446
+ const [state, setState] = store({ items: ["a", "b", "c"] });
447
+ const { container } = mount(() =>
448
+ jsx("div", {
449
+ children: For({
450
+ each: live(() => state.items.slice()),
451
+ key: (item: string) => item,
452
+ children: (item: string) => jsx("span", { children: item }),
453
+ }),
454
+ }),
455
+ );
456
+ const [div] = container.children;
457
+ const countComments = () =>
458
+ div!.childNodes.filter((n) => n instanceof StubComment).length;
459
+ const before = countComments();
460
+ // Shrinking to one row removes the other rows' anchors too.
461
+ setState((s) => {
462
+ s.items = ["b"];
463
+ });
464
+ expect(div!.textContent).toBe("b");
465
+ expect(countComments()).toBeLessThan(before);
466
+ // Text nodes from removed rows are gone.
467
+ const texts = div!.childNodes.filter((n) => n instanceof StubText);
468
+ expect(texts.length).toBe(0); // row text lives inside spans
469
+ } finally {
470
+ setDevMode(true);
471
+ }
472
+ });
473
+ });