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.
Files changed (54) hide show
  1. package/bin/electrobun.cjs +21 -5
  2. package/dist/api/browser/ui/__tests__/dom.test.ts +473 -0
  3. package/dist/api/browser/ui/__tests__/domStub.ts +218 -0
  4. package/dist/api/browser/ui/dom.ts +490 -0
  5. package/dist/api/browser/ui/index.ts +44 -0
  6. package/dist/api/browser/ui/jsx-dev-runtime.ts +16 -0
  7. package/dist/api/browser/ui/jsx-runtime.ts +56 -0
  8. package/dist/api/config/ElectrobunConfig.ts +33 -0
  9. package/dist/api/preload/.generated/compiled.ts +1 -1
  10. package/dist/api/preload/index.ts +2 -0
  11. package/dist/api/preload/uiTag.ts +45 -0
  12. package/dist/api/sdks/main/__tests__/utils-quit-exit-code.test.ts +44 -0
  13. package/dist/api/sdks/main/core/GpuWindow.ts +19 -0
  14. package/dist/api/sdks/main/core/Utils.ts +52 -4
  15. package/dist/api/sdks/main/core/WGPUView.ts +9 -0
  16. package/dist/api/sdks/main/entries/ui.ts +1 -0
  17. package/dist/api/sdks/main/proc/native.ts +187 -0
  18. package/dist/api/sdks/main/ui/__tests__/font.test.ts +49 -0
  19. package/dist/api/sdks/main/ui/__tests__/hit.test.ts +64 -0
  20. package/dist/api/sdks/main/ui/__tests__/jsx.test.ts +252 -0
  21. package/dist/api/sdks/main/ui/__tests__/layout.test.ts +159 -0
  22. package/dist/api/sdks/main/ui/__tests__/paint.test.ts +115 -0
  23. package/dist/api/sdks/main/ui/__tests__/reactive.test.ts +456 -0
  24. package/dist/api/sdks/main/ui/__tests__/scroll-focus-input.test.ts +298 -0
  25. package/dist/api/sdks/main/ui/__tests__/tree.test.ts +96 -0
  26. package/dist/api/sdks/main/ui/__tests__/ui.test.ts +170 -0
  27. package/dist/api/sdks/main/ui/elements.ts +135 -0
  28. package/dist/api/sdks/main/ui/font.ts +168 -0
  29. package/dist/api/sdks/main/ui/hit.ts +46 -0
  30. package/dist/api/sdks/main/ui/index.ts +71 -0
  31. package/dist/api/sdks/main/ui/input.ts +268 -0
  32. package/dist/api/sdks/main/ui/jsx-dev-runtime.ts +16 -0
  33. package/dist/api/sdks/main/ui/jsx-runtime.ts +136 -0
  34. package/dist/api/sdks/main/ui/keymap.ts +147 -0
  35. package/dist/api/sdks/main/ui/layout.ts +178 -0
  36. package/dist/api/sdks/main/ui/paint.ts +196 -0
  37. package/dist/api/sdks/main/ui/reactive.ts +4 -0
  38. package/dist/api/sdks/main/ui/renderer.ts +278 -0
  39. package/dist/api/sdks/main/ui/text.ts +175 -0
  40. package/dist/api/sdks/main/ui/textInput.ts +121 -0
  41. package/dist/api/sdks/main/ui/tree.ts +276 -0
  42. package/dist/api/sdks/main/ui/ui.ts +457 -0
  43. package/dist/api/sdks/main/ui/uiTagHost.ts +56 -0
  44. package/dist/api/sdks/main/ui/uiwindow.ts +330 -0
  45. package/dist/api/shared/build-dependencies.test.ts +1 -1
  46. package/dist/api/shared/build-dependencies.ts +4 -4
  47. package/dist/api/shared/linux-webkit-automation.test.ts +1 -1
  48. package/dist/api/shared/warren/jsx.ts +279 -0
  49. package/dist/api/shared/warren/reactive.ts +638 -0
  50. package/dist/api/shared/windows-unicode-ui.test.ts +4 -4
  51. package/dist/preload-full.js +35 -0
  52. package/dist/zig-sdk/electrobun.zig +197 -162
  53. package/{dash.config.ts → hutch.config.ts} +3 -2
  54. package/package.json +11 -2
@@ -17,12 +17,22 @@ function normalizeChannel(value) {
17
17
  return null;
18
18
  }
19
19
 
20
- function channelForVersion(version, environment) {
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 version.includes("-") ? "canary" : "production";
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 = channelForVersion(version, environment);
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
- channelForVersion,
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
+ });