@ipxjs/refract 0.11.0 → 0.13.0

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/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  ![](assets/lens-syntax-refract.svg "=x200")
2
2
 
3
- # Refract
3
+ [Info](https://refract.ipx.dev) | [Docs](https://refract.ipx.dev/docs)
4
+
5
+ # Refract
4
6
 
5
7
  A minimal React-like virtual DOM library, written in TypeScript with split entrypoints
6
8
  so you can keep bundles small and targeted.
@@ -94,9 +96,13 @@ Supported compat APIs include:
94
96
  - `forwardRef`, `cloneElement`, `Children`, `isValidElement`
95
97
  - `useLayoutEffect`, `useInsertionEffect`, `useId`
96
98
  - `useSyncExternalStore`, `useImperativeHandle`
99
+ - `use` — suspends on Promise (with a `promiseCache` WeakMap) or reads a context object
100
+ - `Suspense` — renders a fallback subtree while a child suspends
97
101
  - `createPortal`
98
102
  - `createRoot`, `flushSync`, `unstable_batchedUpdates`
103
+ - `findDOMNode` stub (returns `null`; prevents crashes from libraries like `react-transition-group` v4)
99
104
  - `jsx/jsxs/jsxDEV` runtime entrypoints
105
+ - `$$typeof` stamped on every VNode (`Symbol.for("react.element")`) so `react-is` helpers work
100
106
  - React hook dispatcher bridge internals (`__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE`) with optional `registerExternalReactModule(...)` for mixed-runtime environments (tests/Node)
101
107
 
102
108
  Example Vite aliases:
@@ -121,10 +127,10 @@ export default defineConfig({
121
127
  The compat layer is intentionally separate from core so users who do not need
122
128
  React ecosystem compatibility keep the smallest and fastest Refract bundles.
123
129
 
124
- Compatibility status (last verified February 17, 2026):
125
- - `yarn test`: 14 files passed, 85 tests passed
130
+ Compatibility status (last verified February 22, 2026):
131
+ - `yarn test`: 14 files passed, 91 tests passed
126
132
  - Compat-focused suites passed: `tests/compat.test.ts` (10), `tests/poc-compat.test.ts` (2), `tests/react-router-smoke.test.ts` (3)
127
- - Verified behaviors include `forwardRef`, portals, `createRoot`, JSX runtimes, `useSyncExternalStore`, `flushSync`, and react-router tree construction/dispatcher bridging
133
+ - Verified behaviors include `forwardRef`, portals, `createRoot`, JSX runtimes, `useSyncExternalStore`, `flushSync`, react-router tree construction/dispatcher bridging, `React.use` (Promise + context), and Suspense boundary fallback rendering
128
134
 
129
135
  ## API
130
136
 
@@ -318,6 +324,7 @@ How Refract compares to React and Preact:
318
324
  | useId | Yes | Yes | Yes |
319
325
  | useSyncExternalStore | Yes | Yes | Yes |
320
326
  | useImperativeHandle | Yes | Yes | Yes |
327
+ | use | Yes⁶ | Yes | No |
321
328
  | useTransition / useDeferredValue | Partial⁵ | Yes | Partial⁷ |
322
329
  | **State & Data Flow** | | | |
323
330
  | Built-in state management | Yes | Yes | Yes |
@@ -330,7 +337,7 @@ How Refract compares to React and Preact:
330
337
  | className prop | Yes | Yes | Yes¹ |
331
338
  | dangerouslySetInnerHTML | Yes | Yes | Yes |
332
339
  | Portals | Yes | Yes | Yes |
333
- | Suspense / lazy | No | Yes | Yes² |
340
+ | Suspense / lazy | Partial | Yes | Yes² |
334
341
  | Error boundaries | Yes³ | Yes | Yes |
335
342
  | Server-side rendering | No | Yes | Yes |
336
343
  | Hydration | No⁹ | Yes | Yes |
@@ -354,7 +361,7 @@ How Refract compares to React and Preact:
354
361
  ⁵ Refract exposes `useTransition` / `useDeferredValue` but currently runs both synchronously (no concurrent scheduling).
355
362
  ⁶ Available via opt-in compat entrypoints (`refract/compat/react*`) with partial React API parity.
356
363
  ⁷ Preact compatibility is provided through `preact/compat`.
357
- ⁸ Compat exports `Suspense`/`lazy`, but full suspension/fallback semantics are not implemented.
364
+ ⁸ Compat `Suspense` renders a fallback while a child suspends (via `React.use` + `useState` boundary). `lazy` is exported but code-splitting semantics are limited.
358
365
  ⁹ `hydrateRoot` is exposed in compat, but currently performs client render rather than true SSR hydration.
359
366
  ¹⁰ `memo` is supported; `PureComponent` is compat-oriented and does not guarantee full React shallow-compare behavior.
360
367
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ipxjs/refract",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "A minimal React-like virtual DOM library with an optional React compat layer",
5
5
  "type": "module",
6
6
  "main": "src/refract/index.ts",
@@ -16,6 +16,14 @@ export function unstable_batchedUpdates<T>(callback: () => T): T {
16
16
  return callback();
17
17
  }
18
18
 
19
+ // findDOMNode was removed in React 19. react-transition-group v4 calls it
20
+ // when no nodeRef prop is provided. Returning null avoids a hard crash; any
21
+ // guarded usage (if node) degrades gracefully while unguarded usage (e.g.
22
+ // CSSTransition without nodeRef) will silently skip the animation.
23
+ export function findDOMNode(_instance: unknown): Element | null {
24
+ return null;
25
+ }
26
+
19
27
  export function flushSync<T>(callback: () => T): T {
20
28
  const result = callback();
21
29
  flushPendingRenders();
@@ -34,6 +42,7 @@ export function unmountComponentAtNode(container: HTMLElement): boolean {
34
42
 
35
43
  const ReactDomCompat = {
36
44
  createPortal,
45
+ findDOMNode,
37
46
  flushSync,
38
47
  render: renderCompat,
39
48
  unstable_batchedUpdates,
@@ -2,6 +2,8 @@ import { createElement, Fragment } from "../createElement.js";
2
2
  import type { VNode, VNodeType } from "../types.js";
3
3
  import { resolveCompatType, getWrappedHandler } from "./react.js";
4
4
 
5
+ const REACT_ELEMENT_TYPE = Symbol.for("react.element");
6
+
5
7
  type JsxProps = Record<string, unknown> | null | undefined;
6
8
  type JsxChild = VNode | string | number | boolean | null | undefined | JsxChild[];
7
9
 
@@ -45,6 +47,7 @@ function createJsxElement(type: VNodeType, rawProps: JsxProps, key?: string): Re
45
47
  } else {
46
48
  vnode = createElement(effectiveType as VNodeType, props, children as JsxChild);
47
49
  }
50
+ (vnode as any).$$typeof = REACT_ELEMENT_TYPE;
48
51
  return normalizeVNodeChildren(vnode);
49
52
  }
50
53
 
@@ -1,10 +1,11 @@
1
- import { createElement as createElementImpl, Fragment } from "../createElement.js";
1
+ import { createElement as createElementImpl, Fragment as InternalFragment } from "../createElement.js";
2
2
  import { memo } from "../memo.js";
3
3
  import { createContext as createContextImpl, setContextValue } from "../features/context.js";
4
4
  import {
5
5
  createRef,
6
6
  useErrorBoundary,
7
7
  } from "../features/hooks.js";
8
+ import { currentFiber } from "../coreRenderer.js";
8
9
  import type { Component as RefractComponent, Props, VNode } from "../types.js";
9
10
  import {
10
11
  __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,
@@ -41,6 +42,9 @@ export function forwardRef<T, P extends Record<string, unknown> = Record<string,
41
42
  return render(rest as unknown as P, ref ?? null);
42
43
  };
43
44
  (ForwardRefComponent as any).displayName = `ForwardRef(${(render as any).name || 'anonymous'})`;
45
+ // Stamp $$typeof so react-is.isForwardRef(element) works when this component
46
+ // is used as the VNode type.
47
+ (ForwardRefComponent as any).$$typeof = REACT_FORWARD_REF_TYPE;
44
48
  return ForwardRefComponent;
45
49
  }
46
50
 
@@ -87,11 +91,13 @@ function childrenToArray(children: unknown): unknown[] {
87
91
  if (children === undefined || children === null) return [];
88
92
  if (!Array.isArray(children)) return [children];
89
93
  const out: unknown[] = [];
90
- const stack = [...children];
94
+ const stack: unknown[] = [children];
91
95
  while (stack.length > 0) {
92
- const child = stack.shift();
96
+ const child = stack.pop();
93
97
  if (Array.isArray(child)) {
94
- stack.unshift(...child);
98
+ for (let i = child.length - 1; i >= 0; i--) {
99
+ stack.push(child[i]);
100
+ }
95
101
  continue;
96
102
  }
97
103
  if (child === undefined || child === null || typeof child === "boolean") {
@@ -269,7 +275,54 @@ PureComponent.prototype.isPureReactComponent = true;
269
275
  // Suspense / Lazy
270
276
  // ---------------------------------------------------------------------------
271
277
 
278
+ // Cache for promises passed to use() — maps a Promise to its settled result
279
+ // so subsequent renders can return the value instead of throwing again.
280
+ type PromiseResult<T> =
281
+ | { status: 'pending' }
282
+ | { status: 'fulfilled'; value: T }
283
+ | { status: 'rejected'; reason: unknown };
284
+
285
+ const promiseCache = new WeakMap<Promise<unknown>, PromiseResult<unknown>>();
286
+
287
+ export function use<T>(value: Promise<T> | unknown): T {
288
+ if (value instanceof Promise) {
289
+ let result = promiseCache.get(value as Promise<unknown>) as PromiseResult<T> | undefined;
290
+ if (!result) {
291
+ result = { status: 'pending' };
292
+ promiseCache.set(value as Promise<unknown>, result as PromiseResult<unknown>);
293
+ (value as Promise<T>).then(
294
+ (v) => { (result as any).status = 'fulfilled'; (result as any).value = v; },
295
+ (e) => { (result as any).status = 'rejected'; (result as any).reason = e; },
296
+ );
297
+ }
298
+ if (result.status === 'fulfilled') return (result as { status: 'fulfilled'; value: T }).value;
299
+ if (result.status === 'rejected') throw (result as { status: 'rejected'; reason: unknown }).reason;
300
+ throw value; // pending — triggers Suspense boundary
301
+ }
302
+ // Treat non-Promise values as context objects (React 19 use(Context) pattern).
303
+ return useContext(value as unknown) as T;
304
+ }
305
+
306
+ // Suspense is a real component so it can use useState to toggle between the
307
+ // fallback and live children, and so the renderer can attach a
308
+ // _suspenseHandler to its fiber for thrown-promise recovery.
272
309
  export function Suspense(props: { children?: unknown; fallback?: unknown }): unknown {
310
+ const [suspended, setSuspended] = useState(false);
311
+
312
+ // Register the promise handler on this render's fiber so that any
313
+ // descendant that throws a Promise can find and invoke it.
314
+ const fiber = currentFiber;
315
+ if (fiber) {
316
+ fiber._suspenseHandler = (promise: Promise<unknown>) => {
317
+ setSuspended(true);
318
+ promise.then(
319
+ () => setSuspended(false),
320
+ () => setSuspended(false),
321
+ );
322
+ };
323
+ }
324
+
325
+ if (suspended) return props.fallback ?? null;
273
326
  return props.children ?? null;
274
327
  }
275
328
 
@@ -421,6 +474,8 @@ export function resolveCompatType(type: unknown): unknown {
421
474
  };
422
475
  (wrappedForwardRef as any).displayName = exoticType.displayName
423
476
  ?? `ForwardRef(${(render as any).name || "anonymous"})`;
477
+ // Preserve $$typeof on the wrapper so react-is.isForwardRef(element) works.
478
+ (wrappedForwardRef as any).$$typeof = REACT_FORWARD_REF_TYPE;
424
479
  exoticTypeCache.set(type as object, wrappedForwardRef);
425
480
  return wrappedForwardRef;
426
481
  }
@@ -451,7 +506,7 @@ export function resolveCompatType(type: unknown): unknown {
451
506
  if (Array.isArray(children)) {
452
507
  return children.length === 1
453
508
  ? children[0] as VNode
454
- : createElementImpl(Fragment, null, ...(children as ElementChild[]));
509
+ : createElementImpl(InternalFragment, null, ...(children as ElementChild[]));
455
510
  }
456
511
  return children as VNode;
457
512
  };
@@ -533,6 +588,7 @@ function normalizeChildrenSingle(vnode: any): any {
533
588
  export function createElement(type: unknown, props?: unknown, ...children: unknown[]): VNode {
534
589
  const effectiveType = resolveCompatType(type);
535
590
 
591
+ let vnode: VNode;
536
592
  if (typeof type === 'string' && props && typeof props === 'object') {
537
593
  const newProps = { ...props } as Record<string, unknown>;
538
594
  let hasChanges = false;
@@ -543,18 +599,29 @@ export function createElement(type: unknown, props?: unknown, ...children: unkno
543
599
  }
544
600
  }
545
601
  if (hasChanges) {
546
- return normalizeChildrenSingle(createElementImpl(effectiveType as any, newProps, ...(children as any[])));
602
+ vnode = normalizeChildrenSingle(createElementImpl(effectiveType as any, newProps, ...(children as any[])));
603
+ } else {
604
+ vnode = normalizeChildrenSingle(createElementImpl(effectiveType as any, props as any, ...(children as any[])));
547
605
  }
606
+ } else {
607
+ vnode = normalizeChildrenSingle(createElementImpl(effectiveType as any, props as any, ...(children as any[])));
548
608
  }
549
609
 
550
- return normalizeChildrenSingle(createElementImpl(effectiveType as any, props as any, ...(children as any[])));
610
+ // Stamp $$typeof so react-is.isValidElement / isFragment / isForwardRef etc.
611
+ // work correctly on VNodes produced by the compat layer.
612
+ (vnode as any).$$typeof = REACT_ELEMENT_TYPE;
613
+ return vnode;
551
614
  }
552
615
 
553
616
  // ---------------------------------------------------------------------------
554
617
  // Export
555
618
  // ---------------------------------------------------------------------------
556
619
 
557
- export { Fragment, memo };
620
+ // Export the standard React fragment symbol so react-is.isFragment() works
621
+ // correctly. The renderer recognises both this and the internal refract.fragment
622
+ // symbol (see coreRenderer.ts REACT_FRAGMENT_TYPE).
623
+ export const Fragment = Symbol.for("react.fragment");
624
+ export { memo };
558
625
  export {
559
626
  createRef,
560
627
  registerExternalReactModule,
@@ -571,6 +638,7 @@ const ReactCompat = {
571
638
  PureComponent,
572
639
  Suspense,
573
640
  lazy,
641
+ use,
574
642
  createContext,
575
643
  createElement,
576
644
  cloneElement,
@@ -107,6 +107,7 @@ const secretInternals: ReactSecretInternalsCompat = {
107
107
  const externalClientInternals = new Set<ReactClientInternalsCompat>();
108
108
  const externalSecretInternals = new Set<ReactSecretInternalsCompat>();
109
109
  const dispatcherStack: (RefractHookDispatcher | null)[] = [];
110
+ const MAX_EXTERNAL_INTERNALS = 8;
110
111
 
111
112
  let runtimeInitialized = false;
112
113
 
@@ -135,7 +136,7 @@ export function registerExternalReactModule(moduleValue: unknown): void {
135
136
 
136
137
  const candidateClient = moduleRecord.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
137
138
  if (candidateClient && typeof candidateClient === "object" && "H" in (candidateClient as Record<string, unknown>)) {
138
- externalClientInternals.add(candidateClient as ReactClientInternalsCompat);
139
+ addBoundedInternal(externalClientInternals, candidateClient as ReactClientInternalsCompat);
139
140
  }
140
141
 
141
142
  const candidateSecret = moduleRecord.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
@@ -147,12 +148,23 @@ export function registerExternalReactModule(moduleValue: unknown): void {
147
148
  && typeof dispatcherHolder.ReactCurrentDispatcher === "object"
148
149
  && "current" in (dispatcherHolder.ReactCurrentDispatcher as Record<string, unknown>)
149
150
  ) {
150
- externalSecretInternals.add(candidateSecret as ReactSecretInternalsCompat);
151
+ addBoundedInternal(externalSecretInternals, candidateSecret as ReactSecretInternalsCompat);
151
152
  }
152
153
 
153
154
  syncDispatcherToExternal();
154
155
  }
155
156
 
157
+ function addBoundedInternal<T>(set: Set<T>, value: T): void {
158
+ if (set.has(value)) return;
159
+ if (set.size >= MAX_EXTERNAL_INTERNALS) {
160
+ const oldest = set.values().next().value as T | undefined;
161
+ if (oldest !== undefined) {
162
+ set.delete(oldest);
163
+ }
164
+ }
165
+ set.add(value);
166
+ }
167
+
156
168
  function beforeComponentRender(): void {
157
169
  dispatcherStack.push(clientInternals.H);
158
170
  setDispatcher(dispatcher);
@@ -54,8 +54,12 @@ export function renderFiber(vnode: VNode, container: Node): void {
54
54
  };
55
55
  deletions = [];
56
56
  isRendering = true;
57
- performWork(rootFiber);
58
- isRendering = false;
57
+ try {
58
+ performWork(rootFiber);
59
+ } finally {
60
+ isRendering = false;
61
+ currentFiber = null;
62
+ }
59
63
  const committedDeletions = deletions.slice();
60
64
  commitRoot(rootFiber);
61
65
  clearAlternates(rootFiber);
@@ -90,6 +94,7 @@ function processWorkUnit(fiber: Fiber): boolean {
90
94
  if (!tryHandleRenderError(fiber, error)) throw error;
91
95
  } finally {
92
96
  runAfterComponentRenderHandlers(fiber);
97
+ currentFiber = null;
93
98
  }
94
99
  } else if (isFragment) {
95
100
  reconcileChildren(fiber, normalizeChildrenProp(fiber.props.children));
@@ -208,7 +213,7 @@ function flattenRenderedChildren(raw: RenderedChild[]): VNode[] {
208
213
  continue;
209
214
  }
210
215
  if (typeof child === "object") {
211
- const record = child as Record<string, unknown>;
216
+ const record = child as unknown as Record<string, unknown>;
212
217
  if ("type" in record && "props" in record) {
213
218
  const rawKey = record.key;
214
219
  result.push({
@@ -254,33 +259,57 @@ function getNextDomSibling(fiber: Fiber): Node | null {
254
259
  /** Collect all DOM nodes from a component/fragment fiber's subtree */
255
260
  function collectChildDomNodes(fiber: Fiber): Node[] {
256
261
  const nodes: Node[] = [];
257
- function walk(f: Fiber | null): void {
258
- while (f) {
259
- if (isPortalFiber(f)) {
260
- f = f.sibling;
261
- continue;
262
- }
263
- if (f.dom) {
264
- nodes.push(f.dom);
265
- } else {
266
- walk(f.child);
267
- }
268
- f = f.sibling;
262
+ const stack: Fiber[] = [];
263
+ const rootChildren: Fiber[] = [];
264
+ let child = fiber.child;
265
+ while (child) {
266
+ rootChildren.push(child);
267
+ child = child.sibling;
268
+ }
269
+ for (let i = rootChildren.length - 1; i >= 0; i--) {
270
+ stack.push(rootChildren[i]);
271
+ }
272
+
273
+ while (stack.length > 0) {
274
+ const current = stack.pop()!;
275
+ if (isPortalFiber(current)) {
276
+ continue;
277
+ }
278
+ if (current.dom) {
279
+ nodes.push(current.dom);
280
+ continue;
281
+ }
282
+ const children: Fiber[] = [];
283
+ let next = current.child;
284
+ while (next) {
285
+ children.push(next);
286
+ next = next.sibling;
287
+ }
288
+ for (let i = children.length - 1; i >= 0; i--) {
289
+ stack.push(children[i]);
269
290
  }
270
291
  }
271
- walk(fiber.child);
272
292
  return nodes;
273
293
  }
274
294
 
275
295
  /** Get the first committed DOM node in a fiber subtree */
276
296
  function getFirstCommittedDom(fiber: Fiber): Node | null {
277
- if (isPortalFiber(fiber)) return null;
278
- if (fiber.dom && !(fiber.flags & PLACEMENT)) return fiber.dom;
279
- let child = fiber.child;
280
- while (child) {
281
- const dom = getFirstCommittedDom(child);
282
- if (dom) return dom;
283
- child = child.sibling;
297
+ const stack: Fiber[] = [fiber];
298
+ while (stack.length > 0) {
299
+ const current = stack.pop()!;
300
+ if (isPortalFiber(current)) continue;
301
+ if (current.dom && !(current.flags & PLACEMENT)) {
302
+ return current.dom;
303
+ }
304
+ const children: Fiber[] = [];
305
+ let child = current.child;
306
+ while (child) {
307
+ children.push(child);
308
+ child = child.sibling;
309
+ }
310
+ for (let i = children.length - 1; i >= 0; i--) {
311
+ stack.push(children[i]);
312
+ }
284
313
  }
285
314
  return null;
286
315
  }
@@ -296,69 +325,74 @@ function commitRoot(rootFiber: Fiber): void {
296
325
  }
297
326
 
298
327
  function commitWork(fiber: Fiber): void {
299
- if (isPortalFiber(fiber)) {
300
- fiber.flags = 0;
301
- if (fiber.child) commitWork(fiber.child);
302
- if (fiber.sibling) commitWork(fiber.sibling);
303
- return;
304
- }
328
+ const stack: Fiber[] = [fiber];
329
+ while (stack.length > 0) {
330
+ const current = stack.pop()!;
331
+ if (isPortalFiber(current)) {
332
+ current.flags = 0;
333
+ if (current.sibling) stack.push(current.sibling);
334
+ if (current.child) stack.push(current.child);
335
+ continue;
336
+ }
305
337
 
306
- let parentFiber = fiber.parent;
307
- while (parentFiber && !parentFiber.dom) {
308
- parentFiber = parentFiber.parent;
309
- }
310
- const parentDom = parentFiber!.dom!;
311
-
312
- if (fiber.flags & PLACEMENT) {
313
- if (fiber.dom) {
314
- const before = getNextDomSibling(fiber);
315
- if (before) {
316
- parentDom.insertBefore(fiber.dom, before);
317
- } else {
318
- parentDom.appendChild(fiber.dom);
319
- }
320
- } else {
321
- // Component/fragment: move all child DOM nodes
322
- const domNodes = collectChildDomNodes(fiber);
323
- const before = getNextDomSibling(fiber);
324
- for (const dom of domNodes) {
325
- if (before) {
326
- parentDom.insertBefore(dom, before);
338
+ let parentFiber = current.parent;
339
+ while (parentFiber && !parentFiber.dom) {
340
+ parentFiber = parentFiber.parent;
341
+ }
342
+ const parentDom = parentFiber?.dom;
343
+
344
+ if (parentDom) {
345
+ if (current.flags & PLACEMENT) {
346
+ if (current.dom) {
347
+ const before = getNextDomSibling(current);
348
+ if (before) {
349
+ parentDom.insertBefore(current.dom, before);
350
+ } else {
351
+ parentDom.appendChild(current.dom);
352
+ }
327
353
  } else {
328
- parentDom.appendChild(dom);
354
+ // Component/fragment: move all child DOM nodes
355
+ const domNodes = collectChildDomNodes(current);
356
+ const before = getNextDomSibling(current);
357
+ for (const dom of domNodes) {
358
+ if (before) {
359
+ parentDom.insertBefore(dom, before);
360
+ } else {
361
+ parentDom.appendChild(dom);
362
+ }
363
+ }
364
+ }
365
+ } else if (current.flags & UPDATE && current.dom) {
366
+ if (current.type === "TEXT") {
367
+ const oldValue = current.alternate?.props.nodeValue;
368
+ if (oldValue !== current.props.nodeValue) {
369
+ current.dom.textContent = current.props.nodeValue as string;
370
+ }
371
+ } else {
372
+ applyProps(
373
+ current.dom as HTMLElement,
374
+ current.alternate?.props ?? {},
375
+ current.props,
376
+ );
329
377
  }
330
378
  }
331
379
  }
332
- } else if (fiber.flags & UPDATE && fiber.dom) {
333
- if (fiber.type === "TEXT") {
334
- const oldValue = fiber.alternate?.props.nodeValue;
335
- if (oldValue !== fiber.props.nodeValue) {
336
- fiber.dom.textContent = fiber.props.nodeValue as string;
337
- }
338
- } else {
339
- applyProps(
340
- fiber.dom as HTMLElement,
341
- fiber.alternate?.props ?? {},
342
- fiber.props,
343
- );
344
- }
345
- }
346
380
 
347
- // Handle ref prop — only on mount or when ref changes (like React)
348
- if (fiber.dom && fiber.props.ref) {
349
- const oldRef = fiber.alternate?.props.ref;
350
- if (fiber.flags & PLACEMENT || fiber.props.ref !== oldRef) {
351
- if (oldRef && oldRef !== fiber.props.ref) {
352
- setRef(oldRef, null);
381
+ // Handle ref prop — only on mount or when ref changes (like React)
382
+ if (current.dom && current.props.ref) {
383
+ const oldRef = current.alternate?.props.ref;
384
+ if (current.flags & PLACEMENT || current.props.ref !== oldRef) {
385
+ if (oldRef && oldRef !== current.props.ref) {
386
+ setRef(oldRef, null);
387
+ }
388
+ setRef(current.props.ref, current.dom);
353
389
  }
354
- setRef(fiber.props.ref, fiber.dom);
355
390
  }
356
- }
357
-
358
- fiber.flags = 0;
359
391
 
360
- if (fiber.child) commitWork(fiber.child);
361
- if (fiber.sibling) commitWork(fiber.sibling);
392
+ current.flags = 0;
393
+ if (current.sibling) stack.push(current.sibling);
394
+ if (current.child) stack.push(current.child);
395
+ }
362
396
  }
363
397
 
364
398
  function setRef(ref: unknown, value: Node | null): void {
@@ -373,41 +407,51 @@ function setRef(ref: unknown, value: Node | null): void {
373
407
  * Alternates are only needed during reconciliation; retaining them
374
408
  * creates an ever-growing chain of old fiber trees. */
375
409
  function clearAlternates(fiber: Fiber | null): void {
376
- while (fiber) {
377
- fiber.alternate = null;
378
- if (fiber.child) clearAlternates(fiber.child);
379
- fiber = fiber.sibling;
410
+ if (!fiber) return;
411
+ const stack: Fiber[] = [fiber];
412
+ while (stack.length > 0) {
413
+ const current = stack.pop()!;
414
+ current.alternate = null;
415
+ if (current.sibling) stack.push(current.sibling);
416
+ if (current.child) stack.push(current.child);
380
417
  }
381
418
  }
382
419
 
383
420
  function commitDeletion(fiber: Fiber): void {
384
421
  runCleanups(fiber);
385
- // Clear ref on unmount
386
- if (fiber.dom && fiber.props.ref) {
387
- setRef(fiber.props.ref, null);
388
- }
389
- if (isPortalFiber(fiber)) {
390
- let child: Fiber | null = fiber.child;
391
- while (child) {
392
- commitDeletion(child);
393
- child = child.sibling;
422
+ walkSubtree(fiber, (node) => {
423
+ if (node.dom && node.props.ref) {
424
+ setRef(node.props.ref, null);
394
425
  }
395
- } else if (fiber.dom) {
396
- fiber.dom.parentNode?.removeChild(fiber.dom);
397
- } else if (fiber.child) {
398
- // Fragment/component — delete children
399
- let child: Fiber | null = fiber.child;
400
- while (child) {
401
- commitDeletion(child);
402
- child = child.sibling;
426
+ });
427
+ walkSubtree(fiber, (node) => {
428
+ if (!isPortalFiber(node) && node.dom) {
429
+ node.dom.parentNode?.removeChild(node.dom);
403
430
  }
404
- }
431
+ });
405
432
  }
406
433
 
407
434
  function runCleanups(fiber: Fiber): void {
408
- runFiberCleanupHandlers(fiber);
409
- if (fiber.child) runCleanups(fiber.child);
410
- if (fiber.sibling) runCleanups(fiber.sibling);
435
+ walkSubtree(fiber, (node) => {
436
+ runFiberCleanupHandlers(node);
437
+ });
438
+ }
439
+
440
+ function walkSubtree(root: Fiber, visit: (fiber: Fiber) => void): void {
441
+ const stack: Fiber[] = [root];
442
+ while (stack.length > 0) {
443
+ const current = stack.pop()!;
444
+ visit(current);
445
+ const children: Fiber[] = [];
446
+ let child = current.child;
447
+ while (child) {
448
+ children.push(child);
449
+ child = child.sibling;
450
+ }
451
+ for (let i = children.length - 1; i >= 0; i--) {
452
+ stack.push(children[i]);
453
+ }
454
+ }
411
455
  }
412
456
 
413
457
  const pendingContainers = new Set<Node>();
@@ -473,8 +517,12 @@ function flushRenders(): void {
473
517
  };
474
518
  deletions = [];
475
519
  isRendering = true;
476
- performWork(newRoot);
477
- isRendering = false;
520
+ try {
521
+ performWork(newRoot);
522
+ } finally {
523
+ isRendering = false;
524
+ currentFiber = null;
525
+ }
478
526
  const committedDeletions = deletions.slice();
479
527
  commitRoot(newRoot);
480
528
  clearAlternates(newRoot);
@@ -36,9 +36,11 @@ export function useState<T>(initial: T | (() => T)): [T, (value: T | ((prev: T)
36
36
  // Create a stable setter that is reused across renders (like React)
37
37
  if (!hook._setter) {
38
38
  hook._setter = (value: T | ((prev: T) => T)) => {
39
+ if (!hook._fiber) return;
39
40
  const action = typeof value === "function"
40
41
  ? value as (prev: T) => T
41
42
  : () => value;
43
+ if (!hook.queue) hook.queue = [];
42
44
  (hook.queue as ((prev: T) => T)[]).push(action);
43
45
  scheduleRender(hook._fiber!);
44
46
  };
@@ -191,6 +193,8 @@ export function useReducer<S, A, I>(
191
193
  // Create stable dispatch (like React)
192
194
  if (!hook._dispatch) {
193
195
  hook._dispatch = (action: A) => {
196
+ if (!hook._fiber) return;
197
+ if (!hook.queue) hook.queue = [];
194
198
  (hook.queue as A[]).push(action);
195
199
  scheduleRender(hook._fiber!);
196
200
  };
@@ -33,6 +33,8 @@ function cleanupFiberEffects(fiber: Fiber): void {
33
33
  if (!fiber.hooks) return;
34
34
 
35
35
  for (const hook of fiber.hooks) {
36
+ hook.queue = undefined;
37
+ hook._fiber = undefined;
36
38
  const state = hook.state;
37
39
  if (!state || typeof state !== "object") continue;
38
40
  const effectState = state as { cleanup?: () => void; pending?: boolean };
@@ -109,7 +111,24 @@ function handleErrorBoundary(fiber: Fiber, error: unknown): boolean {
109
111
  return false;
110
112
  }
111
113
 
114
+ function handleSuspenseBoundary(fiber: Fiber, error: unknown): boolean {
115
+ if (!(error instanceof Promise)) return false;
116
+ let current: Fiber | null = fiber.parent;
117
+ while (current) {
118
+ if (current._suspenseHandler) {
119
+ current._suspenseHandler(error as Promise<unknown>);
120
+ reconcileChildren(fiber, []);
121
+ return true;
122
+ }
123
+ current = current.parent;
124
+ }
125
+ return false;
126
+ }
127
+
112
128
  registerFiberCleanupHandler(cleanupFiberEffects);
113
129
  registerAfterCommitHandler(runPendingEffects);
114
130
  registerBeforeRenderBatchHandler(flushPassiveEffects);
131
+ // Suspense must be registered before the error boundary so a thrown Promise
132
+ // is caught by the nearest Suspense boundary before reaching any error boundary.
133
+ registerRenderErrorHandler(handleSuspenseBoundary);
115
134
  registerRenderErrorHandler(handleErrorBoundary);
@@ -48,6 +48,7 @@ export interface Fiber {
48
48
  _contexts?: Map<number, unknown>;
49
49
  _objectContexts?: Map<object, unknown>;
50
50
  _errorHandler?: (error: unknown) => void;
51
+ _suspenseHandler?: (promise: Promise<unknown>) => void;
51
52
  alternate: Fiber | null;
52
53
  flags: number;
53
54
  }
@@ -71,6 +71,25 @@ describe("hooks", () => {
71
71
  expect(renderCount).toBe(2);
72
72
  expect(container.querySelector("span")!.textContent).toBe("3");
73
73
  });
74
+
75
+ it("ignores setState after unmount", async () => {
76
+ let setCount!: (v: number | ((p: number) => number)) => void;
77
+ let renders = 0;
78
+ function Counter() {
79
+ const [count, sc] = useState(0);
80
+ setCount = sc;
81
+ renders++;
82
+ return createElement("span", null, String(count));
83
+ }
84
+
85
+ render(createElement(Counter, null), container);
86
+ render(createElement("div", null, "gone"), container);
87
+
88
+ expect(() => setCount(1)).not.toThrow();
89
+ await new Promise((r) => setTimeout(r, 10));
90
+ expect(container.textContent).toBe("gone");
91
+ expect(renders).toBe(1);
92
+ });
74
93
  });
75
94
 
76
95
  describe("useEffect", () => {
@@ -269,5 +288,69 @@ describe("hooks", () => {
269
288
  await new Promise((r) => setTimeout(r, 10));
270
289
  expect(container.querySelector("span")!.textContent).toBe("2");
271
290
  });
291
+
292
+ it("ignores dispatch after unmount", async () => {
293
+ type Action = { type: "inc" };
294
+ let dispatch!: (action: Action) => void;
295
+ let renders = 0;
296
+ function Counter() {
297
+ const [count, d] = useReducer((state: number, action: Action) => {
298
+ if (action.type === "inc") return state + 1;
299
+ return state;
300
+ }, 0);
301
+ dispatch = d;
302
+ renders++;
303
+ return createElement("span", null, String(count));
304
+ }
305
+
306
+ render(createElement(Counter, null), container);
307
+ render(createElement("div", null, "gone"), container);
308
+
309
+ expect(() => dispatch({ type: "inc" })).not.toThrow();
310
+ await new Promise((r) => setTimeout(r, 10));
311
+ expect(container.textContent).toBe("gone");
312
+ expect(renders).toBe(1);
313
+ });
314
+ });
315
+
316
+ describe("cleanup scoping", () => {
317
+ it("only cleans up deleted subtree effects", async () => {
318
+ const leftCleanup = vi.fn();
319
+ const rightCleanup = vi.fn();
320
+ let setShowLeft!: (v: boolean) => void;
321
+
322
+ function Left() {
323
+ useEffect(() => leftCleanup, []);
324
+ return createElement("span", null, "left");
325
+ }
326
+
327
+ function Right() {
328
+ useEffect(() => rightCleanup, []);
329
+ return createElement("span", null, "right");
330
+ }
331
+
332
+ function App() {
333
+ const [showLeft, ss] = useState(true);
334
+ setShowLeft = ss;
335
+ return createElement(
336
+ "div",
337
+ null,
338
+ showLeft ? createElement(Left, { key: "left" }) : null,
339
+ createElement(Right, { key: "right" }),
340
+ );
341
+ }
342
+
343
+ render(createElement(App, null), container);
344
+ flushPassiveEffects();
345
+ expect(leftCleanup).toHaveBeenCalledTimes(0);
346
+ expect(rightCleanup).toHaveBeenCalledTimes(0);
347
+
348
+ setShowLeft(false);
349
+ await new Promise((r) => setTimeout(r, 10));
350
+ flushPassiveEffects();
351
+
352
+ expect(leftCleanup).toHaveBeenCalledTimes(1);
353
+ expect(rightCleanup).toHaveBeenCalledTimes(0);
354
+ });
272
355
  });
273
356
  });