what-react 0.10.0 → 0.11.1

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/src/runtime.js ADDED
@@ -0,0 +1,1147 @@
1
+ /**
2
+ * what-react/runtime — React-semantics renderer for compat components.
3
+ *
4
+ * What's core model is run-once + signals: components execute a single time and
5
+ * hooks return signal ACCESSORS. Real React library code expects VALUES from
6
+ * hooks and a re-render cycle (`const [count, setCount] = useState(0)` where
7
+ * `count` is a number). This runtime provides those semantics WITHOUT touching
8
+ * what-core:
9
+ *
10
+ * - Every component created through what-react's createElement/jsx-runtime is
11
+ * rendered by THIS runtime: per-instance hook state, value-returning hooks,
12
+ * and re-execution of the component function on state change.
13
+ * - Re-render output is reconciled against the previous VNode tree (keyed,
14
+ * type-matched diff) so DOM elements and child component instances are
15
+ * PRESERVED across re-renders — focus, input state, and child hook state
16
+ * survive, like React.
17
+ * - Granularity: only the instance whose state changed re-renders (plus its
18
+ * descendants via the normal React render cascade). Sibling/parent trees are
19
+ * untouched.
20
+ * - What's own components are NOT handled here. Any vnode not created by
21
+ * what-react's createElement (no `_compat` flag / bridge tag) is delegated
22
+ * verbatim to what-core's renderer as an opaque subtree, so native What
23
+ * components keep their run-once + signal semantics inside compat trees.
24
+ * - Compat components embedded in native What trees work through getBridge():
25
+ * a What component wrapper that mounts this runtime and cleans up via
26
+ * onCleanup.
27
+ *
28
+ * Known limitations (see REACT-COMPAT.md):
29
+ * - SSR of compat components is not supported (browser/jsdom only).
30
+ * - Suspense is minimal: fallback swap on thrown thenables (lazy(), use()).
31
+ * Suspended subtrees are unmounted while the fallback shows (state is lost),
32
+ * unlike React 18's Offscreen-preserving behavior.
33
+ * - Errors thrown inside effects are logged, not routed to error boundaries.
34
+ */
35
+
36
+ import { untrack, mount as whatMount, onCleanup as whatOnCleanup } from 'what-core';
37
+
38
+ // ---- VNode kinds ----
39
+ const KIND_HOLE = 0; // null / undefined / boolean — placeholder comment (slot stability)
40
+ const KIND_TEXT = 1; // string / number
41
+ const KIND_ELEMENT = 2; // vnode with string tag
42
+ const KIND_COMPONENT = 3; // vnode created by what-react createElement (function type)
43
+ const KIND_OPAQUE = 4; // What-native vnode / reactive function / raw DOM node → core renderer
44
+ const KIND_PORTAL = 5; // '__portal' vnode (ReactDOM.createPortal)
45
+
46
+ const EMPTY_OBJ = {};
47
+
48
+ // =====================================================================
49
+ // Hook dispatcher state
50
+ // =====================================================================
51
+
52
+ let currentInstance = null;
53
+
54
+ export function _getCurrentInstance() {
55
+ return currentInstance;
56
+ }
57
+
58
+ export function _requireInstance(hookName) {
59
+ if (!currentInstance) {
60
+ throw new Error(
61
+ `[what-react] ${hookName}() called outside of a component render. ` +
62
+ `Hooks can only be called while a what-react component is rendering. ` +
63
+ `If this happens inside a React library, make sure ALL react imports are ` +
64
+ `aliased to what-react (one module instance) — see the reactCompat() vite plugin.`
65
+ );
66
+ }
67
+ return currentInstance;
68
+ }
69
+
70
+ export function _getHookSlot(inst) {
71
+ const i = inst.hookIndex++;
72
+ let slot = inst.hooks[i];
73
+ if (slot === undefined) {
74
+ slot = inst.hooks[i] = {};
75
+ }
76
+ return slot;
77
+ }
78
+
79
+ // =====================================================================
80
+ // Scheduler — batched re-renders with infinite-loop guard
81
+ // =====================================================================
82
+
83
+ const dirtyQueue = new Set();
84
+ let flushScheduled = false;
85
+
86
+ export function scheduleUpdate(inst) {
87
+ if (!inst || inst.unmounted) return;
88
+ if (currentInstance === inst) {
89
+ // Render-phase update (setState during render of the same component):
90
+ // re-run the component function before committing, like React.
91
+ inst._renderPhaseUpdate = true;
92
+ return;
93
+ }
94
+ dirtyQueue.add(inst);
95
+ if (!flushScheduled) {
96
+ flushScheduled = true;
97
+ queueMicrotask(flushUpdates);
98
+ }
99
+ }
100
+
101
+ export function flushUpdates() {
102
+ flushScheduled = false;
103
+ let cycles = 0;
104
+ while (dirtyQueue.size > 0) {
105
+ if (++cycles > 100) {
106
+ dirtyQueue.clear();
107
+ console.error('[what-react] Update loop guard tripped: more than 100 render cycles in one flush. Possible infinite re-render loop.');
108
+ break;
109
+ }
110
+ // Parents before children: a parent re-render may update child props,
111
+ // making a separately-queued child render redundant.
112
+ const batch = [...dirtyQueue].sort((a, b) => a.depth - b.depth);
113
+ runInCommit(() => {
114
+ for (const inst of batch) {
115
+ if (dirtyQueue.has(inst) && !inst.unmounted) {
116
+ renderInstance(inst);
117
+ }
118
+ }
119
+ });
120
+ // Layout effects ran on commit exit — they may have queued more updates.
121
+ }
122
+ }
123
+
124
+ // =====================================================================
125
+ // Commit phase — effect queues (child-first order, layout sync / passive async)
126
+ // =====================================================================
127
+
128
+ let commitDepth = 0;
129
+ const refQueue = [];
130
+ const layoutQueue = [];
131
+ const passiveQueue = [];
132
+ let passiveScheduled = false;
133
+
134
+ export function runInCommit(fn) {
135
+ commitDepth++;
136
+ try {
137
+ return fn();
138
+ } finally {
139
+ commitDepth--;
140
+ if (commitDepth === 0) {
141
+ // React commit order: attach refs (after DOM insertion), then layout
142
+ // effects synchronously, then passive effects asynchronously.
143
+ flushRefQueue();
144
+ flushEffectQueue(layoutQueue);
145
+ if (passiveQueue.length > 0 && !passiveScheduled) {
146
+ passiveScheduled = true;
147
+ queueMicrotask(_flushPassive);
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ function flushRefQueue() {
154
+ while (refQueue.length > 0) {
155
+ const entry = refQueue.shift();
156
+ // Skip if the element was already unmounted (ref nulled) or ref swapped.
157
+ if (entry.rn.ref !== entry.ref) continue;
158
+ try {
159
+ applyRef(entry.ref, entry.el);
160
+ } catch (e) {
161
+ console.error('[what-react] ref error:', e);
162
+ }
163
+ }
164
+ }
165
+
166
+ export function _flushPassive() {
167
+ passiveScheduled = false;
168
+ flushEffectQueue(passiveQueue);
169
+ }
170
+
171
+ function flushEffectQueue(queue) {
172
+ while (queue.length > 0) {
173
+ const entry = queue.shift();
174
+ const slot = entry.slot;
175
+ slot._pending = null;
176
+ if (entry.inst.unmounted) continue;
177
+ if (slot.cleanup) {
178
+ try { slot.cleanup(); } catch (e) { console.error('[what-react] effect cleanup error:', e); }
179
+ slot.cleanup = null;
180
+ }
181
+ try {
182
+ const result = entry.fn();
183
+ if (typeof result === 'function') slot.cleanup = result;
184
+ } catch (e) {
185
+ console.error('[what-react] effect error:', e);
186
+ }
187
+ }
188
+ }
189
+
190
+ // Hooks push pending effects here; renderInstance moves them to the global
191
+ // queues AFTER the subtree commit so children's effects run before the parent's
192
+ // (React ordering).
193
+ export function _pushLayout(inst, slot, fn) {
194
+ _pushEffect(inst._pendingLayout, inst, slot, fn);
195
+ }
196
+
197
+ export function _pushPassive(inst, slot, fn) {
198
+ _pushEffect(inst._pendingPassive, inst, slot, fn);
199
+ }
200
+
201
+ function _pushEffect(pendingArr, inst, slot, fn) {
202
+ // If a pending entry for this slot hasn't run yet (render-phase re-render,
203
+ // or rapid double render), replace its fn instead of double-queueing.
204
+ if (slot._pending) {
205
+ slot._pending.fn = fn;
206
+ return;
207
+ }
208
+ const entry = { inst, slot, fn };
209
+ slot._pending = entry;
210
+ pendingArr.push(entry);
211
+ }
212
+
213
+ // Drain everything synchronously — used by act() and flushSync().
214
+ export function _drainAll() {
215
+ let guard = 0;
216
+ while ((dirtyQueue.size > 0 || layoutQueue.length > 0 || passiveQueue.length > 0) && ++guard < 100) {
217
+ flushUpdates();
218
+ flushEffectQueue(layoutQueue);
219
+ _flushPassive();
220
+ }
221
+ }
222
+
223
+ // =====================================================================
224
+ // VNode classification & normalization
225
+ // =====================================================================
226
+
227
+ function isVNodeLike(v) {
228
+ return v !== null && typeof v === 'object' && (v._vnode === true || 'tag' in v);
229
+ }
230
+
231
+ function kindOf(v) {
232
+ if (v == null || typeof v === 'boolean') return KIND_HOLE;
233
+ const t = typeof v;
234
+ if (t === 'string' || t === 'number' || t === 'bigint') return KIND_TEXT;
235
+ if (t === 'function') return KIND_OPAQUE; // reactive accessor (What interop)
236
+ if (t === 'object') {
237
+ if (isVNodeLike(v)) {
238
+ const tag = v.tag;
239
+ if (typeof tag === 'string') {
240
+ if (tag === '__portal') return KIND_PORTAL;
241
+ // What-internal boundary tags — let core handle them
242
+ if (tag === '__suspense' || tag === '__errorBoundary') return KIND_OPAQUE;
243
+ return KIND_ELEMENT;
244
+ }
245
+ if (typeof tag === 'function') {
246
+ return (tag._compatType || v._compat) ? KIND_COMPONENT : KIND_OPAQUE;
247
+ }
248
+ return KIND_OPAQUE;
249
+ }
250
+ if (typeof v.nodeType === 'number') return KIND_OPAQUE; // raw DOM node
251
+ }
252
+ return KIND_TEXT; // last resort: stringify
253
+ }
254
+
255
+ // Flatten arrays, keep holes (null/false/true → null) so child slot positions
256
+ // stay stable across conditional renders — React semantics.
257
+ export function normalizeChildren(value, out) {
258
+ if (out === undefined) out = [];
259
+ if (Array.isArray(value)) {
260
+ for (let i = 0; i < value.length; i++) normalizeChildren(value[i], out);
261
+ return out;
262
+ }
263
+ if (value == null || typeof value === 'boolean') {
264
+ out.push(null);
265
+ return out;
266
+ }
267
+ out.push(value);
268
+ return out;
269
+ }
270
+
271
+ function keyOf(v, kind) {
272
+ if ((kind === KIND_ELEMENT || kind === KIND_COMPONENT || kind === KIND_PORTAL) && v.key != null) return v.key;
273
+ return null;
274
+ }
275
+
276
+ function canPatch(rn, v, kind) {
277
+ if (rn.kind !== kind) return false;
278
+ switch (kind) {
279
+ case KIND_HOLE:
280
+ case KIND_TEXT:
281
+ return true;
282
+ case KIND_ELEMENT:
283
+ return rn.vnode.tag === v.tag;
284
+ case KIND_COMPONENT:
285
+ return rn.bridge === v.tag;
286
+ case KIND_PORTAL:
287
+ return true; // container change handled in patchPortal
288
+ case KIND_OPAQUE: {
289
+ if (rn.vnode === v) return true;
290
+ // Native What vnodes / reactive function children are recreated on every
291
+ // compat re-render, but the mounted run-once instance must be KEPT:
292
+ // What semantics route reactivity through signals, not vnode identity.
293
+ // Same function tag (or both reactive functions) at the same slot →
294
+ // keep the existing subtree. New plain-value props passed from compat
295
+ // parents to What components will NOT propagate (pass signals instead).
296
+ if (typeof v === 'function' && typeof rn.vnode === 'function') return true;
297
+ if (
298
+ isVNodeLike(v) && isVNodeLike(rn.vnode) &&
299
+ typeof v.tag === 'function' && rn.vnode.tag === v.tag &&
300
+ (v.key ?? null) === rn.key
301
+ ) return true;
302
+ return false;
303
+ }
304
+ }
305
+ return false;
306
+ }
307
+
308
+ // =====================================================================
309
+ // Reconciler — mount / patch / unmount
310
+ // =====================================================================
311
+
312
+ /**
313
+ * Diff a list of previously-rendered RNodes against new child values.
314
+ * Returns the new RNode list. DOM is inserted into parentDom before `anchor`.
315
+ *
316
+ * - Keyed children match by (key, type).
317
+ * - Unkeyed children match positionally among unkeyed siblings; a type
318
+ * mismatch consumes the slot (unmount + mount), like React's replace.
319
+ * - Pass 1 runs left-to-right (render/effect order matches React),
320
+ * mounting fresh nodes into detached fragments.
321
+ * - Pass 3 walks right-to-left fixing DOM positions with a moving anchor.
322
+ */
323
+ export function patchChildren(parentDom, old, newValues, anchor, svg, owner) {
324
+ let keyed = null;
325
+ const unkeyed = [];
326
+ for (let i = 0; i < old.length; i++) {
327
+ const rn = old[i];
328
+ if (rn.key != null) {
329
+ if (keyed === null) keyed = new Map();
330
+ if (!keyed.has(rn.key)) keyed.set(rn.key, rn);
331
+ else unkeyed.push(rn); // duplicate key — fall back to positional
332
+ } else {
333
+ unkeyed.push(rn);
334
+ }
335
+ }
336
+
337
+ const kept = new Set(); // matched old rnodes (stay mounted)
338
+ const consumed = new Set(); // old rnodes whose slot was taken (unmount)
339
+ let ui = 0;
340
+ const result = new Array(newValues.length);
341
+
342
+ // --- Pass 1: match + patch, or mount into detached fragment ---
343
+ for (let i = 0; i < newValues.length; i++) {
344
+ const v = newValues[i];
345
+ const kind = kindOf(v);
346
+ const key = keyOf(v, kind);
347
+ let match = null;
348
+
349
+ if (key != null) {
350
+ const cand = keyed !== null ? keyed.get(key) : undefined;
351
+ if (cand && !kept.has(cand) && !consumed.has(cand) && canPatch(cand, v, kind)) {
352
+ match = cand;
353
+ }
354
+ } else {
355
+ while (ui < unkeyed.length && (kept.has(unkeyed[ui]) || consumed.has(unkeyed[ui]))) ui++;
356
+ const cand = unkeyed[ui];
357
+ if (cand !== undefined) {
358
+ if (canPatch(cand, v, kind)) {
359
+ match = cand;
360
+ } else {
361
+ consumed.add(cand); // replace-in-slot
362
+ }
363
+ ui++;
364
+ }
365
+ }
366
+
367
+ if (match) {
368
+ kept.add(match);
369
+ result[i] = patchRNode(match, v, kind, parentDom, svg, owner);
370
+ } else {
371
+ const frag = document.createDocumentFragment();
372
+ const rn = mountRNode(v, kind, frag, svg, owner);
373
+ rn._pendingFrag = frag;
374
+ result[i] = rn;
375
+ }
376
+ }
377
+
378
+ // --- Pass 2: unmount old nodes that weren't kept ---
379
+ for (let i = 0; i < old.length; i++) {
380
+ const rn = old[i];
381
+ if (!kept.has(rn)) unmountRNode(rn, true);
382
+ }
383
+
384
+ // --- Pass 3: position (right-to-left, moving anchor) ---
385
+ let ref = anchor; // insert before this node; null = append at end
386
+ for (let i = result.length - 1; i >= 0; i--) {
387
+ const rn = result[i];
388
+ if (rn._pendingFrag) {
389
+ parentDom.insertBefore(rn._pendingFrag, ref);
390
+ rn._pendingFrag = null;
391
+ const first = firstDomNode(rn);
392
+ if (first) ref = first;
393
+ } else {
394
+ const nodes = domNodesOf(rn);
395
+ if (nodes.length > 0) {
396
+ const last = nodes[nodes.length - 1];
397
+ if (last.nextSibling !== ref || nodes[0].parentNode !== parentDom) {
398
+ for (let n = 0; n < nodes.length; n++) parentDom.insertBefore(nodes[n], ref);
399
+ }
400
+ ref = nodes[0];
401
+ }
402
+ }
403
+ }
404
+
405
+ return result;
406
+ }
407
+
408
+ function domNodesOf(rn) {
409
+ switch (rn.kind) {
410
+ case KIND_HOLE:
411
+ case KIND_TEXT:
412
+ case KIND_ELEMENT:
413
+ case KIND_PORTAL:
414
+ return rn.dom ? [rn.dom] : [];
415
+ case KIND_COMPONENT:
416
+ case KIND_OPAQUE: {
417
+ const nodes = [];
418
+ let n = rn.start;
419
+ const end = rn.end;
420
+ while (n) {
421
+ nodes.push(n);
422
+ if (n === end) break;
423
+ n = n.nextSibling;
424
+ }
425
+ return nodes;
426
+ }
427
+ }
428
+ return [];
429
+ }
430
+
431
+ function firstDomNode(rn) {
432
+ switch (rn.kind) {
433
+ case KIND_COMPONENT:
434
+ case KIND_OPAQUE:
435
+ return rn.start;
436
+ default:
437
+ return rn.dom || null;
438
+ }
439
+ }
440
+
441
+ // ---- Mount ----
442
+
443
+ function mountRNode(v, kind, container, svg, owner) {
444
+ switch (kind) {
445
+ case KIND_HOLE: {
446
+ const dom = document.createComment('w:h');
447
+ container.appendChild(dom);
448
+ return { kind, vnode: v, key: null, dom };
449
+ }
450
+ case KIND_TEXT: {
451
+ const text = typeof v === 'string' ? v : String(v);
452
+ const dom = document.createTextNode(text);
453
+ container.appendChild(dom);
454
+ return { kind, vnode: v, key: null, dom, text };
455
+ }
456
+ case KIND_ELEMENT:
457
+ return mountElement(v, container, svg, owner);
458
+ case KIND_COMPONENT:
459
+ return mountComponent(v, container, svg, owner);
460
+ case KIND_PORTAL:
461
+ return mountPortal(v, container, owner);
462
+ case KIND_OPAQUE:
463
+ return mountOpaque(v, container);
464
+ }
465
+ return { kind: KIND_HOLE, vnode: v, key: null, dom: container.appendChild(document.createComment('w:h')) };
466
+ }
467
+
468
+ const SVG_NS = 'http://www.w3.org/2000/svg';
469
+
470
+ function mountElement(v, container, svg, owner) {
471
+ const tag = v.tag;
472
+ const childSvg = (svg || tag === 'svg') && tag !== 'foreignObject';
473
+ const isSvgEl = svg || tag === 'svg';
474
+ const el = isSvgEl ? document.createElementNS(SVG_NS, tag) : document.createElement(tag);
475
+ const props = v.props || EMPTY_OBJ;
476
+
477
+ // 'type' must be applied before 'value' on inputs (and before onChange
478
+ // normalization picks the native event).
479
+ if (props.type !== undefined) setProperty(el, 'type', props.type, undefined, isSvgEl);
480
+ let value, checked, hasValue = false, hasChecked = false;
481
+ for (const name in props) {
482
+ if (name === 'children' || name === 'key' || name === 'ref' || name === 'type') continue;
483
+ if (name === 'value') { value = props[name]; hasValue = true; continue; }
484
+ if (name === 'checked') { checked = props[name]; hasChecked = true; continue; }
485
+ setProperty(el, name, props[name], undefined, isSvgEl);
486
+ }
487
+
488
+ const rn = {
489
+ kind: KIND_ELEMENT, vnode: v, key: v.key ?? null, dom: el,
490
+ children: [], ref: props.ref || null,
491
+ };
492
+
493
+ // Children
494
+ const kids = normalizeChildren(v.children !== undefined ? v.children : props.children);
495
+ rn.children = patchChildren(el, [], kids, null, childSvg, owner);
496
+
497
+ // Controlled props after children (e.g. <select> options must exist)
498
+ if (hasValue) setProperty(el, 'value', value, undefined, isSvgEl);
499
+ if (hasChecked) setProperty(el, 'checked', checked, undefined, isSvgEl);
500
+
501
+ // Refs attach at commit exit — AFTER the element is inserted into the live
502
+ // DOM — so ref callbacks that measure layout (getBoundingClientRect etc.)
503
+ // see real geometry, like React.
504
+ if (rn.ref) refQueue.push({ rn, ref: rn.ref, el });
505
+
506
+ container.appendChild(el);
507
+ return rn;
508
+ }
509
+
510
+ function mountComponent(v, container, svg, owner) {
511
+ const tag = v.tag;
512
+ const renderFn = (typeof tag === 'function' && tag._compatType) ? tag._compatType : tag;
513
+ const start = document.createComment('w$');
514
+ const end = document.createComment('/w$');
515
+ container.appendChild(start);
516
+ container.appendChild(end);
517
+
518
+ const inst = {
519
+ type: renderFn,
520
+ props: v.props || EMPTY_OBJ,
521
+ key: v.key ?? null,
522
+ hooks: [],
523
+ hookIndex: 0,
524
+ parent: owner,
525
+ depth: owner ? owner.depth + 1 : 0,
526
+ start, end, svg,
527
+ rendered: [],
528
+ unmounted: false,
529
+ _renderPhaseUpdate: false,
530
+ _pendingLayout: [],
531
+ _pendingPassive: [],
532
+ _guardStart: 0,
533
+ _guardCount: 0,
534
+ };
535
+
536
+ const rn = {
537
+ kind: KIND_COMPONENT, vnode: v, key: inst.key,
538
+ bridge: tag, inst, start, end,
539
+ };
540
+
541
+ renderInstance(inst);
542
+ return rn;
543
+ }
544
+
545
+ function mountPortal(v, container, owner) {
546
+ const dom = document.createComment('w:portal');
547
+ container.appendChild(dom);
548
+ const target = v.props && v.props.container;
549
+ const rn = { kind: KIND_PORTAL, vnode: v, key: v.key ?? null, dom, container: target || null, children: [] };
550
+ if (!target) {
551
+ console.warn('[what-react] createPortal: target container not found');
552
+ return rn;
553
+ }
554
+ rn.children = patchChildren(target, [], normalizeChildren(v.children), null, false, owner);
555
+ return rn;
556
+ }
557
+
558
+ // Opaque subtree: delegate to what-core's renderer. We mount into a detached
559
+ // fragment via core's public mount() (which returns a disposer), move the
560
+ // nodes inline between our own markers, and on unmount move them BACK into the
561
+ // holder fragment so core's disposer can run full disposeTree cleanup.
562
+ function mountOpaque(v, container) {
563
+ const start = document.createComment('w:o');
564
+ const end = document.createComment('/w:o');
565
+ container.appendChild(start);
566
+
567
+ let dispose = null;
568
+ let holder = null;
569
+ if (typeof v === 'object' && typeof v.nodeType === 'number') {
570
+ // Raw DOM node — insert as-is, caller owns its lifecycle.
571
+ container.appendChild(v);
572
+ } else {
573
+ holder = document.createDocumentFragment();
574
+ try {
575
+ dispose = whatMount(v, holder);
576
+ } catch (e) {
577
+ console.error('[what-react] Failed to render What-native child:', e);
578
+ }
579
+ container.appendChild(holder); // moves rendered nodes inline
580
+ }
581
+ container.appendChild(end);
582
+
583
+ return {
584
+ kind: KIND_OPAQUE, vnode: v, key: null, start, end,
585
+ dispose: dispose && (() => {
586
+ // Move everything between the markers back into the holder so core's
587
+ // disposer (disposeTree + clear) tears down effects/components fully.
588
+ let n = start.nextSibling;
589
+ while (n && n !== end) {
590
+ const next = n.nextSibling;
591
+ holder.appendChild(n);
592
+ n = next;
593
+ }
594
+ try { dispose(); } catch (e) { /* already disposed */ }
595
+ }),
596
+ };
597
+ }
598
+
599
+ // ---- Patch ----
600
+
601
+ function patchRNode(rn, v, kind, parentDom, svg, owner) {
602
+ switch (kind) {
603
+ case KIND_HOLE:
604
+ rn.vnode = v;
605
+ return rn;
606
+ case KIND_TEXT: {
607
+ const text = typeof v === 'string' ? v : String(v);
608
+ if (rn.text !== text) {
609
+ rn.text = text;
610
+ rn.dom.data = text;
611
+ }
612
+ rn.vnode = v;
613
+ return rn;
614
+ }
615
+ case KIND_ELEMENT:
616
+ return patchElement(rn, v, svg, owner);
617
+ case KIND_COMPONENT:
618
+ return patchComponent(rn, v);
619
+ case KIND_PORTAL:
620
+ return patchPortal(rn, v, owner);
621
+ case KIND_OPAQUE:
622
+ // identity-matched — nothing to do
623
+ rn.vnode = v;
624
+ return rn;
625
+ }
626
+ return rn;
627
+ }
628
+
629
+ function patchElement(rn, v, svg, owner) {
630
+ const el = rn.dom;
631
+ const oldProps = (rn.vnode && rn.vnode.props) || EMPTY_OBJ;
632
+ const newProps = v.props || EMPTY_OBJ;
633
+ const isSvgEl = svg || v.tag === 'svg';
634
+ const childSvg = (svg || v.tag === 'svg') && v.tag !== 'foreignObject';
635
+
636
+ if (oldProps !== newProps) {
637
+ // Removed props
638
+ for (const name in oldProps) {
639
+ if (name === 'children' || name === 'key' || name === 'ref') continue;
640
+ if (!(name in newProps)) setProperty(el, name, null, oldProps[name], isSvgEl);
641
+ }
642
+ // Changed props (value/checked re-asserted after children)
643
+ let value, checked, hasValue = false, hasChecked = false;
644
+ for (const name in newProps) {
645
+ if (name === 'children' || name === 'key' || name === 'ref') continue;
646
+ if (name === 'value') { value = newProps[name]; hasValue = true; continue; }
647
+ if (name === 'checked') { checked = newProps[name]; hasChecked = true; continue; }
648
+ if (oldProps[name] !== newProps[name]) {
649
+ setProperty(el, name, newProps[name], oldProps[name], isSvgEl);
650
+ }
651
+ }
652
+
653
+ // Children
654
+ const kids = normalizeChildren(v.children !== undefined ? v.children : newProps.children);
655
+ rn.children = patchChildren(el, rn.children, kids, null, childSvg, owner);
656
+
657
+ if (hasValue) setProperty(el, 'value', value, oldProps.value, isSvgEl);
658
+ if (hasChecked) setProperty(el, 'checked', checked, oldProps.checked, isSvgEl);
659
+
660
+ // Refs
661
+ const newRef = newProps.ref || null;
662
+ if (rn.ref !== newRef) {
663
+ if (rn.ref) applyRef(rn.ref, null);
664
+ if (newRef) applyRef(newRef, el);
665
+ rn.ref = newRef;
666
+ }
667
+ } else {
668
+ // Same props object — still recurse children (descendant components may
669
+ // need the render cascade; bailouts happen at component level).
670
+ const kids = normalizeChildren(v.children !== undefined ? v.children : newProps.children);
671
+ rn.children = patchChildren(el, rn.children, kids, null, childSvg, owner);
672
+ }
673
+
674
+ rn.vnode = v;
675
+ return rn;
676
+ }
677
+
678
+ function patchComponent(rn, v) {
679
+ const inst = rn.inst;
680
+ const newProps = v.props || EMPTY_OBJ;
681
+ const oldVnode = rn.vnode;
682
+ rn.vnode = v;
683
+
684
+ const selfDirty = dirtyQueue.has(inst);
685
+
686
+ // Identical element bailout (same vnode object, no pending self update) —
687
+ // context consumers below still update via their own subscriptions.
688
+ if (!selfDirty && oldVnode === v && inst.props === newProps) {
689
+ return rn;
690
+ }
691
+
692
+ // React.memo: skip re-render when props compare equal.
693
+ const compare = inst.type._memoCompare;
694
+ if (!selfDirty && compare && compare(inst.props, newProps)) {
695
+ inst.props = newProps;
696
+ return rn;
697
+ }
698
+
699
+ inst.props = newProps;
700
+ renderInstance(inst);
701
+ return rn;
702
+ }
703
+
704
+ function patchPortal(rn, v, owner) {
705
+ const target = v.props && v.props.container;
706
+ if (target === rn.container) {
707
+ if (target) {
708
+ rn.children = patchChildren(target, rn.children, normalizeChildren(v.children), null, false, owner);
709
+ }
710
+ } else {
711
+ for (const child of rn.children) unmountRNode(child, true);
712
+ rn.container = target || null;
713
+ rn.children = target
714
+ ? patchChildren(target, [], normalizeChildren(v.children), null, false, owner)
715
+ : [];
716
+ }
717
+ rn.vnode = v;
718
+ return rn;
719
+ }
720
+
721
+ // ---- Unmount ----
722
+
723
+ export function unmountRNode(rn, removeDom) {
724
+ switch (rn.kind) {
725
+ case KIND_HOLE:
726
+ case KIND_TEXT:
727
+ if (removeDom && rn.dom.parentNode) rn.dom.parentNode.removeChild(rn.dom);
728
+ return;
729
+ case KIND_ELEMENT: {
730
+ for (const child of rn.children) unmountRNode(child, false);
731
+ if (rn.ref) {
732
+ try { applyRef(rn.ref, null); } catch (e) { console.error('[what-react] ref cleanup error:', e); }
733
+ rn.ref = null; // invalidates any queued mount-time ref entry
734
+ }
735
+ if (removeDom && rn.dom.parentNode) rn.dom.parentNode.removeChild(rn.dom);
736
+ return;
737
+ }
738
+ case KIND_COMPONENT: {
739
+ unmountInstance(rn.inst);
740
+ if (removeDom) removeRange(rn.start, rn.end);
741
+ return;
742
+ }
743
+ case KIND_PORTAL: {
744
+ for (const child of rn.children) unmountRNode(child, true); // other container
745
+ if (removeDom && rn.dom.parentNode) rn.dom.parentNode.removeChild(rn.dom);
746
+ return;
747
+ }
748
+ case KIND_OPAQUE: {
749
+ if (rn.dispose) rn.dispose();
750
+ if (removeDom) removeRange(rn.start, rn.end);
751
+ return;
752
+ }
753
+ }
754
+ }
755
+
756
+ function unmountInstance(inst) {
757
+ if (inst.unmounted) return;
758
+ inst.unmounted = true;
759
+ dirtyQueue.delete(inst);
760
+
761
+ // Children first (React effect-cleanup order is bottom-up).
762
+ for (const child of inst.rendered) unmountRNode(child, false);
763
+ inst.rendered = [];
764
+
765
+ // Hook cleanups (useEffect / useLayoutEffect / useSyncExternalStore subs).
766
+ for (const slot of inst.hooks) {
767
+ if (slot && slot._isEffect && slot.cleanup) {
768
+ try { slot.cleanup(); } catch (e) { console.error('[what-react] effect cleanup error:', e); }
769
+ slot.cleanup = null;
770
+ }
771
+ }
772
+
773
+ // Context unsubscriptions.
774
+ if (inst._ctxDeps) {
775
+ for (const [provider, context] of inst._ctxDeps) {
776
+ const subs = provider._ctxSubs && provider._ctxSubs.get(context);
777
+ if (subs) subs.delete(inst);
778
+ }
779
+ inst._ctxDeps = null;
780
+ }
781
+ }
782
+
783
+ function removeRange(start, end) {
784
+ const parent = start.parentNode;
785
+ if (!parent) return;
786
+ let n = start;
787
+ while (n) {
788
+ const next = n.nextSibling;
789
+ parent.removeChild(n);
790
+ if (n === end) break;
791
+ n = next;
792
+ }
793
+ }
794
+
795
+ // =====================================================================
796
+ // Render — execute a component function with hook dispatcher + reconcile
797
+ // =====================================================================
798
+
799
+ export function renderInstance(inst) {
800
+ if (inst.unmounted) return;
801
+ dirtyQueue.delete(inst);
802
+
803
+ // Time-window loop guard (independent of the flush-cycle guard): protects
804
+ // against effect→setState→effect chains across microtasks.
805
+ const now = Date.now();
806
+ if (now - inst._guardStart > 200) {
807
+ inst._guardStart = now;
808
+ inst._guardCount = 0;
809
+ }
810
+ if (++inst._guardCount > 250) {
811
+ if (inst._guardCount === 251) {
812
+ console.error(`[what-react] Too many re-renders for <${inst.type.displayName || inst.type.name || 'Anonymous'}> (>250 in 200ms). Possible infinite loop — skipping renders.`);
813
+ }
814
+ return;
815
+ }
816
+
817
+ let out;
818
+ let renderPhaseLoops = 0;
819
+ do {
820
+ inst._renderPhaseUpdate = false;
821
+ inst.hookIndex = 0;
822
+ const prev = currentInstance;
823
+ currentInstance = inst;
824
+ try {
825
+ // untrack(): signal reads inside React components must not subscribe
826
+ // to any enclosing what-core effect.
827
+ out = untrack(() => inst.type(inst.props));
828
+ } catch (err) {
829
+ currentInstance = prev;
830
+ inst._pendingLayout.length = 0;
831
+ inst._pendingPassive.length = 0;
832
+ handleRenderError(inst, err, false);
833
+ return;
834
+ }
835
+ currentInstance = prev;
836
+ if (++renderPhaseLoops > 25) {
837
+ console.error('[what-react] Too many render-phase updates (setState during render). Possible loop.');
838
+ break;
839
+ }
840
+ } while (inst._renderPhaseUpdate);
841
+
842
+ const parentDom = inst.end.parentNode;
843
+ try {
844
+ inst.rendered = patchChildren(parentDom, inst.rendered, normalizeChildren(out), inst.end, inst.svg, inst);
845
+ } catch (err) {
846
+ // Subtree commit failed (a descendant threw). Reset this instance's output
847
+ // and route the error/suspension. Cleanups of partially-mounted children
848
+ // may not run — documented limitation.
849
+ removeRangeContents(inst.start, inst.end);
850
+ inst.rendered = [];
851
+ inst._pendingLayout.length = 0;
852
+ inst._pendingPassive.length = 0;
853
+ handleRenderError(inst, err, true);
854
+ return;
855
+ }
856
+
857
+ // Queue own effects after the subtree's (children queued theirs during patch).
858
+ for (const e of inst._pendingLayout) layoutQueue.push(e);
859
+ inst._pendingLayout.length = 0;
860
+ for (const e of inst._pendingPassive) passiveQueue.push(e);
861
+ inst._pendingPassive.length = 0;
862
+ }
863
+
864
+ function removeRangeContents(start, end) {
865
+ const parent = start.parentNode;
866
+ if (!parent) return;
867
+ let n = start.nextSibling;
868
+ while (n && n !== end) {
869
+ const next = n.nextSibling;
870
+ parent.removeChild(n);
871
+ n = next;
872
+ }
873
+ }
874
+
875
+ function handleRenderError(inst, err, fromChildren) {
876
+ // Suspense: thrown thenable (lazy components, use(promise)).
877
+ if (err !== null && typeof err === 'object' && typeof err.then === 'function') {
878
+ let boundary = fromChildren ? inst : inst.parent;
879
+ while (boundary && !boundary._isSuspense) boundary = boundary.parent;
880
+ if (boundary) {
881
+ boundary._suspendCount = (boundary._suspendCount || 0) + 1;
882
+ scheduleUpdate(boundary);
883
+ const wake = () => {
884
+ boundary._suspendCount--;
885
+ if (!boundary.unmounted) scheduleUpdate(boundary);
886
+ };
887
+ err.then(wake, wake);
888
+ return;
889
+ }
890
+ err = new Error('[what-react] A component suspended but no <Suspense> boundary was found above it.');
891
+ }
892
+
893
+ // Error boundaries (class components with componentDidCatch / getDerivedStateFromError).
894
+ let handler = fromChildren ? inst : inst.parent;
895
+ while (handler && !handler._errorHandler) handler = handler.parent;
896
+ if (handler) {
897
+ handler._errorHandler(err);
898
+ return;
899
+ }
900
+ throw err;
901
+ }
902
+
903
+ // =====================================================================
904
+ // Roots — entry points used by react-compat's ReactDOM implementation
905
+ // =====================================================================
906
+
907
+ export function mountRoot(element, container) {
908
+ const isSvg = typeof SVGElement !== 'undefined' && container instanceof SVGElement;
909
+ const rns = runInCommit(() => patchChildren(container, [], normalizeChildren(element), null, isSvg, null));
910
+ return { container, rns };
911
+ }
912
+
913
+ export function patchRoot(root, element) {
914
+ const isSvg = typeof SVGElement !== 'undefined' && root.container instanceof SVGElement;
915
+ root.rns = runInCommit(() => patchChildren(root.container, root.rns, normalizeChildren(element), null, isSvg, null));
916
+ return root;
917
+ }
918
+
919
+ export function unmountRoot(root) {
920
+ runInCommit(() => {
921
+ for (const rn of root.rns) unmountRNode(rn, true);
922
+ root.rns = [];
923
+ });
924
+ }
925
+
926
+ // =====================================================================
927
+ // Bridge — render a compat component from inside a native What tree
928
+ // =====================================================================
929
+
930
+ const bridgeCache = new WeakMap();
931
+
932
+ /**
933
+ * Returns a What-native component wrapper for a React component function.
934
+ * what-react's createElement uses this as the vnode tag so that:
935
+ * - core's renderer (whatMount / native What trees) can render compat
936
+ * components by calling the bridge as a regular run-once component,
937
+ * - THIS runtime recognizes compat vnodes via tag._compatType and renders
938
+ * them natively (the bridge is never called inside compat trees).
939
+ */
940
+ export function getBridge(renderFn) {
941
+ let bridge = bridgeCache.get(renderFn);
942
+ if (bridge) return bridge;
943
+
944
+ bridge = function CompatBridge(coreProps) {
945
+ // Snapshot core's reactive props proxy into a plain object.
946
+ const props = Object.assign({}, coreProps);
947
+ const vnode = {
948
+ tag: bridge, type: renderFn, props,
949
+ children: [], key: null, _vnode: true, _compat: true,
950
+ };
951
+ const frag = document.createDocumentFragment();
952
+ const rn = runInCommit(() => mountRNode(vnode, KIND_COMPONENT, frag, false, null));
953
+ try {
954
+ whatOnCleanup(() => unmountRNode(rn, false));
955
+ } catch (e) {
956
+ // Not inside a core component (unusual) — leak-free unmount unavailable.
957
+ }
958
+ return frag;
959
+ };
960
+ bridge._compatType = renderFn;
961
+ bridge.displayName = renderFn.displayName || renderFn.name || 'CompatBridge';
962
+
963
+ bridgeCache.set(renderFn, bridge);
964
+ return bridge;
965
+ }
966
+
967
+ // =====================================================================
968
+ // DOM props — React prop semantics (className, style px, onChange→input, ...)
969
+ // =====================================================================
970
+
971
+ function applyRef(ref, value) {
972
+ if (typeof ref === 'function') ref(value);
973
+ else if (ref && typeof ref === 'object') ref.current = value;
974
+ }
975
+
976
+ // preact's unitless-CSS-property test
977
+ const IS_NON_DIMENSIONAL = /acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;
978
+
979
+ function setStyleValue(style, key, value) {
980
+ if (key[0] === '-') {
981
+ style.setProperty(key, value == null ? '' : value);
982
+ } else if (value == null) {
983
+ style[key] = '';
984
+ } else if (typeof value === 'number' && !IS_NON_DIMENSIONAL.test(key)) {
985
+ style[key] = value + 'px';
986
+ } else {
987
+ style[key] = value;
988
+ }
989
+ }
990
+
991
+ function setStyle(el, value, oldValue) {
992
+ if (typeof value === 'string') {
993
+ el.style.cssText = value;
994
+ return;
995
+ }
996
+ if (typeof oldValue === 'string') {
997
+ el.style.cssText = '';
998
+ oldValue = null;
999
+ }
1000
+ if (oldValue && typeof oldValue === 'object') {
1001
+ for (const k in oldValue) {
1002
+ if (!(value && k in value)) setStyleValue(el.style, k, '');
1003
+ }
1004
+ }
1005
+ if (value && typeof value === 'object') {
1006
+ for (const k in value) {
1007
+ if (!oldValue || oldValue[k] !== value[k]) setStyleValue(el.style, k, value[k]);
1008
+ }
1009
+ }
1010
+ }
1011
+
1012
+ // React's onChange fires per keystroke — native 'input' for text-like controls.
1013
+ function changeEventFor(el) {
1014
+ const tag = el.tagName;
1015
+ if (tag === 'SELECT') return 'change';
1016
+ if (tag === 'TEXTAREA') return 'input';
1017
+ if (tag === 'INPUT') {
1018
+ const t = el.type;
1019
+ if (t === 'checkbox' || t === 'radio' || t === 'file') return 'change';
1020
+ return 'input';
1021
+ }
1022
+ return 'change';
1023
+ }
1024
+
1025
+ function eventProxy(e) {
1026
+ if (!e.nativeEvent) e.nativeEvent = e;
1027
+ if (!e.persist) e.persist = noop;
1028
+ const handler = this._compatListeners && this._compatListeners[e.type];
1029
+ if (handler) return handler(e);
1030
+ }
1031
+
1032
+ function eventProxyCapture(e) {
1033
+ if (!e.nativeEvent) e.nativeEvent = e;
1034
+ if (!e.persist) e.persist = noop;
1035
+ const handler = this._compatListenersCapture && this._compatListenersCapture[e.type];
1036
+ if (handler) return handler(e);
1037
+ }
1038
+
1039
+ function noop() {}
1040
+
1041
+ function setEvent(el, name, value) {
1042
+ let base = name.slice(2);
1043
+ let useCapture = false;
1044
+ if (base.endsWith('Capture')) {
1045
+ useCapture = true;
1046
+ base = base.slice(0, -7);
1047
+ }
1048
+ let event = base.toLowerCase();
1049
+ if (event === 'doubleclick') event = 'dblclick';
1050
+ else if (event === 'change') event = changeEventFor(el);
1051
+ else if (event === 'focus') event = 'focusin'; // React synthetic focus bubbles
1052
+ else if (event === 'blur') event = 'focusout';
1053
+
1054
+ const store = useCapture
1055
+ ? (el._compatListenersCapture || (el._compatListenersCapture = {}))
1056
+ : (el._compatListeners || (el._compatListeners = {}));
1057
+ const proxy = useCapture ? eventProxyCapture : eventProxy;
1058
+ const had = store[event];
1059
+ if (value) {
1060
+ store[event] = value;
1061
+ if (!had) el.addEventListener(event, proxy, useCapture);
1062
+ } else if (had) {
1063
+ delete store[event];
1064
+ el.removeEventListener(event, proxy, useCapture);
1065
+ }
1066
+ }
1067
+
1068
+ function setValueProp(el, value) {
1069
+ if (el.tagName === 'SELECT') {
1070
+ const str = value == null ? '' : String(value);
1071
+ el.value = str;
1072
+ if (el.value !== str) {
1073
+ queueMicrotask(() => { el.value = str; });
1074
+ }
1075
+ return;
1076
+ }
1077
+ const str = value == null ? '' : String(value);
1078
+ if (el.value !== str) el.value = str; // guard preserves caret position
1079
+ }
1080
+
1081
+ export function setProperty(el, name, value, oldValue, svg) {
1082
+ if (name === 'children' || name === 'key' || name === 'ref') return;
1083
+
1084
+ if (name === 'class' || name === 'className') {
1085
+ if (svg) el.setAttribute('class', value || '');
1086
+ else el.className = value || '';
1087
+ return;
1088
+ }
1089
+ if (name === 'htmlFor' || name === 'for') {
1090
+ if (value == null) el.removeAttribute('for');
1091
+ else el.setAttribute('for', value);
1092
+ return;
1093
+ }
1094
+ if (name === 'style') {
1095
+ setStyle(el, value, oldValue);
1096
+ return;
1097
+ }
1098
+ if (name[0] === 'o' && name[1] === 'n' && name.length > 2) {
1099
+ setEvent(el, name, value);
1100
+ return;
1101
+ }
1102
+ if (name === 'dangerouslySetInnerHTML') {
1103
+ el.innerHTML = (value && value.__html) || '';
1104
+ return;
1105
+ }
1106
+ if (name === 'value') {
1107
+ setValueProp(el, value);
1108
+ return;
1109
+ }
1110
+ if (name === 'checked') {
1111
+ el.checked = !!value;
1112
+ return;
1113
+ }
1114
+ if (name === 'defaultValue') {
1115
+ if ('defaultValue' in el) el.defaultValue = value == null ? '' : value;
1116
+ return;
1117
+ }
1118
+ if (name === 'defaultChecked') {
1119
+ el.defaultChecked = !!value;
1120
+ return;
1121
+ }
1122
+ if (name.startsWith('data-') || name.startsWith('aria-')) {
1123
+ if (value == null || value === false) el.removeAttribute(name);
1124
+ else el.setAttribute(name, value === true ? 'true' : value);
1125
+ return;
1126
+ }
1127
+
1128
+ if (svg) {
1129
+ if (name === 'xlinkHref') {
1130
+ el.setAttributeNS('http://www.w3.org/1999/xlink', 'href', value);
1131
+ return;
1132
+ }
1133
+ if (value == null || value === false) el.removeAttribute(name);
1134
+ else el.setAttribute(name, value === true ? '' : value);
1135
+ return;
1136
+ }
1137
+
1138
+ // Property when available, attribute otherwise.
1139
+ if (name !== 'list' && name !== 'form' && name !== 'tagName' && name !== 'download' && name in el) {
1140
+ try {
1141
+ el[name] = value == null ? '' : value;
1142
+ return;
1143
+ } catch (e) { /* read-only property — fall through to attribute */ }
1144
+ }
1145
+ if (value == null || value === false) el.removeAttribute(name);
1146
+ else el.setAttribute(name, value === true ? '' : value);
1147
+ }