@ipxjs/refract 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ipxjs/refract",
3
- "version": "0.12.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",
@@ -91,11 +91,13 @@ function childrenToArray(children: unknown): unknown[] {
91
91
  if (children === undefined || children === null) return [];
92
92
  if (!Array.isArray(children)) return [children];
93
93
  const out: unknown[] = [];
94
- const stack = [...children];
94
+ const stack: unknown[] = [children];
95
95
  while (stack.length > 0) {
96
- const child = stack.shift();
96
+ const child = stack.pop();
97
97
  if (Array.isArray(child)) {
98
- stack.unshift(...child);
98
+ for (let i = child.length - 1; i >= 0; i--) {
99
+ stack.push(child[i]);
100
+ }
99
101
  continue;
100
102
  }
101
103
  if (child === undefined || child === null || typeof child === "boolean") {
@@ -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));
@@ -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 };
@@ -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
  });