@ipxjs/refract 0.3.1
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/LICENSE +21 -0
- package/README.md +308 -0
- package/assets/lens-syntax-refract.svg +32 -0
- package/package.json +32 -0
- package/src/refract/context.ts +2 -0
- package/src/refract/core.ts +3 -0
- package/src/refract/coreRenderer.ts +291 -0
- package/src/refract/createElement.ts +40 -0
- package/src/refract/devtools.ts +254 -0
- package/src/refract/dom.ts +122 -0
- package/src/refract/features/context.ts +40 -0
- package/src/refract/features/hooks.ts +145 -0
- package/src/refract/features/memoRuntime.ts +33 -0
- package/src/refract/features/security.ts +61 -0
- package/src/refract/fiber.ts +10 -0
- package/src/refract/full.ts +14 -0
- package/src/refract/hooks.ts +11 -0
- package/src/refract/hooksRuntime.ts +63 -0
- package/src/refract/index.ts +1 -0
- package/src/refract/memo.ts +27 -0
- package/src/refract/memoMarker.ts +14 -0
- package/src/refract/reconcile.ts +185 -0
- package/src/refract/render.ts +9 -0
- package/src/refract/renderCore.ts +7 -0
- package/src/refract/runtimeExtensions.ts +80 -0
- package/src/refract/types.ts +48 -0
- package/tests/context.test.ts +90 -0
- package/tests/createElement.test.ts +71 -0
- package/tests/devtools.test.ts +90 -0
- package/tests/entrypoints.test.ts +63 -0
- package/tests/fragments.test.ts +92 -0
- package/tests/hooks.test.ts +255 -0
- package/tests/innerhtml-errors.test.ts +139 -0
- package/tests/keyed.test.ts +172 -0
- package/tests/memo.test.ts +132 -0
- package/tests/reconcile.test.ts +106 -0
- package/tests/render.test.ts +82 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { createElement } from "../src/refract/createElement.js";
|
|
3
|
+
import { render } from "../src/refract/render.js";
|
|
4
|
+
import {
|
|
5
|
+
DEVTOOLS_GLOBAL_HOOK,
|
|
6
|
+
setDevtoolsHook,
|
|
7
|
+
type RefractDevtoolsFiberSnapshot,
|
|
8
|
+
type RefractDevtoolsRootSnapshot,
|
|
9
|
+
} from "../src/refract/devtools.js";
|
|
10
|
+
|
|
11
|
+
describe("devtools", () => {
|
|
12
|
+
let container: HTMLDivElement;
|
|
13
|
+
const globalScope = globalThis as Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
container = document.createElement("div");
|
|
17
|
+
setDevtoolsHook();
|
|
18
|
+
delete globalScope[DEVTOOLS_GLOBAL_HOOK];
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
setDevtoolsHook();
|
|
23
|
+
delete globalScope[DEVTOOLS_GLOBAL_HOOK];
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("publishes commit and unmount snapshots through the global hook", () => {
|
|
27
|
+
const inject = vi.fn(() => 3);
|
|
28
|
+
const onCommitFiberRoot = vi.fn();
|
|
29
|
+
const onCommitFiberUnmount = vi.fn();
|
|
30
|
+
|
|
31
|
+
globalScope[DEVTOOLS_GLOBAL_HOOK] = {
|
|
32
|
+
inject,
|
|
33
|
+
onCommitFiberRoot,
|
|
34
|
+
onCommitFiberUnmount,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
render(
|
|
38
|
+
createElement(
|
|
39
|
+
"div",
|
|
40
|
+
{ id: "app" },
|
|
41
|
+
createElement("span", { id: "old" }, "before"),
|
|
42
|
+
),
|
|
43
|
+
container,
|
|
44
|
+
);
|
|
45
|
+
render(
|
|
46
|
+
createElement(
|
|
47
|
+
"div",
|
|
48
|
+
{ id: "app" },
|
|
49
|
+
createElement("p", { id: "new" }, "after"),
|
|
50
|
+
),
|
|
51
|
+
container,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(inject).toHaveBeenCalledTimes(1);
|
|
55
|
+
expect(onCommitFiberRoot).toHaveBeenCalledTimes(2);
|
|
56
|
+
expect(onCommitFiberUnmount).toHaveBeenCalled();
|
|
57
|
+
|
|
58
|
+
const [, latestRoot] = onCommitFiberRoot.mock.calls[1] as [number, RefractDevtoolsRootSnapshot];
|
|
59
|
+
expect(latestRoot.current?.type).toBe("div");
|
|
60
|
+
expect(latestRoot.current?.children[0].type).toBe("p");
|
|
61
|
+
expect(latestRoot.current?.children[0].props.id).toBe("new");
|
|
62
|
+
|
|
63
|
+
const unmountSnapshots = onCommitFiberUnmount.mock.calls.map(
|
|
64
|
+
(call) => call[1] as RefractDevtoolsFiberSnapshot,
|
|
65
|
+
);
|
|
66
|
+
const removedSpan = unmountSnapshots.find((snapshot) => snapshot.type === "span");
|
|
67
|
+
expect(removedSpan?.props.id).toBe("old");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("uses an explicitly configured hook when setDevtoolsHook is provided", () => {
|
|
71
|
+
const globalInject = vi.fn(() => 1);
|
|
72
|
+
globalScope[DEVTOOLS_GLOBAL_HOOK] = {
|
|
73
|
+
inject: globalInject,
|
|
74
|
+
onCommitFiberRoot: vi.fn(),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const explicitInject = vi.fn(() => 9);
|
|
78
|
+
const explicitCommit = vi.fn();
|
|
79
|
+
setDevtoolsHook({
|
|
80
|
+
inject: explicitInject,
|
|
81
|
+
onCommitFiberRoot: explicitCommit,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
render(createElement("div", { className: "local" }), container);
|
|
85
|
+
|
|
86
|
+
expect(explicitInject).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(explicitCommit).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(globalInject).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
describe("entrypoints", () => {
|
|
4
|
+
it("core exports only the minimal runtime surface", async () => {
|
|
5
|
+
vi.resetModules();
|
|
6
|
+
const core = await import("../src/refract/core.js") as Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
expect(typeof core.createElement).toBe("function");
|
|
9
|
+
expect(typeof core.render).toBe("function");
|
|
10
|
+
expect(core.useState).toBeUndefined();
|
|
11
|
+
expect(core.memo).toBeUndefined();
|
|
12
|
+
expect(core.setDevtoolsHook).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("full exports extended APIs", async () => {
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
const full = await import("../src/refract/full.js") as Record<string, unknown>;
|
|
18
|
+
|
|
19
|
+
expect(typeof full.createElement).toBe("function");
|
|
20
|
+
expect(typeof full.render).toBe("function");
|
|
21
|
+
expect(typeof full.useState).toBe("function");
|
|
22
|
+
expect(typeof full.memo).toBe("function");
|
|
23
|
+
expect(typeof full.setDevtoolsHook).toBe("function");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("core render does not auto-enable the security sanitizer", async () => {
|
|
27
|
+
vi.resetModules();
|
|
28
|
+
const core = await import("../src/refract/core.js");
|
|
29
|
+
const container = document.createElement("div");
|
|
30
|
+
|
|
31
|
+
core.render(
|
|
32
|
+
core.createElement("div", {
|
|
33
|
+
dangerouslySetInnerHTML: {
|
|
34
|
+
__html: "<a href=\"javascript:alert(1)\">x</a><script>evil()</script>",
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
container,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const div = container.querySelector("div")!;
|
|
41
|
+
expect(div.querySelector("script")).not.toBeNull();
|
|
42
|
+
expect(div.querySelector("a")!.getAttribute("href")).toBe("javascript:alert(1)");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("full render enables sanitizer defaults", async () => {
|
|
46
|
+
vi.resetModules();
|
|
47
|
+
const full = await import("../src/refract/full.js");
|
|
48
|
+
const container = document.createElement("div");
|
|
49
|
+
|
|
50
|
+
full.render(
|
|
51
|
+
full.createElement("div", {
|
|
52
|
+
dangerouslySetInnerHTML: {
|
|
53
|
+
__html: "<a href=\"javascript:alert(1)\">x</a><script>evil()</script>",
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
container,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const div = container.querySelector("div")!;
|
|
60
|
+
expect(div.querySelector("script")).toBeNull();
|
|
61
|
+
expect(div.querySelector("a")!.getAttribute("href")).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { createElement, Fragment } from "../src/refract/createElement.js";
|
|
3
|
+
import { render } from "../src/refract/render.js";
|
|
4
|
+
|
|
5
|
+
describe("fragments", () => {
|
|
6
|
+
let container: HTMLDivElement;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
container = document.createElement("div");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("renders fragment children directly into parent", () => {
|
|
13
|
+
const vnode = createElement(
|
|
14
|
+
"div",
|
|
15
|
+
null,
|
|
16
|
+
createElement(Fragment as unknown as string, null,
|
|
17
|
+
createElement("span", null, "a"),
|
|
18
|
+
createElement("span", null, "b"),
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
render(vnode, container);
|
|
22
|
+
|
|
23
|
+
const div = container.querySelector("div")!;
|
|
24
|
+
expect(div.children).toHaveLength(2);
|
|
25
|
+
expect(div.children[0].textContent).toBe("a");
|
|
26
|
+
expect(div.children[1].textContent).toBe("b");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("renders nested fragments", () => {
|
|
30
|
+
const vnode = createElement(
|
|
31
|
+
"div",
|
|
32
|
+
null,
|
|
33
|
+
createElement(Fragment as unknown as string, null,
|
|
34
|
+
createElement(Fragment as unknown as string, null,
|
|
35
|
+
createElement("span", null, "deep"),
|
|
36
|
+
),
|
|
37
|
+
createElement("span", null, "shallow"),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
render(vnode, container);
|
|
41
|
+
|
|
42
|
+
const div = container.querySelector("div")!;
|
|
43
|
+
expect(div.querySelectorAll("span")).toHaveLength(2);
|
|
44
|
+
expect(div.children[0].textContent).toBe("deep");
|
|
45
|
+
expect(div.children[1].textContent).toBe("shallow");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("reconciles fragment children on re-render", () => {
|
|
49
|
+
render(
|
|
50
|
+
createElement("div", null,
|
|
51
|
+
createElement(Fragment as unknown as string, null,
|
|
52
|
+
createElement("span", null, "old"),
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
container,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
render(
|
|
59
|
+
createElement("div", null,
|
|
60
|
+
createElement(Fragment as unknown as string, null,
|
|
61
|
+
createElement("span", null, "new"),
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
container,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const div = container.querySelector("div")!;
|
|
68
|
+
expect(div.querySelector("span")!.textContent).toBe("new");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("handles fragment with mixed children", () => {
|
|
72
|
+
const vnode = createElement(
|
|
73
|
+
"div",
|
|
74
|
+
null,
|
|
75
|
+
createElement("span", null, "before"),
|
|
76
|
+
createElement(Fragment as unknown as string, null,
|
|
77
|
+
createElement("span", null, "frag1"),
|
|
78
|
+
createElement("span", null, "frag2"),
|
|
79
|
+
),
|
|
80
|
+
createElement("span", null, "after"),
|
|
81
|
+
);
|
|
82
|
+
render(vnode, container);
|
|
83
|
+
|
|
84
|
+
const div = container.querySelector("div")!;
|
|
85
|
+
const spans = div.querySelectorAll("span");
|
|
86
|
+
expect(spans).toHaveLength(4);
|
|
87
|
+
expect(spans[0].textContent).toBe("before");
|
|
88
|
+
expect(spans[1].textContent).toBe("frag1");
|
|
89
|
+
expect(spans[2].textContent).toBe("frag2");
|
|
90
|
+
expect(spans[3].textContent).toBe("after");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { createElement } from "../src/refract/createElement.js";
|
|
3
|
+
import { render } from "../src/refract/render.js";
|
|
4
|
+
import { useState, useEffect, useRef, useMemo, useCallback, useReducer } from "../src/refract/hooks.js";
|
|
5
|
+
|
|
6
|
+
describe("hooks", () => {
|
|
7
|
+
let container: HTMLDivElement;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
container = document.createElement("div");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("useState", () => {
|
|
14
|
+
it("renders initial state", () => {
|
|
15
|
+
function Counter() {
|
|
16
|
+
const [count] = useState(0);
|
|
17
|
+
return createElement("span", null, String(count));
|
|
18
|
+
}
|
|
19
|
+
render(createElement(Counter, null), container);
|
|
20
|
+
expect(container.querySelector("span")!.textContent).toBe("0");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("updates state on setState", async () => {
|
|
24
|
+
let setCount!: (v: number | ((p: number) => number)) => void;
|
|
25
|
+
function Counter() {
|
|
26
|
+
const [count, sc] = useState(0);
|
|
27
|
+
setCount = sc;
|
|
28
|
+
return createElement("span", null, String(count));
|
|
29
|
+
}
|
|
30
|
+
render(createElement(Counter, null), container);
|
|
31
|
+
expect(container.querySelector("span")!.textContent).toBe("0");
|
|
32
|
+
|
|
33
|
+
setCount(1);
|
|
34
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
35
|
+
expect(container.querySelector("span")!.textContent).toBe("1");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("supports functional updates", async () => {
|
|
39
|
+
let setCount!: (v: number | ((p: number) => number)) => void;
|
|
40
|
+
function Counter() {
|
|
41
|
+
const [count, sc] = useState(0);
|
|
42
|
+
setCount = sc;
|
|
43
|
+
return createElement("span", null, String(count));
|
|
44
|
+
}
|
|
45
|
+
render(createElement(Counter, null), container);
|
|
46
|
+
|
|
47
|
+
setCount((prev) => prev + 1);
|
|
48
|
+
setCount((prev) => prev + 1);
|
|
49
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
50
|
+
expect(container.querySelector("span")!.textContent).toBe("2");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("batches multiple setState calls", async () => {
|
|
54
|
+
let renderCount = 0;
|
|
55
|
+
let setCount!: (v: number | ((p: number) => number)) => void;
|
|
56
|
+
function Counter() {
|
|
57
|
+
const [count, sc] = useState(0);
|
|
58
|
+
setCount = sc;
|
|
59
|
+
renderCount++;
|
|
60
|
+
return createElement("span", null, String(count));
|
|
61
|
+
}
|
|
62
|
+
render(createElement(Counter, null), container);
|
|
63
|
+
expect(renderCount).toBe(1);
|
|
64
|
+
|
|
65
|
+
setCount((p) => p + 1);
|
|
66
|
+
setCount((p) => p + 1);
|
|
67
|
+
setCount((p) => p + 1);
|
|
68
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
69
|
+
// Should have batched into one re-render
|
|
70
|
+
expect(renderCount).toBe(2);
|
|
71
|
+
expect(container.querySelector("span")!.textContent).toBe("3");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("useEffect", () => {
|
|
76
|
+
it("runs effect after render", () => {
|
|
77
|
+
const effectFn = vi.fn();
|
|
78
|
+
function App() {
|
|
79
|
+
useEffect(effectFn);
|
|
80
|
+
return createElement("div", null);
|
|
81
|
+
}
|
|
82
|
+
render(createElement(App, null), container);
|
|
83
|
+
expect(effectFn).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("runs cleanup on re-render when deps change", async () => {
|
|
87
|
+
const cleanup = vi.fn();
|
|
88
|
+
let setValue!: (v: number) => void;
|
|
89
|
+
function App() {
|
|
90
|
+
const [value, sv] = useState(0);
|
|
91
|
+
setValue = sv;
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
return cleanup;
|
|
94
|
+
}, [value]);
|
|
95
|
+
return createElement("span", null, String(value));
|
|
96
|
+
}
|
|
97
|
+
render(createElement(App, null), container);
|
|
98
|
+
expect(cleanup).not.toHaveBeenCalled();
|
|
99
|
+
|
|
100
|
+
setValue(1);
|
|
101
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
102
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("skips effect when deps unchanged", async () => {
|
|
106
|
+
const effectFn = vi.fn();
|
|
107
|
+
let setValue!: (v: number) => void;
|
|
108
|
+
function App() {
|
|
109
|
+
const [value, sv] = useState(0);
|
|
110
|
+
setValue = sv;
|
|
111
|
+
useEffect(effectFn, []);
|
|
112
|
+
return createElement("span", null, String(value));
|
|
113
|
+
}
|
|
114
|
+
render(createElement(App, null), container);
|
|
115
|
+
expect(effectFn).toHaveBeenCalledTimes(1);
|
|
116
|
+
|
|
117
|
+
setValue(1);
|
|
118
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
119
|
+
// Effect should NOT run again because deps [] didn't change
|
|
120
|
+
expect(effectFn).toHaveBeenCalledTimes(1);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("useRef", () => {
|
|
125
|
+
it("returns stable ref object", async () => {
|
|
126
|
+
const refs: { current: number }[] = [];
|
|
127
|
+
let setValue!: (v: number) => void;
|
|
128
|
+
function App() {
|
|
129
|
+
const [value, sv] = useState(0);
|
|
130
|
+
setValue = sv;
|
|
131
|
+
const ref = useRef(42);
|
|
132
|
+
refs.push(ref);
|
|
133
|
+
return createElement("span", null, String(value));
|
|
134
|
+
}
|
|
135
|
+
render(createElement(App, null), container);
|
|
136
|
+
|
|
137
|
+
setValue(1);
|
|
138
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
139
|
+
|
|
140
|
+
expect(refs).toHaveLength(2);
|
|
141
|
+
expect(refs[0]).toBe(refs[1]); // Same object
|
|
142
|
+
expect(refs[0].current).toBe(42);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("persists mutations across re-renders", async () => {
|
|
146
|
+
let setValue!: (v: number) => void;
|
|
147
|
+
let ref!: { current: number };
|
|
148
|
+
function App() {
|
|
149
|
+
const [value, sv] = useState(0);
|
|
150
|
+
setValue = sv;
|
|
151
|
+
ref = useRef(0);
|
|
152
|
+
ref.current = value;
|
|
153
|
+
return createElement("span", null, String(ref.current));
|
|
154
|
+
}
|
|
155
|
+
render(createElement(App, null), container);
|
|
156
|
+
expect(ref.current).toBe(0);
|
|
157
|
+
|
|
158
|
+
setValue(5);
|
|
159
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
160
|
+
expect(ref.current).toBe(5);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("useMemo", () => {
|
|
165
|
+
it("memoizes value based on deps", async () => {
|
|
166
|
+
let computeCount = 0;
|
|
167
|
+
let setValue!: (v: number) => void;
|
|
168
|
+
function App() {
|
|
169
|
+
const [value, sv] = useState(0);
|
|
170
|
+
setValue = sv;
|
|
171
|
+
const memoized = useMemo(() => {
|
|
172
|
+
computeCount++;
|
|
173
|
+
return value * 2;
|
|
174
|
+
}, [value]);
|
|
175
|
+
return createElement("span", null, String(memoized));
|
|
176
|
+
}
|
|
177
|
+
render(createElement(App, null), container);
|
|
178
|
+
expect(computeCount).toBe(1);
|
|
179
|
+
expect(container.querySelector("span")!.textContent).toBe("0");
|
|
180
|
+
|
|
181
|
+
setValue(5);
|
|
182
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
183
|
+
expect(computeCount).toBe(2);
|
|
184
|
+
expect(container.querySelector("span")!.textContent).toBe("10");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("skips recomputation when deps unchanged", async () => {
|
|
188
|
+
let computeCount = 0;
|
|
189
|
+
let setOther!: (v: number) => void;
|
|
190
|
+
function App() {
|
|
191
|
+
const [other, so] = useState(0);
|
|
192
|
+
setOther = so;
|
|
193
|
+
const memoized = useMemo(() => {
|
|
194
|
+
computeCount++;
|
|
195
|
+
return "fixed";
|
|
196
|
+
}, []);
|
|
197
|
+
return createElement("span", null, `${memoized}-${other}`);
|
|
198
|
+
}
|
|
199
|
+
render(createElement(App, null), container);
|
|
200
|
+
expect(computeCount).toBe(1);
|
|
201
|
+
|
|
202
|
+
setOther(1);
|
|
203
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
204
|
+
expect(computeCount).toBe(1); // Not recomputed
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("useCallback", () => {
|
|
209
|
+
it("returns stable callback when deps unchanged", async () => {
|
|
210
|
+
const callbacks: (() => void)[] = [];
|
|
211
|
+
let setValue!: (v: number) => void;
|
|
212
|
+
function App() {
|
|
213
|
+
const [value, sv] = useState(0);
|
|
214
|
+
setValue = sv;
|
|
215
|
+
const cb = useCallback(() => {}, []);
|
|
216
|
+
callbacks.push(cb);
|
|
217
|
+
return createElement("span", null, String(value));
|
|
218
|
+
}
|
|
219
|
+
render(createElement(App, null), container);
|
|
220
|
+
|
|
221
|
+
setValue(1);
|
|
222
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
223
|
+
|
|
224
|
+
expect(callbacks[0]).toBe(callbacks[1]);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("useReducer", () => {
|
|
229
|
+
it("dispatches actions through reducer", async () => {
|
|
230
|
+
type Action = { type: "inc" } | { type: "dec" };
|
|
231
|
+
let dispatch!: (action: Action) => void;
|
|
232
|
+
function Counter() {
|
|
233
|
+
const [count, d] = useReducer((state: number, action: Action) => {
|
|
234
|
+
if (action.type === "inc") return state + 1;
|
|
235
|
+
if (action.type === "dec") return state - 1;
|
|
236
|
+
return state;
|
|
237
|
+
}, 0);
|
|
238
|
+
dispatch = d;
|
|
239
|
+
return createElement("span", null, String(count));
|
|
240
|
+
}
|
|
241
|
+
render(createElement(Counter, null), container);
|
|
242
|
+
expect(container.querySelector("span")!.textContent).toBe("0");
|
|
243
|
+
|
|
244
|
+
dispatch({ type: "inc" });
|
|
245
|
+
dispatch({ type: "inc" });
|
|
246
|
+
dispatch({ type: "inc" });
|
|
247
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
248
|
+
expect(container.querySelector("span")!.textContent).toBe("3");
|
|
249
|
+
|
|
250
|
+
dispatch({ type: "dec" });
|
|
251
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
252
|
+
expect(container.querySelector("span")!.textContent).toBe("2");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { createElement } from "../src/refract/createElement.js";
|
|
3
|
+
import { render } from "../src/refract/render.js";
|
|
4
|
+
import { useState, useErrorBoundary } from "../src/refract/hooks.js";
|
|
5
|
+
|
|
6
|
+
describe("dangerouslySetInnerHTML", () => {
|
|
7
|
+
let container: HTMLDivElement;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
container = document.createElement("div");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("sets innerHTML from __html prop", () => {
|
|
14
|
+
function App() {
|
|
15
|
+
return createElement("div", {
|
|
16
|
+
dangerouslySetInnerHTML: { __html: "<b>bold</b>" },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
render(createElement(App, null), container);
|
|
20
|
+
expect(container.querySelector("div")!.innerHTML).toBe("<b>bold</b>");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("skips children reconciliation when dangerouslySetInnerHTML is set", () => {
|
|
24
|
+
function App() {
|
|
25
|
+
return createElement("div", {
|
|
26
|
+
dangerouslySetInnerHTML: { __html: "<b>bold</b>" },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
render(createElement(App, null), container);
|
|
30
|
+
// The innerHTML should be set, not treated as a regular child
|
|
31
|
+
const div = container.querySelector("div")!;
|
|
32
|
+
expect(div.querySelector("b")).not.toBeNull();
|
|
33
|
+
expect(div.querySelector("b")!.textContent).toBe("bold");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("sanitizes dangerous tags and attributes", () => {
|
|
37
|
+
function App() {
|
|
38
|
+
return createElement("div", {
|
|
39
|
+
dangerouslySetInnerHTML: {
|
|
40
|
+
__html: `
|
|
41
|
+
<img src="x" onerror="alert('xss')" />
|
|
42
|
+
<a href="javascript:alert('xss')">click</a>
|
|
43
|
+
<script>alert('xss')</script>
|
|
44
|
+
<b>safe</b>
|
|
45
|
+
`,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
render(createElement(App, null), container);
|
|
50
|
+
const div = container.querySelector("div")!;
|
|
51
|
+
expect(div.querySelector("script")).toBeNull();
|
|
52
|
+
expect(div.querySelector("img")!.getAttribute("onerror")).toBeNull();
|
|
53
|
+
expect(div.querySelector("a")!.getAttribute("href")).toBeNull();
|
|
54
|
+
expect(div.querySelector("b")!.textContent).toBe("safe");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("throws when __html is not a string", () => {
|
|
58
|
+
function App() {
|
|
59
|
+
return createElement("div", {
|
|
60
|
+
dangerouslySetInnerHTML: { __html: 123 as unknown as string },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
expect(() => render(createElement(App, null), container)).toThrow(
|
|
64
|
+
"dangerouslySetInnerHTML expects a string __html value",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("useErrorBoundary", () => {
|
|
70
|
+
let container: HTMLDivElement;
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
container = document.createElement("div");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("catches errors from child components", async () => {
|
|
77
|
+
function Broken(): never {
|
|
78
|
+
throw new Error("boom");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ErrorBoundary() {
|
|
82
|
+
const [error, resetError] = useErrorBoundary();
|
|
83
|
+
if (error) {
|
|
84
|
+
return createElement("span", null, "Error: " + (error as Error).message);
|
|
85
|
+
}
|
|
86
|
+
return createElement(Broken, null);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
render(createElement(ErrorBoundary, null), container);
|
|
90
|
+
// Error boundary triggers async re-render
|
|
91
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
92
|
+
expect(container.querySelector("span")!.textContent).toBe("Error: boom");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("renders children normally when no error", () => {
|
|
96
|
+
function Good() {
|
|
97
|
+
return createElement("span", null, "ok");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ErrorBoundary() {
|
|
101
|
+
const [error] = useErrorBoundary();
|
|
102
|
+
if (error) {
|
|
103
|
+
return createElement("span", null, "Error");
|
|
104
|
+
}
|
|
105
|
+
return createElement(Good, null);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
render(createElement(ErrorBoundary, null), container);
|
|
109
|
+
expect(container.querySelector("span")!.textContent).toBe("ok");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("recovers after error reset", async () => {
|
|
113
|
+
let shouldThrow = true;
|
|
114
|
+
let resetFn!: () => void;
|
|
115
|
+
|
|
116
|
+
function MaybeBreak() {
|
|
117
|
+
if (shouldThrow) throw new Error("boom");
|
|
118
|
+
return createElement("span", null, "recovered");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function ErrorBoundary() {
|
|
122
|
+
const [error, resetError] = useErrorBoundary();
|
|
123
|
+
resetFn = resetError;
|
|
124
|
+
if (error) {
|
|
125
|
+
return createElement("span", null, "caught");
|
|
126
|
+
}
|
|
127
|
+
return createElement(MaybeBreak, null);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
render(createElement(ErrorBoundary, null), container);
|
|
131
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
132
|
+
expect(container.querySelector("span")!.textContent).toBe("caught");
|
|
133
|
+
|
|
134
|
+
shouldThrow = false;
|
|
135
|
+
resetFn();
|
|
136
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
137
|
+
expect(container.querySelector("span")!.textContent).toBe("recovered");
|
|
138
|
+
});
|
|
139
|
+
});
|