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
@@ -0,0 +1,456 @@
1
+ // Warren reactivity — the spec's test checklist.
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import {
5
+ batch,
6
+ cleanup,
7
+ commit,
8
+ createRoot,
9
+ inert,
10
+ isLive,
11
+ live,
12
+ memo,
13
+ setDevMode,
14
+ signal,
15
+ store,
16
+ type LiveBinding,
17
+ } from "../reactive";
18
+
19
+ function effect(fn: () => void): void {
20
+ // An effect is a live whose return value nobody uses.
21
+ live(fn);
22
+ }
23
+
24
+ function withWarnings<T>(fn: () => T): { result: T; warnings: string[] } {
25
+ const warnings: string[] = [];
26
+ const original = console.warn;
27
+ console.warn = (msg: unknown) => warnings.push(String(msg));
28
+ try {
29
+ return { result: fn(), warnings };
30
+ } finally {
31
+ console.warn = original;
32
+ }
33
+ }
34
+
35
+ describe("tracking tiers", () => {
36
+ test("signal call inside live subscribes; outside any scope it does not", () => {
37
+ const [count, setCount] = signal(0);
38
+ const seen: number[] = [];
39
+ createRoot(() => {
40
+ effect(() => seen.push(count()));
41
+ });
42
+ expect(seen).toEqual([0]);
43
+ setCount(1);
44
+ expect(seen).toEqual([0, 1]);
45
+
46
+ const snapshot = count();
47
+ expect(snapshot).toBe(1);
48
+ setCount(2);
49
+ expect(seen).toEqual([0, 1, 2]); // only the live re-ran; no phantom subs
50
+ });
51
+
52
+ test("property read inside live subscribes; component-body read does not", () => {
53
+ const [state, setState] = store({ user: { name: "ada" } });
54
+ const seen: string[] = [];
55
+ let bodySnapshot = "";
56
+ createRoot(() => {
57
+ bodySnapshot = state.user.name; // inert: body is not a scope
58
+ effect(() => seen.push(state.user.name)); // scope: tracks
59
+ });
60
+ expect(bodySnapshot).toBe("ada");
61
+ expect(seen).toEqual(["ada"]);
62
+ setState((s) => (s.user.name = "grace"));
63
+ expect(seen).toEqual(["ada", "grace"]);
64
+ });
65
+
66
+ test("dynamic extent: helper property reads track inside live", () => {
67
+ const [state, setState] = store({ user: { name: "ada", plan: "pro" } });
68
+ const label = (u: { name: string; plan: string }) => `${u.name} · ${u.plan}`;
69
+ const seen: string[] = [];
70
+ createRoot(() => {
71
+ effect(() => seen.push(label(state.user)));
72
+ });
73
+ expect(seen).toEqual(["ada · pro"]);
74
+ setState((s) => (s.user.plan = "free"));
75
+ expect(seen).toEqual(["ada · pro", "ada · free"]);
76
+ setState((s) => (s.user.name = "grace"));
77
+ expect(seen).toEqual(["ada · pro", "ada · free", "grace · free"]);
78
+ });
79
+
80
+ test("inert inside a scope suppresses that read only", () => {
81
+ const [state, setState] = store({ user: { name: "ada", plan: "pro" } });
82
+ const seen: string[] = [];
83
+ createRoot(() => {
84
+ effect(() => {
85
+ const plan = inert(() => state.user.plan);
86
+ seen.push(`${state.user.name} · ${plan}`);
87
+ });
88
+ });
89
+ expect(seen).toEqual(["ada · pro"]);
90
+ setState((s) => (s.user.plan = "free")); // not subscribed
91
+ expect(seen).toEqual(["ada · pro"]);
92
+ setState((s) => (s.user.name = "grace")); // subscribed
93
+ expect(seen).toEqual(["ada · pro", "grace · free"]);
94
+ });
95
+
96
+ test("inert outside a scope is a no-op", () => {
97
+ const [count] = signal(5);
98
+ expect(inert(count)).toBe(5);
99
+ });
100
+ });
101
+
102
+ describe("scopes", () => {
103
+ test("live is deferred: runs after commit, then re-runs on change", () => {
104
+ const [count, setCount] = signal(0);
105
+ const runs: number[] = [];
106
+ createRoot(() => {
107
+ effect(() => runs.push(count()));
108
+ expect(runs).toEqual([]); // not yet — commit hasn't exited
109
+ });
110
+ expect(runs).toEqual([0]);
111
+ setCount(1);
112
+ expect(runs).toEqual([0, 1]);
113
+ });
114
+
115
+ test("memo caches and recomputes on dependency change", () => {
116
+ const [count, setCount] = signal(2);
117
+ let computes = 0;
118
+ let doubled: () => number = () => 0;
119
+ createRoot(() => {
120
+ doubled = memo(() => {
121
+ computes++;
122
+ return count() * 2;
123
+ });
124
+ });
125
+ expect(doubled()).toBe(4);
126
+ expect(doubled()).toBe(4);
127
+ expect(computes).toBe(1);
128
+ setCount(3);
129
+ expect(doubled()).toBe(6);
130
+ expect(computes).toBe(2);
131
+ });
132
+
133
+ test("a live reading a memo never observes a stale value (no glitch)", () => {
134
+ const [count, setCount] = signal(1);
135
+ const observed: Array<[number, number]> = [];
136
+ createRoot(() => {
137
+ const doubled = memo(() => count() * 2);
138
+ effect(() => observed.push([count(), doubled()]));
139
+ });
140
+ setCount(2);
141
+ setCount(3);
142
+ for (const [c, d] of observed) {
143
+ expect(d).toBe(c * 2); // consistent at every observation
144
+ }
145
+ expect(observed.length).toBe(3);
146
+ });
147
+
148
+ test("memo equals cut stops propagation: dependents skip equal values", () => {
149
+ const [items, setItems] = signal<string[]>(["a", "b"]);
150
+ let runs = 0;
151
+ createRoot(() => {
152
+ const empty = memo(() => items().length === 0);
153
+ effect(() => {
154
+ empty();
155
+ runs++;
156
+ });
157
+ });
158
+ expect(runs).toBe(1);
159
+ setItems(["b", "a"]); // memo recomputes, value unchanged -> no re-run
160
+ expect(runs).toBe(1);
161
+ setItems([]); // value flips -> dependent re-runs
162
+ expect(runs).toBe(2);
163
+ });
164
+
165
+ test("cleanup runs before every re-run, not only at disposal", () => {
166
+ const [count, setCount] = signal(0);
167
+ const events: string[] = [];
168
+ createRoot(() => {
169
+ effect(() => {
170
+ const value = count();
171
+ cleanup(() => events.push(`cleanup ${value}`));
172
+ });
173
+ });
174
+ expect(events).toEqual([]);
175
+ setCount(1);
176
+ expect(events).toEqual(["cleanup 0"]);
177
+ });
178
+
179
+ test("nested scopes dispose with their parent", () => {
180
+ const [outer, setOuter] = signal(0);
181
+ const [inner, setInner] = signal(0);
182
+ let innerRuns = 0;
183
+ createRoot(() => {
184
+ effect(() => {
185
+ outer();
186
+ effect(() => {
187
+ inner();
188
+ innerRuns++;
189
+ });
190
+ });
191
+ });
192
+ expect(innerRuns).toBe(1);
193
+ setOuter(1); // outer re-runs; previous inner scope disposed
194
+ expect(innerRuns).toBe(2);
195
+ setInner(1); // only the current inner scope re-runs
196
+ expect(innerRuns).toBe(3);
197
+ });
198
+
199
+ test("body-level lives dispose with the root", () => {
200
+ const [count, setCount] = signal(0);
201
+ let runs = 0;
202
+ createRoot((dispose) => {
203
+ effect(() => {
204
+ count();
205
+ runs++;
206
+ });
207
+ dispose();
208
+ });
209
+ setCount(1);
210
+ expect(runs).toBe(0); // disposed before its first (deferred) run
211
+ });
212
+ });
213
+
214
+ describe("state", () => {
215
+ test("store proxy throws on write (in production too)", () => {
216
+ const [state] = store({ value: 1 });
217
+ expect(() => {
218
+ (state as any).value = 2;
219
+ }).toThrow(/read-only/);
220
+ expect(() => {
221
+ delete (state as any).value;
222
+ }).toThrow(/read-only/);
223
+ });
224
+
225
+ test("the setter's draft is writable and mutates directly", () => {
226
+ const [state, setState] = store({
227
+ user: { name: "ada" },
228
+ tags: [] as string[],
229
+ });
230
+ setState((s) => {
231
+ s.user.name = "grace";
232
+ s.tags.push("admin");
233
+ });
234
+ expect(state.user.name).toBe("grace");
235
+ expect(state.tags.length).toBe(1);
236
+ });
237
+
238
+ test("a setter call is one propagation for its store", () => {
239
+ const [state, setState] = store({ a: 1, b: 2 });
240
+ let runs = 0;
241
+ createRoot(() => {
242
+ effect(() => {
243
+ state.a;
244
+ state.b;
245
+ runs++;
246
+ });
247
+ });
248
+ expect(runs).toBe(1);
249
+ setState((s) => {
250
+ s.a = 10;
251
+ s.b = 20;
252
+ });
253
+ expect(runs).toBe(2); // one propagation, not two
254
+ });
255
+
256
+ test("signal tuple get/set with functional updates", () => {
257
+ const [count, setCount] = signal(10);
258
+ setCount((c) => c + 5);
259
+ expect(count()).toBe(15);
260
+ });
261
+ });
262
+
263
+ describe("batch", () => {
264
+ test("nests and collapses to a single flush", () => {
265
+ const [a, setA] = signal(0);
266
+ const [b, setB] = signal(0);
267
+ let runs = 0;
268
+ createRoot(() => {
269
+ effect(() => {
270
+ a();
271
+ b();
272
+ runs++;
273
+ });
274
+ });
275
+ expect(runs).toBe(1);
276
+ batch(() => {
277
+ setA(1);
278
+ batch(() => {
279
+ setB(1);
280
+ });
281
+ setA(2);
282
+ });
283
+ expect(runs).toBe(2);
284
+ });
285
+
286
+ test("reads inside a batch see pending writes", () => {
287
+ const [total, setTotal] = signal(0);
288
+ batch(() => {
289
+ for (const price of [1, 2, 3]) {
290
+ setTotal((t) => t + price);
291
+ }
292
+ expect(total()).toBe(6);
293
+ });
294
+ expect(total()).toBe(6);
295
+ });
296
+
297
+ test("throw inside a batch: exception propagates, depth resets, queue flushes", () => {
298
+ const [count, setCount] = signal(0);
299
+ let runs = 0;
300
+ createRoot(() => {
301
+ effect(() => {
302
+ count();
303
+ runs++;
304
+ });
305
+ });
306
+ expect(runs).toBe(1);
307
+ expect(() =>
308
+ batch(() => {
309
+ setCount(1);
310
+ throw new Error("boom");
311
+ }),
312
+ ).toThrow("boom");
313
+ // The mutation landed; the queued notification flushed on the way out.
314
+ expect(runs).toBe(2);
315
+ // Depth reset: later writes propagate normally.
316
+ setCount(2);
317
+ expect(runs).toBe(3);
318
+ });
319
+
320
+ test("throw inside live/inert restores tracking (no phantom subscriptions)", () => {
321
+ const [count, setCount] = signal(0);
322
+ const [other, setOther] = signal(0);
323
+ let runs = 0;
324
+ createRoot(() => {
325
+ effect(() => {
326
+ count();
327
+ runs++;
328
+ if (count() === 1) throw new Error("live boom");
329
+ });
330
+ });
331
+ expect(runs).toBe(1);
332
+ expect(() => setCount(1)).toThrow("live boom");
333
+ // Tracking stack restored: reads out here must not subscribe.
334
+ other();
335
+ setOther(1);
336
+ expect(runs).toBe(2);
337
+ });
338
+ });
339
+
340
+ describe("hard errors", () => {
341
+ test("cleanup() with no argument throws", () => {
342
+ const seen: string[] = [];
343
+ createRoot(() => {
344
+ effect(() => {
345
+ try {
346
+ (cleanup as any)();
347
+ } catch (e) {
348
+ seen.push(String(e));
349
+ }
350
+ });
351
+ });
352
+ expect(seen.length).toBe(1);
353
+ expect(seen[0]).toContain("requires a function");
354
+ });
355
+
356
+ test("cleanup() outside a scope throws", () => {
357
+ expect(() => cleanup(() => {})).toThrow(/outside a scope/);
358
+ });
359
+
360
+ test("cleanup() inside a memo throws", () => {
361
+ createRoot(() => {
362
+ const bad = memo(() => {
363
+ cleanup(() => {});
364
+ return 1;
365
+ });
366
+ expect(() => bad()).toThrow(/inside a memo/);
367
+ });
368
+ });
369
+
370
+ test("live() outside JSX and outside any scope throws", () => {
371
+ expect(() => live(() => 1)).toThrow(/outside/);
372
+ });
373
+ });
374
+
375
+ describe("dev warnings", () => {
376
+ test("signal read during render with no scope warns", () => {
377
+ setDevMode(true);
378
+ const [count] = signal(0);
379
+ const { warnings } = withWarnings(() => {
380
+ createRoot(() => {
381
+ count(); // unwrapped read during a commit
382
+ });
383
+ });
384
+ expect(warnings.some((w) => w.includes("no scope"))).toBe(true);
385
+ });
386
+
387
+ test("live() with zero dependencies warns", () => {
388
+ setDevMode(true);
389
+ const { warnings } = withWarnings(() => {
390
+ createRoot(() => {
391
+ live(() => 42); // static expression, defensive over-wrap
392
+ });
393
+ });
394
+ expect(warnings.some((w) => w.includes("zero dependencies"))).toBe(true);
395
+ });
396
+
397
+ test("nested live() warns and stays transparent", () => {
398
+ setDevMode(true);
399
+ const [state, setState] = store({ user: { tier: "gold" } });
400
+ const seen: string[] = [];
401
+ const { warnings } = withWarnings(() => {
402
+ createRoot(() => {
403
+ live(() => {
404
+ const tier = live(() => state.user.tier);
405
+ seen.push(String(tier));
406
+ });
407
+ });
408
+ setState((s) => (s.user.tier = "silver"));
409
+ });
410
+ expect(warnings.some((w) => w.includes("nested live"))).toBe(true);
411
+ expect(seen).toEqual(["gold", "silver"]);
412
+ });
413
+
414
+ test("warnings are silent when dev mode is off", () => {
415
+ setDevMode(false);
416
+ const [count] = signal(0);
417
+ const { warnings } = withWarnings(() => {
418
+ createRoot(() => {
419
+ count();
420
+ live(() => 42);
421
+ });
422
+ });
423
+ expect(warnings.length).toBe(0);
424
+ setDevMode(true);
425
+ });
426
+ });
427
+
428
+ describe("live bindings", () => {
429
+ test("statement live is an effect; value live is a claimable binding", () => {
430
+ const [count] = signal(1);
431
+ const applied: number[] = [];
432
+ createRoot(() => {
433
+ const binding = live(() => count() * 10);
434
+ expect(isLive(binding)).toBe(true);
435
+ (binding as LiveBinding<number>).claimed = true;
436
+ applied.push((binding as LiveBinding<number>).fn());
437
+ });
438
+ expect(applied).toEqual([10]);
439
+ });
440
+
441
+ test("commit boundaries defer lives created inside", () => {
442
+ const runs: number[] = [];
443
+ const [count] = signal(7);
444
+ createRoot(() => {
445
+ commit(() => {
446
+ live(() => {
447
+ runs.push(count());
448
+ });
449
+ expect(runs).toEqual([]);
450
+ });
451
+ // Inner commit exited but the root's commit is still open.
452
+ expect(runs).toEqual([]);
453
+ });
454
+ expect(runs).toEqual([7]);
455
+ });
456
+ });