electrobun 1.18.4-beta.19 → 1.18.4-beta.25
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/bin/electrobun.cjs +21 -5
- package/dist/api/browser/ui/__tests__/dom.test.ts +473 -0
- package/dist/api/browser/ui/__tests__/domStub.ts +218 -0
- package/dist/api/browser/ui/dom.ts +490 -0
- package/dist/api/browser/ui/index.ts +44 -0
- package/dist/api/browser/ui/jsx-dev-runtime.ts +16 -0
- package/dist/api/browser/ui/jsx-runtime.ts +56 -0
- package/dist/api/config/ElectrobunConfig.ts +33 -0
- package/dist/api/preload/.generated/compiled.ts +1 -1
- package/dist/api/preload/index.ts +2 -0
- package/dist/api/preload/uiTag.ts +45 -0
- package/dist/api/sdks/main/__tests__/utils-quit-exit-code.test.ts +44 -0
- package/dist/api/sdks/main/core/GpuWindow.ts +19 -0
- package/dist/api/sdks/main/core/Utils.ts +52 -4
- package/dist/api/sdks/main/core/WGPUView.ts +9 -0
- package/dist/api/sdks/main/entries/ui.ts +1 -0
- package/dist/api/sdks/main/proc/native.ts +187 -0
- package/dist/api/sdks/main/ui/__tests__/font.test.ts +49 -0
- package/dist/api/sdks/main/ui/__tests__/hit.test.ts +64 -0
- package/dist/api/sdks/main/ui/__tests__/jsx.test.ts +252 -0
- package/dist/api/sdks/main/ui/__tests__/layout.test.ts +159 -0
- package/dist/api/sdks/main/ui/__tests__/paint.test.ts +115 -0
- package/dist/api/sdks/main/ui/__tests__/reactive.test.ts +456 -0
- package/dist/api/sdks/main/ui/__tests__/scroll-focus-input.test.ts +298 -0
- package/dist/api/sdks/main/ui/__tests__/tree.test.ts +96 -0
- package/dist/api/sdks/main/ui/__tests__/ui.test.ts +170 -0
- package/dist/api/sdks/main/ui/elements.ts +135 -0
- package/dist/api/sdks/main/ui/font.ts +168 -0
- package/dist/api/sdks/main/ui/hit.ts +46 -0
- package/dist/api/sdks/main/ui/index.ts +71 -0
- package/dist/api/sdks/main/ui/input.ts +268 -0
- package/dist/api/sdks/main/ui/jsx-dev-runtime.ts +16 -0
- package/dist/api/sdks/main/ui/jsx-runtime.ts +136 -0
- package/dist/api/sdks/main/ui/keymap.ts +147 -0
- package/dist/api/sdks/main/ui/layout.ts +178 -0
- package/dist/api/sdks/main/ui/paint.ts +196 -0
- package/dist/api/sdks/main/ui/reactive.ts +4 -0
- package/dist/api/sdks/main/ui/renderer.ts +278 -0
- package/dist/api/sdks/main/ui/text.ts +175 -0
- package/dist/api/sdks/main/ui/textInput.ts +121 -0
- package/dist/api/sdks/main/ui/tree.ts +276 -0
- package/dist/api/sdks/main/ui/ui.ts +457 -0
- package/dist/api/sdks/main/ui/uiTagHost.ts +56 -0
- package/dist/api/sdks/main/ui/uiwindow.ts +330 -0
- package/dist/api/shared/build-dependencies.test.ts +1 -1
- package/dist/api/shared/build-dependencies.ts +4 -4
- package/dist/api/shared/linux-webkit-automation.test.ts +1 -1
- package/dist/api/shared/warren/jsx.ts +279 -0
- package/dist/api/shared/warren/reactive.ts +638 -0
- package/dist/api/shared/windows-unicode-ui.test.ts +4 -4
- package/dist/preload-full.js +35 -0
- package/dist/zig-sdk/electrobun.zig +197 -162
- package/{dash.config.ts → hutch.config.ts} +3 -2
- package/package.json +11 -2
package/bin/electrobun.cjs
CHANGED
|
@@ -17,12 +17,22 @@ function normalizeChannel(value) {
|
|
|
17
17
|
return null;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// Electrobun (beta or stable) always runs on STABLE Hutch. Only an explicit
|
|
21
|
+
// ELECTROBUN_HUTCH_CHANNEL / HUTCH_ACTIVE_CHANNEL override changes that (e.g. to
|
|
22
|
+
// test against a hutch-canary build) — the electrobun version never does.
|
|
23
|
+
function hutchChannel(environment) {
|
|
21
24
|
for (const key of ["ELECTROBUN_HUTCH_CHANNEL", "HUTCH_ACTIVE_CHANNEL"]) {
|
|
22
25
|
const selected = normalizeChannel(environment[key]);
|
|
23
26
|
if (selected) return selected;
|
|
24
27
|
}
|
|
25
|
-
return
|
|
28
|
+
return "production";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The Electrobun template channel: "beta" for prerelease builds or an explicit
|
|
32
|
+
// --beta, else "stable". Forwarded to `hutch electrobun` as the --beta flag.
|
|
33
|
+
function electrobunChannel(version, args) {
|
|
34
|
+
if (Array.isArray(args) && args.includes("--beta")) return "beta";
|
|
35
|
+
return version.includes("-") ? "beta" : "stable";
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
function hutchBinaryPath(channel, environment, platform, userHome) {
|
|
@@ -133,9 +143,14 @@ async function main(options = {}) {
|
|
|
133
143
|
const fileExists = options.existsSync || existsSync;
|
|
134
144
|
const install = options.installHutch || installHutch;
|
|
135
145
|
const run = options.runHutch || runHutch;
|
|
136
|
-
const channel =
|
|
146
|
+
const channel = hutchChannel(environment);
|
|
137
147
|
const binary = hutchBinaryPath(channel, environment, platform, userHome);
|
|
138
148
|
|
|
149
|
+
// Forward the electrobun template channel as --beta to `hutch electrobun`.
|
|
150
|
+
const templateChannel = electrobunChannel(version, args);
|
|
151
|
+
const hutchArgs =
|
|
152
|
+
templateChannel === "beta" && !args.includes("--beta") ? [...args, "--beta"] : args;
|
|
153
|
+
|
|
139
154
|
if (!fileExists(binary)) {
|
|
140
155
|
if (environment.ELECTROBUN_HUTCH_BINARY) {
|
|
141
156
|
throw new Error(`ELECTROBUN_HUTCH_BINARY does not exist: ${binary}`);
|
|
@@ -144,7 +159,7 @@ async function main(options = {}) {
|
|
|
144
159
|
await install({ channel, environment, platform });
|
|
145
160
|
}
|
|
146
161
|
if (!fileExists(binary)) throw new Error(`Hutch was not installed at ${binary}`);
|
|
147
|
-
return run({ binary, args, environment });
|
|
162
|
+
return run({ binary, args: hutchArgs, environment });
|
|
148
163
|
}
|
|
149
164
|
|
|
150
165
|
if (require.main === module) {
|
|
@@ -159,7 +174,8 @@ if (require.main === module) {
|
|
|
159
174
|
}
|
|
160
175
|
|
|
161
176
|
module.exports = {
|
|
162
|
-
|
|
177
|
+
hutchChannel,
|
|
178
|
+
electrobunChannel,
|
|
163
179
|
hutchBinaryPath,
|
|
164
180
|
main,
|
|
165
181
|
};
|
|
@@ -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
|
+
});
|