@ipxjs/refract 0.6.0 → 0.7.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.6.0",
3
+ "version": "0.7.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",
@@ -1,5 +1,6 @@
1
1
  import { createElement, Fragment } from "../createElement.js";
2
2
  import { flushPendingRenders } from "../coreRenderer.js";
3
+ import { flushPassiveEffects } from "../hooksRuntime.js";
3
4
  import { setReactCompatEventMode } from "../dom.js";
4
5
  import { createPortal as createPortalImpl } from "../portal.js";
5
6
  import type { PortalChild } from "../portal.js";
@@ -18,6 +19,7 @@ export function unstable_batchedUpdates<T>(callback: () => T): T {
18
19
  export function flushSync<T>(callback: () => T): T {
19
20
  const result = callback();
20
21
  flushPendingRenders();
22
+ flushPassiveEffects();
21
23
  return result;
22
24
  }
23
25
 
@@ -8,6 +8,7 @@ import {
8
8
  runAfterComponentRenderHandlers,
9
9
  runAfterCommitHandlers,
10
10
  runBeforeComponentRenderHandlers,
11
+ runBeforeRenderBatchHandlers,
11
12
  runCommitHandlers,
12
13
  runFiberCleanupHandlers,
13
14
  shouldBailoutComponent,
@@ -358,53 +359,65 @@ const MAX_NESTED_RENDERS = 50;
358
359
  const renderCounts = new Map<Node, number>();
359
360
 
360
361
  function flushRenders(): void {
361
- flushScheduled = false;
362
- // Snapshot and clear pendingContainers BEFORE processing,
363
- // so effects that call scheduleRender during commit add to a fresh set.
364
- const containers = [...pendingContainers];
365
- pendingContainers.clear();
366
- for (const container of containers) {
367
- const currentRoot = roots.get(container);
368
- if (!currentRoot) continue;
369
-
370
- // Re-render guard: detect infinite loops (matches React's limit of 50)
371
- const count = (renderCounts.get(container) ?? 0) + 1;
372
- if (count > MAX_NESTED_RENDERS) {
373
- renderCounts.delete(container);
374
- throw new Error(
375
- "Too many re-renders. Refract limits the number of renders to prevent an infinite loop.",
376
- );
362
+ // Flush any pending passive effects from previous renders before
363
+ // starting new render work. This matches React's behavior: passive
364
+ // effects from render N are guaranteed to run before render N+1.
365
+ runBeforeRenderBatchHandlers();
366
+
367
+ // Keep flushScheduled true during processing to prevent duplicate
368
+ // microtask scheduling. Layout effects that call setState will add to
369
+ // pendingContainers, which we process in the next iteration of the
370
+ // while loop (synchronous cascade, protected by the render counter).
371
+ while (pendingContainers.size > 0) {
372
+ const containers = [...pendingContainers];
373
+ pendingContainers.clear();
374
+
375
+ for (const container of containers) {
376
+ const currentRoot = roots.get(container);
377
+ if (!currentRoot) continue;
378
+
379
+ // Re-render guard: detect infinite loops (matches React's limit of 50)
380
+ const count = (renderCounts.get(container) ?? 0) + 1;
381
+ if (count > MAX_NESTED_RENDERS) {
382
+ renderCounts.clear();
383
+ flushScheduled = false;
384
+ throw new Error(
385
+ "Too many re-renders. Refract limits the number of renders to prevent an infinite loop.",
386
+ );
387
+ }
388
+ renderCounts.set(container, count);
389
+
390
+ const newRoot: Fiber = {
391
+ type: currentRoot.type,
392
+ props: currentRoot.props,
393
+ key: currentRoot.key,
394
+ dom: currentRoot.dom,
395
+ parentDom: currentRoot.parentDom,
396
+ parent: null,
397
+ child: null,
398
+ sibling: null,
399
+ hooks: null,
400
+ alternate: currentRoot,
401
+ flags: UPDATE,
402
+ };
403
+ deletions = [];
404
+ isRendering = true;
405
+ performWork(newRoot);
406
+ isRendering = false;
407
+ const committedDeletions = deletions.slice();
408
+ commitRoot(newRoot);
409
+ runAfterCommitHandlers();
410
+ roots.set(container, newRoot);
411
+ runCommitHandlers(newRoot, committedDeletions);
377
412
  }
378
- renderCounts.set(container, count);
379
-
380
- const newRoot: Fiber = {
381
- type: currentRoot.type,
382
- props: currentRoot.props,
383
- key: currentRoot.key,
384
- dom: currentRoot.dom,
385
- parentDom: currentRoot.parentDom,
386
- parent: null,
387
- child: null,
388
- sibling: null,
389
- hooks: null,
390
- alternate: currentRoot,
391
- flags: UPDATE,
392
- };
393
- deletions = [];
394
- isRendering = true;
395
- performWork(newRoot);
396
- isRendering = false;
397
- const committedDeletions = deletions.slice();
398
- commitRoot(newRoot);
399
- runAfterCommitHandlers();
400
- roots.set(container, newRoot);
401
- runCommitHandlers(newRoot, committedDeletions);
402
413
  }
403
414
 
404
- // Reset counters when no more pending renders (loop resolved)
405
- if (pendingContainers.size === 0) {
406
- renderCounts.clear();
407
- }
415
+ // All synchronous work complete (including layout effect cascades).
416
+ // Reset counter and allow new microtask scheduling.
417
+ // Deferred passive effects will trigger fresh flushRenders calls
418
+ // with their own counter scope.
419
+ renderCounts.clear();
420
+ flushScheduled = false;
408
421
  }
409
422
 
410
423
  export function flushPendingRenders(): void {
@@ -58,6 +58,7 @@ interface EffectHook extends Hook {
58
58
  deps: unknown[] | undefined;
59
59
  cleanup?: EffectCleanup;
60
60
  pending: boolean;
61
+ effectType: "insertion" | "layout" | "passive";
61
62
  };
62
63
  }
63
64
 
@@ -66,7 +67,7 @@ export function useEffect(effect: () => EffectCleanup, deps?: unknown[]): void {
66
67
  const fiber = currentFiber!;
67
68
 
68
69
  if (hook.state === undefined) {
69
- hook.state = { effect, deps, cleanup: undefined, pending: true };
70
+ hook.state = { effect, deps, cleanup: undefined, pending: true, effectType: "passive" };
70
71
  markPendingEffects(fiber);
71
72
  } else {
72
73
  if (depsChanged(hook.state.deps, deps)) {
@@ -85,7 +86,7 @@ export function useLayoutEffect(effect: () => EffectCleanup, deps?: unknown[]):
85
86
  const fiber = currentFiber!;
86
87
 
87
88
  if (hook.state === undefined) {
88
- hook.state = { effect, deps, cleanup: undefined, pending: true };
89
+ hook.state = { effect, deps, cleanup: undefined, pending: true, effectType: "layout" };
89
90
  markPendingLayoutEffects(fiber);
90
91
  } else {
91
92
  if (depsChanged(hook.state.deps, deps)) {
@@ -104,7 +105,7 @@ export function useInsertionEffect(effect: () => EffectCleanup, deps?: unknown[]
104
105
  const fiber = currentFiber!;
105
106
 
106
107
  if (hook.state === undefined) {
107
- hook.state = { effect, deps, cleanup: undefined, pending: true };
108
+ hook.state = { effect, deps, cleanup: undefined, pending: true, effectType: "insertion" };
108
109
  markPendingInsertionEffects(fiber);
109
110
  } else {
110
111
  if (depsChanged(hook.state.deps, deps)) {
@@ -3,6 +3,7 @@ export { render } from "./render.js";
3
3
  export { memo } from "./memo.js";
4
4
  export { setHtmlSanitizer } from "./features/security.js";
5
5
  export { setDevtoolsHook, DEVTOOLS_GLOBAL_HOOK } from "./devtools.js";
6
+ export { flushPassiveEffects } from "./hooksRuntime.js";
6
7
  export {
7
8
  useState,
8
9
  useEffect,
@@ -2,6 +2,7 @@ import type { Fiber } from "./types.js";
2
2
  import { reconcileChildren } from "./reconcile.js";
3
3
  import {
4
4
  registerAfterCommitHandler,
5
+ registerBeforeRenderBatchHandler,
5
6
  registerFiberCleanupHandler,
6
7
  registerRenderErrorHandler,
7
8
  } from "./runtimeExtensions.js";
@@ -9,6 +10,8 @@ import {
9
10
  const fibersWithPendingEffects = new Set<Fiber>();
10
11
  const fibersWithPendingLayoutEffects = new Set<Fiber>();
11
12
  const fibersWithPendingInsertionEffects = new Set<Fiber>();
13
+ const deferredPassiveEffectFibers = new Set<Fiber>();
14
+ let passiveFlushScheduled = false;
12
15
 
13
16
  export function markPendingEffects(fiber: Fiber): void {
14
17
  fibersWithPendingEffects.add(fiber);
@@ -26,18 +29,23 @@ function cleanupFiberEffects(fiber: Fiber): void {
26
29
  fibersWithPendingEffects.delete(fiber);
27
30
  fibersWithPendingLayoutEffects.delete(fiber);
28
31
  fibersWithPendingInsertionEffects.delete(fiber);
32
+ deferredPassiveEffectFibers.delete(fiber);
29
33
  if (!fiber.hooks) return;
30
34
 
31
35
  for (const hook of fiber.hooks) {
32
- const state = hook.state as { cleanup?: () => void } | undefined;
36
+ const state = hook.state as { cleanup?: () => void; pending?: boolean } | undefined;
33
37
  if (state?.cleanup) {
34
38
  state.cleanup();
35
39
  state.cleanup = undefined;
36
40
  }
41
+ // Prevent deferred passive effects from running on unmounted fibers
42
+ if (state && "pending" in state) {
43
+ state.pending = false;
44
+ }
37
45
  }
38
46
  }
39
47
 
40
- function runPendingEffectsFor(fibers: Set<Fiber>): void {
48
+ function runPendingEffectsFor(fibers: Set<Fiber>, effectType: string): void {
41
49
  for (const fiber of fibers) {
42
50
  if (!fiber.hooks) continue;
43
51
 
@@ -46,7 +54,9 @@ function runPendingEffectsFor(fibers: Set<Fiber>): void {
46
54
  effect?: () => void | (() => void);
47
55
  pending?: boolean;
48
56
  cleanup?: () => void;
57
+ effectType?: string;
49
58
  } | undefined;
59
+ if (state?.effectType !== effectType) continue;
50
60
  if (state?.pending && state.effect) {
51
61
  if (state.cleanup) state.cleanup();
52
62
  state.cleanup = state.effect() || undefined;
@@ -58,10 +68,30 @@ function runPendingEffectsFor(fibers: Set<Fiber>): void {
58
68
  }
59
69
 
60
70
  function runPendingEffects(): void {
61
- // run in insertion -> layout -> passive order
62
- runPendingEffectsFor(fibersWithPendingInsertionEffects);
63
- runPendingEffectsFor(fibersWithPendingLayoutEffects);
64
- runPendingEffectsFor(fibersWithPendingEffects);
71
+ // Insertion and layout effects run synchronously (matching React)
72
+ runPendingEffectsFor(fibersWithPendingInsertionEffects, "insertion");
73
+ runPendingEffectsFor(fibersWithPendingLayoutEffects, "layout");
74
+
75
+ // Passive effects (useEffect) are deferred until after the current
76
+ // synchronous work completes, matching React's behavior.
77
+ if (fibersWithPendingEffects.size > 0) {
78
+ for (const fiber of fibersWithPendingEffects) {
79
+ deferredPassiveEffectFibers.add(fiber);
80
+ }
81
+ fibersWithPendingEffects.clear();
82
+ if (!passiveFlushScheduled) {
83
+ passiveFlushScheduled = true;
84
+ setTimeout(flushPassiveEffects, 0);
85
+ }
86
+ }
87
+ }
88
+
89
+ export function flushPassiveEffects(): void {
90
+ passiveFlushScheduled = false;
91
+ if (deferredPassiveEffectFibers.size === 0) return;
92
+ const fibers = new Set(deferredPassiveEffectFibers);
93
+ deferredPassiveEffectFibers.clear();
94
+ runPendingEffectsFor(fibers, "passive");
65
95
  }
66
96
 
67
97
  function handleErrorBoundary(fiber: Fiber, error: unknown): boolean {
@@ -79,4 +109,5 @@ function handleErrorBoundary(fiber: Fiber, error: unknown): boolean {
79
109
 
80
110
  registerFiberCleanupHandler(cleanupFiberEffects);
81
111
  registerAfterCommitHandler(runPendingEffects);
112
+ registerBeforeRenderBatchHandler(flushPassiveEffects);
82
113
  registerRenderErrorHandler(handleErrorBoundary);
@@ -2,6 +2,7 @@ import type { Fiber } from "./types.js";
2
2
 
3
3
  type FiberCleanupHandler = (fiber: Fiber) => void;
4
4
  type AfterCommitHandler = () => void;
5
+ type BeforeRenderBatchHandler = () => void;
5
6
  type RenderErrorHandler = (fiber: Fiber, error: unknown) => boolean;
6
7
  type CommitHandler = (rootFiber: Fiber, deletions: Fiber[]) => void;
7
8
  type ComponentBailoutHandler = (fiber: Fiber) => boolean;
@@ -10,6 +11,7 @@ type AfterComponentRenderHandler = (fiber: Fiber) => void;
10
11
 
11
12
  const fiberCleanupHandlers = new Set<FiberCleanupHandler>();
12
13
  const afterCommitHandlers = new Set<AfterCommitHandler>();
14
+ const beforeRenderBatchHandlers = new Set<BeforeRenderBatchHandler>();
13
15
  const renderErrorHandlers = new Set<RenderErrorHandler>();
14
16
  const commitHandlers = new Set<CommitHandler>();
15
17
  const componentBailoutHandlers = new Set<ComponentBailoutHandler>();
@@ -32,6 +34,11 @@ export function registerAfterCommitHandler(handler: AfterCommitHandler): () => v
32
34
  return makeUnregister(afterCommitHandlers, handler);
33
35
  }
34
36
 
37
+ export function registerBeforeRenderBatchHandler(handler: BeforeRenderBatchHandler): () => void {
38
+ beforeRenderBatchHandlers.add(handler);
39
+ return makeUnregister(beforeRenderBatchHandlers, handler);
40
+ }
41
+
35
42
  export function registerRenderErrorHandler(handler: RenderErrorHandler): () => void {
36
43
  renderErrorHandlers.add(handler);
37
44
  return makeUnregister(renderErrorHandlers, handler);
@@ -69,6 +76,12 @@ export function runAfterCommitHandlers(): void {
69
76
  }
70
77
  }
71
78
 
79
+ export function runBeforeRenderBatchHandlers(): void {
80
+ for (const handler of beforeRenderBatchHandlers) {
81
+ handler();
82
+ }
83
+ }
84
+
72
85
  export function tryHandleRenderError(fiber: Fiber, error: unknown): boolean {
73
86
  for (const handler of renderErrorHandlers) {
74
87
  if (handler(fiber, error)) {
@@ -54,7 +54,7 @@ describe("react compat", () => {
54
54
  expect(container.querySelector("span")!.id).toBe(ids[1]);
55
55
  });
56
56
 
57
- it("runs insertion/layout/passive effects in commit order", () => {
57
+ it("runs insertion/layout effects synchronously, passive effects deferred", async () => {
58
58
  const root = createRoot(container);
59
59
  const calls: string[] = [];
60
60
 
@@ -72,6 +72,9 @@ describe("react compat", () => {
72
72
  }
73
73
 
74
74
  root.render(ReactCompat.createElement(App, null));
75
+ // Insertion and layout are synchronous; passive is deferred (like React)
76
+ expect(calls).toEqual(["insertion", "layout"]);
77
+ await waitForRender();
75
78
  expect(calls).toEqual(["insertion", "layout", "effect"]);
76
79
  });
77
80
 
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
2
2
  import { createElement } from "../src/refract/createElement.js";
3
3
  import { render } from "../src/refract/render.js";
4
4
  import { useState, useEffect, useRef, useMemo, useCallback, useReducer } from "../src/refract/hooks.js";
5
+ import { flushPassiveEffects } from "../src/refract/hooksRuntime.js";
5
6
 
6
7
  describe("hooks", () => {
7
8
  let container: HTMLDivElement;
@@ -73,13 +74,28 @@ describe("hooks", () => {
73
74
  });
74
75
 
75
76
  describe("useEffect", () => {
76
- it("runs effect after render", () => {
77
+ it("runs effect after render (deferred)", async () => {
77
78
  const effectFn = vi.fn();
78
79
  function App() {
79
80
  useEffect(effectFn);
80
81
  return createElement("div", null);
81
82
  }
82
83
  render(createElement(App, null), container);
84
+ // Passive effects are deferred (like React)
85
+ expect(effectFn).not.toHaveBeenCalled();
86
+ await new Promise((r) => setTimeout(r, 10));
87
+ expect(effectFn).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ it("can be flushed synchronously with flushPassiveEffects", () => {
91
+ const effectFn = vi.fn();
92
+ function App() {
93
+ useEffect(effectFn);
94
+ return createElement("div", null);
95
+ }
96
+ render(createElement(App, null), container);
97
+ expect(effectFn).not.toHaveBeenCalled();
98
+ flushPassiveEffects();
83
99
  expect(effectFn).toHaveBeenCalledTimes(1);
84
100
  });
85
101
 
@@ -112,6 +128,8 @@ describe("hooks", () => {
112
128
  return createElement("span", null, String(value));
113
129
  }
114
130
  render(createElement(App, null), container);
131
+ // Flush initial passive effects
132
+ flushPassiveEffects();
115
133
  expect(effectFn).toHaveBeenCalledTimes(1);
116
134
 
117
135
  setValue(1);