electrobun 1.18.4-beta.19 → 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 (53) hide show
  1. package/dist/api/browser/ui/__tests__/dom.test.ts +473 -0
  2. package/dist/api/browser/ui/__tests__/domStub.ts +218 -0
  3. package/dist/api/browser/ui/dom.ts +490 -0
  4. package/dist/api/browser/ui/index.ts +44 -0
  5. package/dist/api/browser/ui/jsx-dev-runtime.ts +16 -0
  6. package/dist/api/browser/ui/jsx-runtime.ts +56 -0
  7. package/dist/api/config/ElectrobunConfig.ts +33 -0
  8. package/dist/api/preload/.generated/compiled.ts +1 -1
  9. package/dist/api/preload/index.ts +2 -0
  10. package/dist/api/preload/uiTag.ts +45 -0
  11. package/dist/api/sdks/main/__tests__/utils-quit-exit-code.test.ts +44 -0
  12. package/dist/api/sdks/main/core/GpuWindow.ts +19 -0
  13. package/dist/api/sdks/main/core/Utils.ts +52 -4
  14. package/dist/api/sdks/main/core/WGPUView.ts +9 -0
  15. package/dist/api/sdks/main/entries/ui.ts +1 -0
  16. package/dist/api/sdks/main/proc/native.ts +187 -0
  17. package/dist/api/sdks/main/ui/__tests__/font.test.ts +49 -0
  18. package/dist/api/sdks/main/ui/__tests__/hit.test.ts +64 -0
  19. package/dist/api/sdks/main/ui/__tests__/jsx.test.ts +252 -0
  20. package/dist/api/sdks/main/ui/__tests__/layout.test.ts +159 -0
  21. package/dist/api/sdks/main/ui/__tests__/paint.test.ts +115 -0
  22. package/dist/api/sdks/main/ui/__tests__/reactive.test.ts +456 -0
  23. package/dist/api/sdks/main/ui/__tests__/scroll-focus-input.test.ts +298 -0
  24. package/dist/api/sdks/main/ui/__tests__/tree.test.ts +96 -0
  25. package/dist/api/sdks/main/ui/__tests__/ui.test.ts +170 -0
  26. package/dist/api/sdks/main/ui/elements.ts +135 -0
  27. package/dist/api/sdks/main/ui/font.ts +168 -0
  28. package/dist/api/sdks/main/ui/hit.ts +46 -0
  29. package/dist/api/sdks/main/ui/index.ts +71 -0
  30. package/dist/api/sdks/main/ui/input.ts +268 -0
  31. package/dist/api/sdks/main/ui/jsx-dev-runtime.ts +16 -0
  32. package/dist/api/sdks/main/ui/jsx-runtime.ts +136 -0
  33. package/dist/api/sdks/main/ui/keymap.ts +147 -0
  34. package/dist/api/sdks/main/ui/layout.ts +178 -0
  35. package/dist/api/sdks/main/ui/paint.ts +196 -0
  36. package/dist/api/sdks/main/ui/reactive.ts +4 -0
  37. package/dist/api/sdks/main/ui/renderer.ts +278 -0
  38. package/dist/api/sdks/main/ui/text.ts +175 -0
  39. package/dist/api/sdks/main/ui/textInput.ts +121 -0
  40. package/dist/api/sdks/main/ui/tree.ts +276 -0
  41. package/dist/api/sdks/main/ui/ui.ts +457 -0
  42. package/dist/api/sdks/main/ui/uiTagHost.ts +56 -0
  43. package/dist/api/sdks/main/ui/uiwindow.ts +330 -0
  44. package/dist/api/shared/build-dependencies.test.ts +1 -1
  45. package/dist/api/shared/build-dependencies.ts +4 -4
  46. package/dist/api/shared/linux-webkit-automation.test.ts +1 -1
  47. package/dist/api/shared/warren/jsx.ts +279 -0
  48. package/dist/api/shared/warren/reactive.ts +638 -0
  49. package/dist/api/shared/windows-unicode-ui.test.ts +4 -4
  50. package/dist/preload-full.js +35 -0
  51. package/dist/zig-sdk/electrobun.zig +197 -162
  52. package/{dash.config.ts → hutch.config.ts} +3 -2
  53. package/package.json +11 -2
@@ -0,0 +1,638 @@
1
+ // Warren — reactivity core.
2
+ //
3
+ // The one-line rule: nothing is reactive unless you can see why. Component
4
+ // bodies, handlers, and helpers are inert; reactivity exists only inside a
5
+ // scope you can see — live() or memo(). Within a scope, tracking follows
6
+ // the dynamic extent: signal calls and store property reads both subscribe,
7
+ // including inside helpers called from the scope.
8
+ //
9
+ // Seven primitives, every one a bare verb taking a function:
10
+ // signal(v) reactive value; returns [get, set]
11
+ // store(obj) nested reactive state; returns [readonlyProxy, setter];
12
+ // the setter takes a mutator (draft => { ... })
13
+ // live(fn) reactive scope; deferred — runs after commit
14
+ // memo(fn) caching reactive scope; dependents never observe it stale
15
+ // inert(fn) read without subscribing, inside a scope (no-op outside)
16
+ // cleanup(fn) teardown for the enclosing scope; runs before each re-run
17
+ // batch(fn) defer notification (never mutation) to the outermost exit
18
+ //
19
+ // There is no createEffect — an effect is a live whose return value nobody
20
+ // uses. There is no produce — the store setter mutates a draft directly.
21
+
22
+ export type Accessor<T> = () => T;
23
+ export type Setter<T> = (next: T | ((prev: T) => T)) => T;
24
+
25
+ // Structural brand (string key, not a unique symbol) so the type unifies
26
+ // across symlinked/realified module paths.
27
+ export interface LiveBinding<T> {
28
+ readonly __warrenLive: true;
29
+ /** The wrapped expression. Consumers re-run it inside their own scope. */
30
+ fn: () => T;
31
+ /** Set by a consumer (value prop, control flow) to suppress the auto-effect. */
32
+ claimed: boolean;
33
+ }
34
+
35
+ /** What value props accept: a plain value, or a live() binding. */
36
+ export type Reactive<T> = T | LiveBinding<T>;
37
+
38
+ export function isLive(value: unknown): value is LiveBinding<unknown> {
39
+ return (
40
+ typeof value === "object" &&
41
+ value !== null &&
42
+ (value as any).__warrenLive === true
43
+ );
44
+ }
45
+
46
+ let devMode = true;
47
+ /** Toggle dev-mode warnings (default on while Warren is experimental). */
48
+ export function setDevMode(enabled: boolean): void {
49
+ devMode = enabled;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Scopes
54
+ // ---------------------------------------------------------------------------
55
+
56
+ interface SubscriberSet extends Set<Scope> {
57
+ /** True for signal subscriber sets (dev warning #3 heuristics). */
58
+ fromSignal?: boolean;
59
+ }
60
+
61
+ type ScopeKind = "root" | "live" | "memo";
62
+
63
+ let currentScope: Scope | null = null;
64
+ /** Non-null only while a tracking scope (live/memo) body executes. */
65
+ let trackingScope: Scope | null = null;
66
+ let inertDepth = 0;
67
+
68
+ class Scope {
69
+ kind: ScopeKind;
70
+ fn: (() => void) | null;
71
+ parent: Scope | null;
72
+ children: Scope[] = [];
73
+ cleanups: Array<() => void> = [];
74
+ sources: SubscriberSet[] = [];
75
+ disposed = false;
76
+ hasRun = false;
77
+
78
+ constructor(kind: ScopeKind, fn: (() => void) | null, parent: Scope | null) {
79
+ this.kind = kind;
80
+ this.fn = fn;
81
+ this.parent = parent;
82
+ parent?.children.push(this);
83
+ }
84
+
85
+ run(): void {
86
+ if (this.disposed || !this.fn) return;
87
+ // A re-run replaces the previous run's scope state.
88
+ for (let i = this.children.length - 1; i >= 0; i--) {
89
+ this.children[i]!.dispose();
90
+ }
91
+ this.children.length = 0;
92
+ for (let i = this.cleanups.length - 1; i >= 0; i--) {
93
+ this.cleanups[i]!();
94
+ }
95
+ this.cleanups.length = 0;
96
+ this.clearSources();
97
+
98
+ const prevScope = currentScope;
99
+ const prevTracking = trackingScope;
100
+ const prevInert = inertDepth;
101
+ currentScope = this;
102
+ trackingScope = this.kind === "root" ? null : this;
103
+ inertDepth = 0;
104
+ try {
105
+ this.fn();
106
+ } finally {
107
+ currentScope = prevScope;
108
+ trackingScope = prevTracking;
109
+ inertDepth = prevInert;
110
+ this.hasRun = true;
111
+ }
112
+ }
113
+
114
+ clearSources(): void {
115
+ for (const subs of this.sources) subs.delete(this);
116
+ this.sources.length = 0;
117
+ }
118
+
119
+ dispose(): void {
120
+ if (this.disposed) return;
121
+ this.disposed = true;
122
+ liveQueue.delete(this);
123
+ this.clearSources();
124
+ for (let i = this.children.length - 1; i >= 0; i--) {
125
+ this.children[i]!.dispose();
126
+ }
127
+ this.children.length = 0;
128
+ for (let i = this.cleanups.length - 1; i >= 0; i--) {
129
+ this.cleanups[i]!();
130
+ }
131
+ this.cleanups.length = 0;
132
+ if (this.parent && !this.parent.disposed) {
133
+ const idx = this.parent.children.indexOf(this);
134
+ if (idx >= 0) this.parent.children.splice(idx, 1);
135
+ }
136
+ }
137
+ }
138
+
139
+ function track(subs: SubscriberSet): void {
140
+ if (!trackingScope || inertDepth > 0) return;
141
+ if (!subs.has(trackingScope)) {
142
+ subs.add(trackingScope);
143
+ trackingScope.sources.push(subs);
144
+ }
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Propagation. Memos pull-validate so a live never observes a stale memo;
149
+ // lives queue and flush after commit (end of mount pass, end of outermost
150
+ // batch, or immediately after an unbatched write's propagation).
151
+ // ---------------------------------------------------------------------------
152
+
153
+ const liveQueue = new Set<Scope>();
154
+ let batchDepth = 0;
155
+ let commitDepth = 0;
156
+ let notifyDepth = 0;
157
+ let flushing = false;
158
+
159
+ function notify(subs: SubscriberSet): void {
160
+ if (subs.size === 0) return;
161
+ // Flushing waits until propagation fully settles (notifyDepth back to 0):
162
+ // a memo invalidating mid-loop must not run lives before every subscriber
163
+ // of the original write has been queued, or a live can run twice.
164
+ notifyDepth++;
165
+ try {
166
+ for (const scope of [...subs]) {
167
+ if (scope.kind === "memo") {
168
+ (scope as MemoScope).invalidate();
169
+ } else {
170
+ liveQueue.add(scope);
171
+ }
172
+ }
173
+ } finally {
174
+ notifyDepth--;
175
+ }
176
+ scheduleFlush();
177
+ }
178
+
179
+ function scheduleFlush(): void {
180
+ if (batchDepth > 0 || commitDepth > 0 || notifyDepth > 0 || flushing) return;
181
+ flushLive();
182
+ }
183
+
184
+ function flushLive(): void {
185
+ if (flushing) return;
186
+ flushing = true;
187
+ try {
188
+ let iterations = 0;
189
+ while (liveQueue.size) {
190
+ if (++iterations > 100_000) {
191
+ liveQueue.clear();
192
+ throw new Error("Warren: reactive cycle — lives never settled");
193
+ }
194
+ const next = liveQueue.values().next().value!;
195
+ liveQueue.delete(next);
196
+ next.run();
197
+ }
198
+ } finally {
199
+ flushing = false;
200
+ }
201
+ if (liveQueue.size && batchDepth === 0 && commitDepth === 0) flushLive();
202
+ }
203
+
204
+ /**
205
+ * A commit boundary (mount or rebuild pass). Lives created or notified
206
+ * inside run when the outermost commit exits.
207
+ */
208
+ export function commit<T>(fn: () => T): T {
209
+ commitDepth++;
210
+ try {
211
+ return fn();
212
+ } finally {
213
+ commitDepth--;
214
+ if (commitDepth === 0 && batchDepth === 0) flushLive();
215
+ }
216
+ }
217
+
218
+ export function batch<T>(fn: () => T): T {
219
+ batchDepth++;
220
+ try {
221
+ return fn();
222
+ } finally {
223
+ // Queued notifications flush on the way out even when fn threw: the
224
+ // mutations already landed, so discarding them would leave the graph
225
+ // inconsistent with the store.
226
+ batchDepth--;
227
+ if (batchDepth === 0 && commitDepth === 0) flushLive();
228
+ }
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // signal
233
+ // ---------------------------------------------------------------------------
234
+
235
+ const defaultEquals = <T>(a: T, b: T) => a === b;
236
+
237
+ export function signal<T>(
238
+ value: T,
239
+ options?: { equals?: false | ((a: T, b: T) => boolean) },
240
+ ): [Accessor<T>, Setter<T>] {
241
+ const equals =
242
+ options?.equals === false ? () => false : options?.equals ?? defaultEquals;
243
+ const subs: SubscriberSet = new Set() as SubscriberSet;
244
+ subs.fromSignal = true;
245
+ const read: Accessor<T> = () => {
246
+ if (
247
+ devMode &&
248
+ commitDepth > 0 &&
249
+ !trackingScope &&
250
+ inertDepth === 0
251
+ ) {
252
+ console.warn(
253
+ "Warren: signal read during render with no scope on the stack — this renders once and then freezes. Wrap it: live(() => ...).",
254
+ );
255
+ }
256
+ track(subs);
257
+ return value;
258
+ };
259
+ const write: Setter<T> = (next) => {
260
+ const resolved =
261
+ typeof next === "function" ? (next as (prev: T) => T)(value) : next;
262
+ if (equals(value, resolved)) return value;
263
+ value = resolved;
264
+ notify(subs);
265
+ return value;
266
+ };
267
+ return [read, write];
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // memo
272
+ // ---------------------------------------------------------------------------
273
+
274
+ class MemoScope extends Scope {
275
+ value: unknown;
276
+ stale = true;
277
+ equals: (a: unknown, b: unknown) => boolean;
278
+ subs: SubscriberSet = new Set() as SubscriberSet;
279
+ compute: () => unknown;
280
+
281
+ constructor(
282
+ compute: () => unknown,
283
+ equals: (a: unknown, b: unknown) => boolean,
284
+ ) {
285
+ super("memo", null, currentScope);
286
+ this.compute = compute;
287
+ this.fn = () => {
288
+ this.value = this.compute();
289
+ };
290
+ this.equals = equals;
291
+ }
292
+
293
+ invalidate(): void {
294
+ if (this.stale) return;
295
+ this.stale = true;
296
+ // Unobserved memos stay lazy: recompute on next read.
297
+ if (this.subs.size === 0) return;
298
+ // Observed memos recompute eagerly so the equals cut can stop
299
+ // propagation: dependents only hear about it when the value actually
300
+ // changed. (Mutations have already landed — batch defers notification,
301
+ // never mutation — so recomputing mid-propagation reads fresh state.)
302
+ const prev = this.value;
303
+ const first = !this.hasRun;
304
+ this.run();
305
+ this.stale = false;
306
+ if (!first && this.equals(prev, this.value)) {
307
+ this.value = prev;
308
+ return;
309
+ }
310
+ notify(this.subs);
311
+ }
312
+
313
+ read(): unknown {
314
+ track(this.subs);
315
+ if (this.stale && !this.disposed) {
316
+ const prev = this.value;
317
+ const first = !this.hasRun;
318
+ this.run();
319
+ this.stale = false;
320
+ if (!first && this.equals(prev, this.value)) {
321
+ this.value = prev;
322
+ }
323
+ }
324
+ return this.value;
325
+ }
326
+ }
327
+
328
+ export function memo<T>(
329
+ fn: () => T,
330
+ options?: { equals?: false | ((a: T, b: T) => boolean) },
331
+ ): Accessor<T> {
332
+ const equals =
333
+ options?.equals === false
334
+ ? () => false
335
+ : ((options?.equals as any) ?? defaultEquals);
336
+ const node = new MemoScope(fn as () => unknown, equals);
337
+ return () => node.read() as T;
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // live
342
+ // ---------------------------------------------------------------------------
343
+
344
+ function warnZeroDeps(scope: Scope): void {
345
+ if (!devMode || !scope.hasRun) return;
346
+ if (scope.sources.length === 0) {
347
+ console.warn(
348
+ "Warren: live() registered zero dependencies — the wrapped expression is static; drop the marker.",
349
+ );
350
+ } else if (scope.sources.every((s) => s.fromSignal)) {
351
+ // Inside another scope the signal calls would have tracked anyway.
352
+ // (Only meaningful for nested lives; harmless reminder elsewhere.)
353
+ }
354
+ }
355
+
356
+ export function live<T>(fn: () => T): Reactive<T> {
357
+ if (typeof fn !== "function") {
358
+ throw new Error("Warren: live() takes a function: live(() => ...)");
359
+ }
360
+ // Nested inside a running scope while actually tracking: a pass-through.
361
+ // The enclosing scope already tracks the dynamic extent, so evaluate and
362
+ // return the value. Inside inert() (component bodies, escapes) tracking
363
+ // is off, so a live() there is a genuine new binding, not a nested one.
364
+ if (trackingScope && inertDepth === 0) {
365
+ if (devMode) {
366
+ console.warn(
367
+ "Warren: nested live() — the enclosing scope already tracks these reads; the marker is redundant here.",
368
+ );
369
+ }
370
+ return fn();
371
+ }
372
+ if (!currentScope) {
373
+ throw new Error(
374
+ "Warren: live() outside JSX and outside any scope has no meaning. Create it inside a component, a mount, or another scope.",
375
+ );
376
+ }
377
+ const binding: LiveBinding<T> = {
378
+ __warrenLive: true,
379
+ fn,
380
+ claimed: false,
381
+ };
382
+ // Statement-position lives become effects; value-position lives get
383
+ // claimed by their consumer first. Defer the decision to the flush.
384
+ const decide = new Scope("live", null, currentScope);
385
+ decide.fn = () => {
386
+ if (binding.claimed) {
387
+ decide.dispose();
388
+ return;
389
+ }
390
+ decide.fn = () => {
391
+ binding.fn();
392
+ };
393
+ decide.run();
394
+ warnZeroDeps(decide);
395
+ };
396
+ liveQueue.add(decide);
397
+ scheduleFlush();
398
+ return binding;
399
+ }
400
+
401
+ /**
402
+ * Consumer side of live(): claim a binding and run `apply(value)` in a
403
+ * scope that re-runs (deferred) when the binding's dependencies change.
404
+ * Used by value props and control-flow components.
405
+ */
406
+ export function claimLive<T>(
407
+ binding: LiveBinding<T>,
408
+ apply: (value: T) => void,
409
+ ): void {
410
+ binding.claimed = true;
411
+ if (!currentScope) {
412
+ throw new Error("Warren: internal — claimLive outside a scope");
413
+ }
414
+ const scope = new Scope("live", null, currentScope);
415
+ scope.fn = () => {
416
+ apply(binding.fn());
417
+ warnZeroDeps(scope);
418
+ };
419
+ liveQueue.add(scope);
420
+ scheduleFlush();
421
+ }
422
+
423
+ /** Internal: a deferred reactive scope (regions, prop bindings). */
424
+ export function liveScope(fn: () => void): void {
425
+ if (!currentScope) {
426
+ throw new Error("Warren: internal — liveScope outside a scope");
427
+ }
428
+ const scope = new Scope("live", fn, currentScope);
429
+ liveQueue.add(scope);
430
+ scheduleFlush();
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // inert / cleanup / roots
435
+ // ---------------------------------------------------------------------------
436
+
437
+ export function inert<T>(fn: () => T): T {
438
+ // Outside a scope this is a no-op — inert is already the default there.
439
+ inertDepth++;
440
+ try {
441
+ return fn();
442
+ } finally {
443
+ inertDepth--;
444
+ }
445
+ }
446
+
447
+ export function cleanup(fn: () => void): void {
448
+ if (fn === undefined) {
449
+ throw new Error(
450
+ 'Warren: cleanup() requires a function — it registers teardown, it does not "clean up now".',
451
+ );
452
+ }
453
+ if (typeof fn !== "function") {
454
+ throw new Error("Warren: cleanup() takes a function");
455
+ }
456
+ if (!currentScope) {
457
+ throw new Error("Warren: cleanup() called outside a scope");
458
+ }
459
+ if (currentScope.kind === "memo") {
460
+ throw new Error(
461
+ "Warren: cleanup() inside a memo — memos are pure computations; use live() for work that owns resources.",
462
+ );
463
+ }
464
+ currentScope.cleanups.push(fn);
465
+ }
466
+
467
+ /** Root scope for a mount. Lives created inside flush when fn returns. */
468
+ export function createRoot<T>(fn: (dispose: () => void) => T): T {
469
+ const scope = new Scope("root", null, null);
470
+ const prevScope = currentScope;
471
+ const prevTracking = trackingScope;
472
+ currentScope = scope;
473
+ trackingScope = null;
474
+ try {
475
+ return commit(() => fn(() => scope.dispose()));
476
+ } finally {
477
+ currentScope = prevScope;
478
+ trackingScope = prevTracking;
479
+ }
480
+ }
481
+
482
+ export function getOwner(): unknown {
483
+ return currentScope;
484
+ }
485
+
486
+ export function runWithOwner<T>(owner: unknown, fn: () => T): T {
487
+ const prevScope = currentScope;
488
+ const prevTracking = trackingScope;
489
+ currentScope = owner as Scope | null;
490
+ trackingScope = null;
491
+ try {
492
+ return fn();
493
+ } finally {
494
+ currentScope = prevScope;
495
+ trackingScope = prevTracking;
496
+ }
497
+ }
498
+
499
+ /** Internal: child scope handle for keyed list rows. */
500
+ export function createChildScope(owner?: unknown): unknown {
501
+ return new Scope("root", null, (owner as Scope | null) ?? currentScope);
502
+ }
503
+
504
+ export function disposeScope(scope: unknown): void {
505
+ (scope as Scope).dispose();
506
+ }
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // store
510
+ // ---------------------------------------------------------------------------
511
+
512
+ export type StoreSetter<T extends object> = (mutate: (draft: T) => void) => void;
513
+
514
+ export function store<T extends object>(initial: T): [T, StoreSetter<T>] {
515
+ const raw = initial;
516
+ const pathSubs = new Map<string, SubscriberSet>();
517
+ const readProxies = new WeakMap<object, object>();
518
+
519
+ const trackPath = (path: string) => {
520
+ // Property reads are inert outside scopes: one flag check, no
521
+ // bookkeeping — the common case (component bodies, handlers).
522
+ if (!trackingScope || inertDepth > 0) return;
523
+ let subs = pathSubs.get(path);
524
+ if (!subs) {
525
+ subs = new Set() as SubscriberSet;
526
+ pathSubs.set(path, subs);
527
+ }
528
+ track(subs);
529
+ };
530
+
531
+ const childPath = (path: string, key: string) =>
532
+ path ? `${path}.${key}` : key;
533
+
534
+ const readProxyFor = (target: object, path: string): object => {
535
+ const existing = readProxies.get(target);
536
+ if (existing) return existing;
537
+ const proxy = new Proxy(target, {
538
+ get(t: any, key) {
539
+ if (typeof key === "symbol") return t[key];
540
+ const value = t[key];
541
+ if (typeof value === "function" && !Object.hasOwn(t, key)) {
542
+ return value;
543
+ }
544
+ const p = childPath(path, String(key));
545
+ trackPath(p);
546
+ if (value !== null && typeof value === "object") {
547
+ return readProxyFor(value, p);
548
+ }
549
+ return value;
550
+ },
551
+ set() {
552
+ throw new Error(
553
+ "Warren: stores are read-only outside their setter. Write through the setter: setState(draft => { ... }).",
554
+ );
555
+ },
556
+ deleteProperty() {
557
+ throw new Error(
558
+ "Warren: stores are read-only outside their setter. Write through the setter: setState(draft => { ... }).",
559
+ );
560
+ },
561
+ });
562
+ readProxies.set(target, proxy);
563
+ return proxy;
564
+ };
565
+
566
+ const notifyChanged = (changed: Set<string>) => {
567
+ if (changed.size === 0) return;
568
+ batch(() => {
569
+ const notified = new Set<SubscriberSet>();
570
+ for (const changedPath of changed) {
571
+ for (const [path, subs] of pathSubs) {
572
+ if (notified.has(subs)) continue;
573
+ const isSelf = path === changedPath;
574
+ const isDescendant = path.startsWith(changedPath + ".");
575
+ if (isSelf || isDescendant) {
576
+ notified.add(subs);
577
+ notify(subs);
578
+ }
579
+ }
580
+ }
581
+ });
582
+ };
583
+
584
+ const draftFor = (
585
+ target: object,
586
+ path: string,
587
+ changed: Set<string>,
588
+ ): object =>
589
+ new Proxy(target, {
590
+ get(t: any, key) {
591
+ if (typeof key === "symbol") return t[key];
592
+ const value = t[key];
593
+ if (typeof value === "function" && !Object.hasOwn(t, key)) {
594
+ return value.bind(draftFor(t, path, changed));
595
+ }
596
+ if (value !== null && typeof value === "object") {
597
+ return draftFor(value, childPath(path, String(key)), changed);
598
+ }
599
+ return value;
600
+ },
601
+ set(t: any, key, value) {
602
+ const p = childPath(path, String(key));
603
+ if (t[key] !== value) {
604
+ const prevLength = Array.isArray(t) ? t.length : -1;
605
+ t[key] = value;
606
+ changed.add(p);
607
+ if (Array.isArray(t) && t.length !== prevLength) {
608
+ changed.add(childPath(path, "length"));
609
+ }
610
+ }
611
+ return true;
612
+ },
613
+ deleteProperty(t: any, key) {
614
+ const p = childPath(path, String(key));
615
+ if (key in t) {
616
+ delete t[key];
617
+ changed.add(p);
618
+ }
619
+ return true;
620
+ },
621
+ });
622
+
623
+ // The setter is a mutator scope: the draft is writable, mutation is
624
+ // direct (no structural sharing), and the store's writes batch into one
625
+ // propagation.
626
+ const setter: StoreSetter<T> = (mutate) => {
627
+ if (typeof mutate !== "function") {
628
+ throw new Error(
629
+ "Warren: the store setter takes a mutator: setState(draft => { draft.x = 1 }).",
630
+ );
631
+ }
632
+ const changed = new Set<string>();
633
+ mutate(draftFor(raw, "", changed) as T);
634
+ notifyChanged(changed);
635
+ };
636
+
637
+ return [readProxyFor(raw, "") as T, setter];
638
+ }
@@ -14,8 +14,8 @@ const packageBuild = readFileSync(
14
14
  join(import.meta.dirname, "../../build.ts"),
15
15
  "utf8",
16
16
  );
17
- const dashConfig = readFileSync(
18
- join(import.meta.dirname, "../../dash.config.ts"),
17
+ const hutchConfig = readFileSync(
18
+ join(import.meta.dirname, "../../hutch.config.ts"),
19
19
  "utf8",
20
20
  );
21
21
  const nativeUiTestScript = readFileSync(
@@ -86,10 +86,10 @@ describe("Windows Unicode native UI source contract", () => {
86
86
  });
87
87
 
88
88
  test("wires native Unicode UI coverage without compiling it off Windows", () => {
89
- expect(dashConfig).toContain(
89
+ expect(hutchConfig).toContain(
90
90
  '"test:windows-ui-native": "hutch scripts/test-windows-ui-native.js"',
91
91
  );
92
- expect(dashConfig).toContain("hutch test:windows-ui-native");
92
+ expect(hutchConfig).toContain("hutch test:windows-ui-native");
93
93
  expect(nativeUiTestScript).toContain(
94
94
  'if (process.platform !== "win32")',
95
95
  );
@@ -1226,6 +1226,40 @@ electrobun-wgpu {
1226
1226
  }
1227
1227
  }
1228
1228
 
1229
+ // src/preload/uiTag.ts
1230
+ class ElectrobunUiTag extends ElectrobunWgpuTag {
1231
+ async initWgpuView() {
1232
+ await super.initWgpuView();
1233
+ if (this.wgpuViewId !== null) {
1234
+ send("uiTagMount", {
1235
+ id: this.wgpuViewId,
1236
+ name: this.getAttribute("name") ?? ""
1237
+ });
1238
+ }
1239
+ }
1240
+ }
1241
+ function initUiTag() {
1242
+ if (!customElements.get("electrobun-ui")) {
1243
+ customElements.define("electrobun-ui", ElectrobunUiTag);
1244
+ }
1245
+ const injectStyles = () => {
1246
+ const style = document.createElement("style");
1247
+ style.textContent = `
1248
+ electrobun-ui {
1249
+ display: block;
1250
+ width: 400px;
1251
+ height: 300px;
1252
+ }
1253
+ `;
1254
+ document.head.appendChild(style);
1255
+ };
1256
+ if (document.readyState === "loading") {
1257
+ document.addEventListener("DOMContentLoaded", injectStyles);
1258
+ } else {
1259
+ injectStyles();
1260
+ }
1261
+ }
1262
+
1229
1263
  // src/preload/events.ts
1230
1264
  function emitWebviewEvent(eventName, detail) {
1231
1265
  setTimeout(() => {
@@ -1374,4 +1408,5 @@ initDragRegions();
1374
1408
  initExternalDropFocusRestoration();
1375
1409
  initWebviewTag();
1376
1410
  initWgpuTag();
1411
+ initUiTag();
1377
1412
  })();