@ipxjs/refract 0.3.1 → 0.4.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.
@@ -7,13 +7,25 @@ import {
7
7
  } from "./runtimeExtensions.js";
8
8
 
9
9
  const fibersWithPendingEffects = new Set<Fiber>();
10
+ const fibersWithPendingLayoutEffects = new Set<Fiber>();
11
+ const fibersWithPendingInsertionEffects = new Set<Fiber>();
10
12
 
11
13
  export function markPendingEffects(fiber: Fiber): void {
12
14
  fibersWithPendingEffects.add(fiber);
13
15
  }
14
16
 
17
+ export function markPendingLayoutEffects(fiber: Fiber): void {
18
+ fibersWithPendingLayoutEffects.add(fiber);
19
+ }
20
+
21
+ export function markPendingInsertionEffects(fiber: Fiber): void {
22
+ fibersWithPendingInsertionEffects.add(fiber);
23
+ }
24
+
15
25
  function cleanupFiberEffects(fiber: Fiber): void {
16
26
  fibersWithPendingEffects.delete(fiber);
27
+ fibersWithPendingLayoutEffects.delete(fiber);
28
+ fibersWithPendingInsertionEffects.delete(fiber);
17
29
  if (!fiber.hooks) return;
18
30
 
19
31
  for (const hook of fiber.hooks) {
@@ -25,8 +37,8 @@ function cleanupFiberEffects(fiber: Fiber): void {
25
37
  }
26
38
  }
27
39
 
28
- function runPendingEffects(): void {
29
- for (const fiber of fibersWithPendingEffects) {
40
+ function runPendingEffectsFor(fibers: Set<Fiber>): void {
41
+ for (const fiber of fibers) {
30
42
  if (!fiber.hooks) continue;
31
43
 
32
44
  for (const hook of fiber.hooks) {
@@ -42,7 +54,14 @@ function runPendingEffects(): void {
42
54
  }
43
55
  }
44
56
  }
45
- fibersWithPendingEffects.clear();
57
+ fibers.clear();
58
+ }
59
+
60
+ function runPendingEffects(): void {
61
+ // run in insertion -> layout -> passive order
62
+ runPendingEffectsFor(fibersWithPendingInsertionEffects);
63
+ runPendingEffectsFor(fibersWithPendingLayoutEffects);
64
+ runPendingEffectsFor(fibersWithPendingEffects);
46
65
  }
47
66
 
48
67
  function handleErrorBoundary(fiber: Fiber, error: unknown): boolean {
@@ -0,0 +1,18 @@
1
+ import { createElement } from "./createElement.js";
2
+ import type { VNode } from "./types.js";
3
+
4
+ export const Portal = Symbol.for("refract.portal");
5
+
6
+ export type PortalChild = VNode | string | number | boolean | null | undefined | PortalChild[];
7
+
8
+ export function createPortal(
9
+ children: PortalChild,
10
+ container: Node,
11
+ key?: string | number | null,
12
+ ): VNode {
13
+ const props: Record<string, unknown> = { container };
14
+ if (key != null) {
15
+ props.key = key;
16
+ }
17
+ return createElement(Portal, props, children);
18
+ }
@@ -5,12 +5,16 @@ type AfterCommitHandler = () => void;
5
5
  type RenderErrorHandler = (fiber: Fiber, error: unknown) => boolean;
6
6
  type CommitHandler = (rootFiber: Fiber, deletions: Fiber[]) => void;
7
7
  type ComponentBailoutHandler = (fiber: Fiber) => boolean;
8
+ type BeforeComponentRenderHandler = (fiber: Fiber) => void;
9
+ type AfterComponentRenderHandler = (fiber: Fiber) => void;
8
10
 
9
11
  const fiberCleanupHandlers = new Set<FiberCleanupHandler>();
10
12
  const afterCommitHandlers = new Set<AfterCommitHandler>();
11
13
  const renderErrorHandlers = new Set<RenderErrorHandler>();
12
14
  const commitHandlers = new Set<CommitHandler>();
13
15
  const componentBailoutHandlers = new Set<ComponentBailoutHandler>();
16
+ const beforeComponentRenderHandlers = new Set<BeforeComponentRenderHandler>();
17
+ const afterComponentRenderHandlers = new Set<AfterComponentRenderHandler>();
14
18
 
15
19
  function makeUnregister<T>(set: Set<T>, value: T): () => void {
16
20
  return () => {
@@ -43,6 +47,16 @@ export function registerComponentBailoutHandler(handler: ComponentBailoutHandler
43
47
  return makeUnregister(componentBailoutHandlers, handler);
44
48
  }
45
49
 
50
+ export function registerBeforeComponentRenderHandler(handler: BeforeComponentRenderHandler): () => void {
51
+ beforeComponentRenderHandlers.add(handler);
52
+ return makeUnregister(beforeComponentRenderHandlers, handler);
53
+ }
54
+
55
+ export function registerAfterComponentRenderHandler(handler: AfterComponentRenderHandler): () => void {
56
+ afterComponentRenderHandlers.add(handler);
57
+ return makeUnregister(afterComponentRenderHandlers, handler);
58
+ }
59
+
46
60
  export function runFiberCleanupHandlers(fiber: Fiber): void {
47
61
  for (const handler of fiberCleanupHandlers) {
48
62
  handler(fiber);
@@ -78,3 +92,15 @@ export function shouldBailoutComponent(fiber: Fiber): boolean {
78
92
  }
79
93
  return false;
80
94
  }
95
+
96
+ export function runBeforeComponentRenderHandlers(fiber: Fiber): void {
97
+ for (const handler of beforeComponentRenderHandlers) {
98
+ handler(fiber);
99
+ }
100
+ }
101
+
102
+ export function runAfterComponentRenderHandlers(fiber: Fiber): void {
103
+ for (const handler of afterComponentRenderHandlers) {
104
+ handler(fiber);
105
+ }
106
+ }
@@ -0,0 +1,177 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import * as ReactCompat from "../src/refract/compat/react.js";
3
+ import { createPortal, flushSync, unstable_batchedUpdates } from "../src/refract/compat/react-dom.js";
4
+ import { createRoot } from "../src/refract/compat/react-dom-client.js";
5
+ import { Fragment, jsx, jsxs } from "../src/refract/compat/react-jsx-runtime.js";
6
+
7
+ function waitForRender(): Promise<void> {
8
+ return new Promise((resolve) => setTimeout(resolve, 0));
9
+ }
10
+
11
+ describe("react compat", () => {
12
+ let container: HTMLDivElement;
13
+ let portalContainer: HTMLDivElement;
14
+
15
+ beforeEach(() => {
16
+ container = document.createElement("div");
17
+ portalContainer = document.createElement("div");
18
+ });
19
+
20
+ it("supports forwardRef to DOM refs", () => {
21
+ const root = createRoot(container);
22
+ const ref = ReactCompat.createRef<HTMLButtonElement>();
23
+
24
+ const Button = ReactCompat.forwardRef<HTMLButtonElement, { id: string }>((props, forwardedRef) =>
25
+ ReactCompat.createElement("button", { id: props.id, ref: forwardedRef }, "Click"),
26
+ );
27
+
28
+ root.render(ReactCompat.createElement(Button, { id: "btn", ref }));
29
+
30
+ const button = container.querySelector("button");
31
+ expect(button).not.toBeNull();
32
+ expect(ref.current).toBe(button);
33
+ });
34
+
35
+ it("provides stable useId values across re-renders", async () => {
36
+ const root = createRoot(container);
37
+ const ids: string[] = [];
38
+ let setCount!: (value: number) => void;
39
+
40
+ function App() {
41
+ const [count, set] = ReactCompat.useState(0);
42
+ const id = ReactCompat.useId();
43
+ setCount = set;
44
+ ids.push(id);
45
+ return ReactCompat.createElement("span", { id }, String(count));
46
+ }
47
+
48
+ root.render(ReactCompat.createElement(App, null));
49
+ setCount(1);
50
+ await waitForRender();
51
+
52
+ expect(ids).toHaveLength(2);
53
+ expect(ids[0]).toBe(ids[1]);
54
+ expect(container.querySelector("span")!.id).toBe(ids[1]);
55
+ });
56
+
57
+ it("runs insertion/layout/passive effects in commit order", () => {
58
+ const root = createRoot(container);
59
+ const calls: string[] = [];
60
+
61
+ function App() {
62
+ ReactCompat.useInsertionEffect(() => {
63
+ calls.push("insertion");
64
+ }, []);
65
+ ReactCompat.useLayoutEffect(() => {
66
+ calls.push("layout");
67
+ }, []);
68
+ ReactCompat.useEffect(() => {
69
+ calls.push("effect");
70
+ }, []);
71
+ return ReactCompat.createElement("div", null);
72
+ }
73
+
74
+ root.render(ReactCompat.createElement(App, null));
75
+ expect(calls).toEqual(["insertion", "layout", "effect"]);
76
+ });
77
+
78
+ it("renders portals into target containers and cleans them up on unmount", () => {
79
+ const root = createRoot(container);
80
+
81
+ function App() {
82
+ return createPortal(
83
+ ReactCompat.createElement("span", { id: "in-portal" }, "Portal"),
84
+ portalContainer,
85
+ );
86
+ }
87
+
88
+ root.render(ReactCompat.createElement(App, null));
89
+ expect(portalContainer.querySelector("#in-portal")!.textContent).toBe("Portal");
90
+ expect(container.querySelector("#in-portal")).toBeNull();
91
+
92
+ root.unmount();
93
+ expect(portalContainer.querySelector("#in-portal")).toBeNull();
94
+ });
95
+
96
+ it("flushSync forces scheduled updates to commit synchronously", () => {
97
+ const root = createRoot(container);
98
+ let setCount!: (value: number) => void;
99
+
100
+ function Counter() {
101
+ const [count, set] = ReactCompat.useState(0);
102
+ setCount = set;
103
+ return ReactCompat.createElement("span", null, String(count));
104
+ }
105
+
106
+ root.render(ReactCompat.createElement(Counter, null));
107
+ setCount(1);
108
+ expect(container.querySelector("span")!.textContent).toBe("0");
109
+
110
+ flushSync(() => {});
111
+ expect(container.querySelector("span")!.textContent).toBe("1");
112
+ });
113
+
114
+ it("cloneElement merges props and replaces children", () => {
115
+ const root = createRoot(container);
116
+ const base = ReactCompat.createElement("div", { id: "base", className: "old" }, "one");
117
+ const cloned = ReactCompat.cloneElement(base, { id: "next", className: "new" }, "two");
118
+
119
+ root.render(cloned);
120
+ const div = container.querySelector("div")!;
121
+ expect(div.id).toBe("next");
122
+ expect(div.className).toBe("new");
123
+ expect(div.textContent).toBe("two");
124
+ });
125
+
126
+ it("supports React.Children helpers", () => {
127
+ const children = [
128
+ ReactCompat.createElement("span", { key: "a" }, "A"),
129
+ null,
130
+ false,
131
+ ReactCompat.createElement("span", { key: "b" }, "B"),
132
+ ];
133
+
134
+ expect(ReactCompat.Children.count(children)).toBe(2);
135
+ expect(ReactCompat.Children.toArray(children)).toHaveLength(2);
136
+ expect(ReactCompat.Children.map(children, (child) => child)).toHaveLength(2);
137
+ expect(() => ReactCompat.Children.only(children)).toThrow();
138
+ expect(ReactCompat.Children.only(ReactCompat.createElement("span", null, "only"))).toBeTruthy();
139
+ });
140
+
141
+ it("exposes batched updates helper", () => {
142
+ const result = unstable_batchedUpdates(() => 42);
143
+ expect(result).toBe(42);
144
+ });
145
+
146
+ it("supports jsx runtime factories", () => {
147
+ const root = createRoot(container);
148
+ const vnode = jsxs("div", {
149
+ id: "jsx-root",
150
+ children: [
151
+ jsx("span", { children: "a" }),
152
+ jsx(Fragment, { children: jsx("span", { children: "b" }) }),
153
+ ],
154
+ });
155
+ root.render(vnode);
156
+ expect(container.querySelector("#jsx-root")!.textContent).toBe("ab");
157
+ });
158
+
159
+ it("supports components that return null or arrays", () => {
160
+ const root = createRoot(container);
161
+ const Empty = (() => null) as unknown as (
162
+ props: Record<string, unknown>,
163
+ ) => ReturnType<typeof ReactCompat.createElement>;
164
+ const Pair = (() => [
165
+ ReactCompat.createElement("span", { key: "a" }, "A"),
166
+ ReactCompat.createElement("span", { key: "b" }, "B"),
167
+ ]) as unknown as (
168
+ props: Record<string, unknown>,
169
+ ) => ReturnType<typeof ReactCompat.createElement>;
170
+
171
+ root.render(ReactCompat.createElement(Empty, null));
172
+ expect(container.innerHTML).toBe("");
173
+
174
+ root.render(ReactCompat.createElement(Pair, null));
175
+ expect(container.textContent).toBe("AB");
176
+ });
177
+ });
@@ -23,6 +23,24 @@ describe("entrypoints", () => {
23
23
  expect(typeof full.setDevtoolsHook).toBe("function");
24
24
  });
25
25
 
26
+ it("compat entrypoints are opt-in and expose React-style surfaces", async () => {
27
+ vi.resetModules();
28
+ const reactCompat = await import("../src/refract/compat/react.js") as Record<string, unknown>;
29
+ const reactDomCompat = await import("../src/refract/compat/react-dom.js") as Record<string, unknown>;
30
+ const reactDomClientCompat = await import("../src/refract/compat/react-dom-client.js") as Record<string, unknown>;
31
+
32
+ expect(typeof reactCompat.createElement).toBe("function");
33
+ expect(typeof reactCompat.forwardRef).toBe("function");
34
+ expect(typeof reactCompat.useLayoutEffect).toBe("function");
35
+ expect(typeof reactCompat.useId).toBe("function");
36
+
37
+ expect(typeof reactDomCompat.createPortal).toBe("function");
38
+ expect(typeof reactDomCompat.unstable_batchedUpdates).toBe("function");
39
+ expect(typeof reactDomCompat.flushSync).toBe("function");
40
+
41
+ expect(typeof reactDomClientCompat.createRoot).toBe("function");
42
+ });
43
+
26
44
  it("core render does not auto-enable the security sanitizer", async () => {
27
45
  vi.resetModules();
28
46
  const core = await import("../src/refract/core.js");
@@ -127,7 +127,7 @@ describe("keyed reconciliation", () => {
127
127
 
128
128
  // Reverse order (simulates shuffle)
129
129
  setItems(["C", "B", "A"]);
130
- await new Promise((r) => queueMicrotask(r));
130
+ await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
131
131
 
132
132
  expect(gallery.children).toHaveLength(3);
133
133
  expect(gallery.children[0].textContent).toBe("C");
@@ -0,0 +1,4 @@
1
+ declare module "react";
2
+ declare module "react-dom/client";
3
+ declare module "react-router-dom";
4
+ declare module "react-router-dom/dist/index.mjs";
@@ -0,0 +1,72 @@
1
+ import { createRequire } from "node:module";
2
+ import * as React from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { describe, expect, it } from "vitest";
5
+ import {
6
+ Link,
7
+ MemoryRouter,
8
+ Route,
9
+ Routes,
10
+ } from "react-router-dom";
11
+ import { registerExternalReactModule } from "../src/refract/compat/react.js";
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const externalReact = require("react") as typeof React;
15
+ registerExternalReactModule(externalReact);
16
+
17
+ describe("react-router-dom compatibility smoke", () => {
18
+ it("resolves react aliases to refract compat entrypoints", () => {
19
+ expect(typeof React.createElement).toBe("function");
20
+ expect(typeof React.forwardRef).toBe("function");
21
+ expect(typeof createRoot).toBe("function");
22
+ });
23
+
24
+ it("constructs a router tree with compat createElement", () => {
25
+ const tree = React.createElement(
26
+ MemoryRouter,
27
+ { initialEntries: ["/"] },
28
+ React.createElement(
29
+ "div",
30
+ null,
31
+ React.createElement(Link, { to: "/about", id: "about-link" }, "About"),
32
+ React.createElement(
33
+ Routes,
34
+ null,
35
+ React.createElement(Route, { path: "/", element: React.createElement("h1", null, "Home") }),
36
+ React.createElement(Route, { path: "/about", element: React.createElement("h1", null, "About") }),
37
+ ),
38
+ ),
39
+ );
40
+ expect(tree.type).toBe(MemoryRouter);
41
+ expect((tree.props.children as unknown[]).length).toBe(1);
42
+ });
43
+
44
+ it("supports hook dispatcher bridging for externally-resolved React", async () => {
45
+ const container = document.createElement("div");
46
+ const root = createRoot(container);
47
+ let setCount!: (value: number) => void;
48
+
49
+ function Counter() {
50
+ const [count, set] = externalReact.useState(0);
51
+ setCount = set;
52
+ return React.createElement("span", { id: "count" }, String(count));
53
+ }
54
+
55
+ function App() {
56
+ return React.createElement(
57
+ "div",
58
+ null,
59
+ React.createElement(Counter, null),
60
+ );
61
+ }
62
+
63
+ root.render(React.createElement(App, null));
64
+ expect(container.querySelector("#count")?.textContent).toBe("0");
65
+
66
+ setCount(1);
67
+
68
+ await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
69
+
70
+ expect(container.querySelector("#count")?.textContent).toBe("1");
71
+ });
72
+ });