@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 +1 -1
- package/src/refract/compat/react-dom.ts +2 -0
- package/src/refract/coreRenderer.ts +57 -44
- package/src/refract/features/hooks.ts +4 -3
- package/src/refract/full.ts +1 -0
- package/src/refract/hooksRuntime.ts +37 -6
- package/src/refract/runtimeExtensions.ts +13 -0
- package/tests/compat.test.ts +4 -1
- package/tests/hooks.test.ts +19 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
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)) {
|
package/src/refract/full.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
62
|
-
runPendingEffectsFor(fibersWithPendingInsertionEffects);
|
|
63
|
-
runPendingEffectsFor(fibersWithPendingLayoutEffects);
|
|
64
|
-
|
|
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)) {
|
package/tests/compat.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/tests/hooks.test.ts
CHANGED
|
@@ -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);
|