@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.
@@ -0,0 +1,172 @@
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 } from "../src/refract/hooks.js";
5
+ import type { Props } from "../src/refract/types.js";
6
+
7
+ describe("keyed reconciliation", () => {
8
+ let container: HTMLDivElement;
9
+
10
+ beforeEach(() => {
11
+ container = document.createElement("div");
12
+ });
13
+
14
+ it("reorders keyed children without recreating DOM nodes", () => {
15
+ render(
16
+ createElement("div", null,
17
+ createElement("span", { key: "a" }, "A"),
18
+ createElement("span", { key: "b" }, "B"),
19
+ createElement("span", { key: "c" }, "C"),
20
+ ),
21
+ container,
22
+ );
23
+
24
+ const div = container.querySelector("div")!;
25
+ const [spanA, spanB, spanC] = Array.from(div.children);
26
+
27
+ // Reverse order
28
+ render(
29
+ createElement("div", null,
30
+ createElement("span", { key: "c" }, "C"),
31
+ createElement("span", { key: "b" }, "B"),
32
+ createElement("span", { key: "a" }, "A"),
33
+ ),
34
+ container,
35
+ );
36
+
37
+ // DOM nodes should be reused (same references)
38
+ const children = Array.from(div.children);
39
+ expect(children[0]).toBe(spanC);
40
+ expect(children[1]).toBe(spanB);
41
+ expect(children[2]).toBe(spanA);
42
+ });
43
+
44
+ it("inserts a new keyed child", () => {
45
+ render(
46
+ createElement("div", null,
47
+ createElement("span", { key: "a" }, "A"),
48
+ createElement("span", { key: "c" }, "C"),
49
+ ),
50
+ container,
51
+ );
52
+
53
+ const div = container.querySelector("div")!;
54
+ const spanA = div.children[0];
55
+ const spanC = div.children[1];
56
+
57
+ render(
58
+ createElement("div", null,
59
+ createElement("span", { key: "a" }, "A"),
60
+ createElement("span", { key: "b" }, "B"),
61
+ createElement("span", { key: "c" }, "C"),
62
+ ),
63
+ container,
64
+ );
65
+
66
+ expect(div.children).toHaveLength(3);
67
+ expect(div.children[0]).toBe(spanA);
68
+ expect(div.children[1].textContent).toBe("B");
69
+ expect(div.children[2]).toBe(spanC);
70
+ });
71
+
72
+ it("removes a keyed child", () => {
73
+ render(
74
+ createElement("div", null,
75
+ createElement("span", { key: "a" }, "A"),
76
+ createElement("span", { key: "b" }, "B"),
77
+ createElement("span", { key: "c" }, "C"),
78
+ ),
79
+ container,
80
+ );
81
+
82
+ const div = container.querySelector("div")!;
83
+ const spanA = div.children[0];
84
+ const spanC = div.children[2];
85
+
86
+ render(
87
+ createElement("div", null,
88
+ createElement("span", { key: "a" }, "A"),
89
+ createElement("span", { key: "c" }, "C"),
90
+ ),
91
+ container,
92
+ );
93
+
94
+ expect(div.children).toHaveLength(2);
95
+ expect(div.children[0]).toBe(spanA);
96
+ expect(div.children[1]).toBe(spanC);
97
+ });
98
+
99
+ it("reorders keyed component children (shuffle)", async () => {
100
+ function Card(props: Props) {
101
+ return createElement("div", { className: "card" }, props.label as string);
102
+ }
103
+
104
+ let setItems!: (v: string[] | ((p: string[]) => string[])) => void;
105
+ function App() {
106
+ const [items, si] = useState(["A", "B", "C"]);
107
+ setItems = si;
108
+ return createElement(
109
+ "div",
110
+ { className: "gallery" },
111
+ ...items.map((label) =>
112
+ createElement(Card, { key: label, label }),
113
+ ),
114
+ );
115
+ }
116
+
117
+ render(createElement(App, null), container);
118
+ const gallery = container.querySelector(".gallery")!;
119
+ expect(gallery.children).toHaveLength(3);
120
+ expect(gallery.children[0].textContent).toBe("A");
121
+ expect(gallery.children[1].textContent).toBe("B");
122
+ expect(gallery.children[2].textContent).toBe("C");
123
+
124
+ const cardA = gallery.children[0];
125
+ const cardB = gallery.children[1];
126
+ const cardC = gallery.children[2];
127
+
128
+ // Reverse order (simulates shuffle)
129
+ setItems(["C", "B", "A"]);
130
+ await new Promise((r) => queueMicrotask(r));
131
+
132
+ expect(gallery.children).toHaveLength(3);
133
+ expect(gallery.children[0].textContent).toBe("C");
134
+ expect(gallery.children[1].textContent).toBe("B");
135
+ expect(gallery.children[2].textContent).toBe("A");
136
+
137
+ // DOM nodes should be reused
138
+ expect(gallery.children[0]).toBe(cardC);
139
+ expect(gallery.children[1]).toBe(cardB);
140
+ expect(gallery.children[2]).toBe(cardA);
141
+ });
142
+
143
+ it("handles shuffle (move to front)", () => {
144
+ render(
145
+ createElement("div", null,
146
+ createElement("span", { key: "a" }, "A"),
147
+ createElement("span", { key: "b" }, "B"),
148
+ createElement("span", { key: "c" }, "C"),
149
+ ),
150
+ container,
151
+ );
152
+
153
+ const div = container.querySelector("div")!;
154
+ const spanC = div.children[2];
155
+
156
+ // Move C to front
157
+ render(
158
+ createElement("div", null,
159
+ createElement("span", { key: "c" }, "C"),
160
+ createElement("span", { key: "a" }, "A"),
161
+ createElement("span", { key: "b" }, "B"),
162
+ ),
163
+ container,
164
+ );
165
+
166
+ expect(div.children).toHaveLength(3);
167
+ expect(div.children[0]).toBe(spanC);
168
+ expect(div.children[0].textContent).toBe("C");
169
+ expect(div.children[1].textContent).toBe("A");
170
+ expect(div.children[2].textContent).toBe("B");
171
+ });
172
+ });
@@ -0,0 +1,132 @@
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 { memo } from "../src/refract/fiber.js";
5
+ import { useState, createRef } from "../src/refract/hooks.js";
6
+
7
+ describe("memo", () => {
8
+ let container: HTMLDivElement;
9
+
10
+ beforeEach(() => {
11
+ container = document.createElement("div");
12
+ });
13
+
14
+ it("skips re-render when props are shallowly equal", async () => {
15
+ let childRenderCount = 0;
16
+ const Child = memo((props: Record<string, unknown>) => {
17
+ childRenderCount++;
18
+ return createElement("span", null, props.text as string);
19
+ });
20
+
21
+ let setOther!: (v: number) => void;
22
+ function App() {
23
+ const [other, so] = useState(0);
24
+ setOther = so;
25
+ return createElement("div", null,
26
+ createElement(Child, { text: "fixed" }),
27
+ createElement("span", null, String(other)),
28
+ );
29
+ }
30
+
31
+ render(createElement(App, null), container);
32
+ expect(childRenderCount).toBe(1);
33
+
34
+ setOther(1);
35
+ await new Promise((r) => setTimeout(r, 10));
36
+ // Child should NOT re-render because text prop didn't change
37
+ expect(childRenderCount).toBe(1);
38
+ });
39
+
40
+ it("re-renders when props change", async () => {
41
+ let childRenderCount = 0;
42
+ const Child = memo((props: Record<string, unknown>) => {
43
+ childRenderCount++;
44
+ return createElement("span", null, props.text as string);
45
+ });
46
+
47
+ let setText!: (v: string) => void;
48
+ function App() {
49
+ const [text, st] = useState("hello");
50
+ setText = st;
51
+ return createElement("div", null,
52
+ createElement(Child, { text }),
53
+ );
54
+ }
55
+
56
+ render(createElement(App, null), container);
57
+ expect(childRenderCount).toBe(1);
58
+
59
+ setText("world");
60
+ await new Promise((r) => setTimeout(r, 10));
61
+ expect(childRenderCount).toBe(2);
62
+ expect(container.querySelector("span")!.textContent).toBe("world");
63
+ });
64
+
65
+ it("supports custom compare function", async () => {
66
+ let childRenderCount = 0;
67
+ const Child = memo(
68
+ (props: Record<string, unknown>) => {
69
+ childRenderCount++;
70
+ return createElement("span", null, String(props.value));
71
+ },
72
+ (a, b) => (a.value as number) % 2 === (b.value as number) % 2,
73
+ );
74
+
75
+ let setValue!: (v: number) => void;
76
+ function App() {
77
+ const [value, sv] = useState(0);
78
+ setValue = sv;
79
+ return createElement("div", null, createElement(Child, { value }));
80
+ }
81
+
82
+ render(createElement(App, null), container);
83
+ expect(childRenderCount).toBe(1);
84
+
85
+ // 0 -> 2, both even, should skip
86
+ setValue(2);
87
+ await new Promise((r) => setTimeout(r, 10));
88
+ expect(childRenderCount).toBe(1);
89
+
90
+ // 2 -> 3, even to odd, should re-render
91
+ setValue(3);
92
+ await new Promise((r) => setTimeout(r, 10));
93
+ expect(childRenderCount).toBe(2);
94
+ });
95
+ });
96
+
97
+ describe("refs", () => {
98
+ let container: HTMLDivElement;
99
+
100
+ beforeEach(() => {
101
+ container = document.createElement("div");
102
+ });
103
+
104
+ it("sets ref.current to DOM element", () => {
105
+ const ref = createRef<HTMLSpanElement>();
106
+ function App() {
107
+ return createElement("span", { ref }, "hello");
108
+ }
109
+ render(createElement(App, null), container);
110
+ expect(ref.current).toBe(container.querySelector("span"));
111
+ });
112
+
113
+ it("calls callback ref with DOM element", () => {
114
+ let node: Node | null = null;
115
+ function App() {
116
+ return createElement("span", { ref: (el: Node | null) => { node = el; } }, "hello");
117
+ }
118
+ render(createElement(App, null), container);
119
+ expect(node).toBe(container.querySelector("span"));
120
+ });
121
+
122
+ it("does not add ref as a DOM attribute", () => {
123
+ const ref = createRef();
124
+ function App() {
125
+ return createElement("div", { ref, className: "test" });
126
+ }
127
+ render(createElement(App, null), container);
128
+ const div = container.querySelector("div")!;
129
+ expect(div.hasAttribute("ref")).toBe(false);
130
+ expect(div.getAttribute("class")).toBe("test");
131
+ });
132
+ });
@@ -0,0 +1,106 @@
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
+
5
+ describe("reconcile", () => {
6
+ let container: HTMLDivElement;
7
+
8
+ beforeEach(() => {
9
+ container = document.createElement("div");
10
+ });
11
+
12
+ it("updates props on re-render", () => {
13
+ render(createElement("img", { src: "old.jpg" }), container);
14
+ render(createElement("img", { src: "new.jpg" }), container);
15
+
16
+ const img = container.querySelector("img")!;
17
+ expect(img.getAttribute("src")).toBe("new.jpg");
18
+ });
19
+
20
+ it("replaces node when type changes", () => {
21
+ render(createElement("img", { src: "pic.jpg" }), container);
22
+ render(createElement("span", null, "text"), container);
23
+
24
+ expect(container.querySelector("img")).toBeNull();
25
+ expect(container.querySelector("span")).not.toBeNull();
26
+ expect(container.querySelector("span")!.textContent).toBe("text");
27
+ });
28
+
29
+ it("updates text content", () => {
30
+ render(createElement("span", null, "old"), container);
31
+ render(createElement("span", null, "new"), container);
32
+
33
+ expect(container.querySelector("span")!.textContent).toBe("new");
34
+ });
35
+
36
+ it("adds new children", () => {
37
+ render(
38
+ createElement("div", null, createElement("img", { src: "a.jpg" })),
39
+ container,
40
+ );
41
+ render(
42
+ createElement(
43
+ "div",
44
+ null,
45
+ createElement("img", { src: "a.jpg" }),
46
+ createElement("img", { src: "b.jpg" }),
47
+ ),
48
+ container,
49
+ );
50
+
51
+ expect(container.querySelector("div")!.querySelectorAll("img")).toHaveLength(2);
52
+ });
53
+
54
+ it("removes extra children", () => {
55
+ render(
56
+ createElement(
57
+ "div",
58
+ null,
59
+ createElement("img", { src: "a.jpg" }),
60
+ createElement("img", { src: "b.jpg" }),
61
+ ),
62
+ container,
63
+ );
64
+ render(
65
+ createElement("div", null, createElement("img", { src: "a.jpg" })),
66
+ container,
67
+ );
68
+
69
+ expect(container.querySelector("div")!.querySelectorAll("img")).toHaveLength(1);
70
+ });
71
+
72
+ it("removes old props that are no longer present", () => {
73
+ render(createElement("img", { src: "pic.jpg", alt: "photo" }), container);
74
+ render(createElement("img", { src: "pic.jpg" }), container);
75
+
76
+ const img = container.querySelector("img")!;
77
+ expect(img.getAttribute("alt")).toBeNull();
78
+ });
79
+
80
+ it("preserves the same DOM node across re-renders", () => {
81
+ render(createElement("img", { src: "old.jpg" }), container);
82
+ const imgBefore = container.querySelector("img")!;
83
+
84
+ render(createElement("img", { src: "new.jpg" }), container);
85
+ const imgAfter = container.querySelector("img")!;
86
+
87
+ expect(imgBefore).toBe(imgAfter);
88
+ });
89
+
90
+ it("removes old style properties that are no longer present", () => {
91
+ render(
92
+ createElement("div", { style: { color: "red", backgroundColor: "black" } }),
93
+ container,
94
+ );
95
+ const div = container.querySelector("div")!;
96
+ expect(div.style.color).toBe("red");
97
+ expect(div.style.backgroundColor).toBe("black");
98
+
99
+ render(
100
+ createElement("div", { style: { color: "blue" } }),
101
+ container,
102
+ );
103
+ expect(div.style.color).toBe("blue");
104
+ expect(div.style.backgroundColor).toBe("");
105
+ });
106
+ });
@@ -0,0 +1,82 @@
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
+
5
+ describe("render", () => {
6
+ let container: HTMLDivElement;
7
+
8
+ beforeEach(() => {
9
+ container = document.createElement("div");
10
+ });
11
+
12
+ it("renders a single img element", () => {
13
+ const vnode = createElement("img", { src: "cat.jpg", alt: "A cat" });
14
+ render(vnode, container);
15
+
16
+ const img = container.querySelector("img");
17
+ expect(img).not.toBeNull();
18
+ expect(img!.getAttribute("src")).toBe("cat.jpg");
19
+ expect(img!.getAttribute("alt")).toBe("A cat");
20
+ });
21
+
22
+ it("renders nested elements", () => {
23
+ const vnode = createElement(
24
+ "div",
25
+ { className: "gallery" },
26
+ createElement("img", { src: "a.jpg" }),
27
+ createElement("img", { src: "b.jpg" }),
28
+ );
29
+ render(vnode, container);
30
+
31
+ const div = container.querySelector("div");
32
+ expect(div).not.toBeNull();
33
+ expect(div!.getAttribute("class")).toBe("gallery");
34
+ expect(div!.querySelectorAll("img")).toHaveLength(2);
35
+ });
36
+
37
+ it("renders text nodes", () => {
38
+ const vnode = createElement("span", null, "Hello world");
39
+ render(vnode, container);
40
+
41
+ const span = container.querySelector("span");
42
+ expect(span).not.toBeNull();
43
+ expect(span!.textContent).toBe("Hello world");
44
+ });
45
+
46
+ it("renders functional components", () => {
47
+ const ImageCard = (props: Record<string, unknown>) =>
48
+ createElement(
49
+ "div",
50
+ { className: "card" },
51
+ createElement("img", { src: props.src as string }),
52
+ createElement("span", null, props.caption as string),
53
+ );
54
+
55
+ render(createElement(ImageCard, { src: "pic.jpg", caption: "Nice" }), container);
56
+
57
+ expect(container.querySelector("img")!.getAttribute("src")).toBe("pic.jpg");
58
+ expect(container.querySelector("span")!.textContent).toBe("Nice");
59
+ });
60
+
61
+ it("attaches event listeners", () => {
62
+ let clicked = false;
63
+ const vnode = createElement("div", { onClick: () => { clicked = true; } });
64
+ render(vnode, container);
65
+
66
+ const div = container.querySelector("div")!;
67
+ div.click();
68
+ expect(clicked).toBe(true);
69
+ });
70
+
71
+ it("blocks javascript: URLs on URL attributes", () => {
72
+ render(createElement("a", { href: "javascript:alert('xss')" }, "link"), container);
73
+ const link = container.querySelector("a")!;
74
+ expect(link.getAttribute("href")).toBeNull();
75
+ });
76
+
77
+ it("allows safe URLs on URL attributes", () => {
78
+ render(createElement("a", { href: "https://example.com" }, "link"), container);
79
+ const link = container.querySelector("a")!;
80
+ expect(link.getAttribute("href")).toBe("https://example.com");
81
+ });
82
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "jsx": "react",
11
+ "jsxFactory": "createElement",
12
+ "jsxFragmentFactory": "Fragment",
13
+ "declaration": true,
14
+ "outDir": "dist",
15
+ "rootDir": "."
16
+ },
17
+ "include": ["src", "demo", "tests"]
18
+ }