@ipxjs/refract 0.13.0 → 0.15.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/README.md +3 -3
- package/package.json +1 -1
- package/src/refract/dom.ts +4 -110
- package/src/refract/features/hooks.ts +10 -0
- package/tests/render.test.ts +89 -9
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ This project is an experiment and uses code generated with both Claude Opus 4.6
|
|
|
30
30
|
- **memo** -- skip re-renders when props are unchanged
|
|
31
31
|
- **Refs** -- createRef and callback refs via the `ref` prop
|
|
32
32
|
- **Error boundaries** -- catch and recover from render errors
|
|
33
|
-
- **SVG support** -- automatic SVG namespace handling
|
|
33
|
+
- **SVG support** -- automatic SVG namespace handling (including `foreignObject` HTML fallback) with Preact-like SVG prop normalization (`xlinkHref`/`xlink:href` -> `href`, camelCase attrs preserved)
|
|
34
34
|
- **dangerouslySetInnerHTML** -- raw HTML injection with sanitizer defaults in `refract/full` and configurable `setHtmlSanitizer` override
|
|
35
35
|
- **Automatic batching** -- state updates are batched via microtask queue
|
|
36
36
|
- **DevTools hook support** -- emits commit/unmount snapshots to a global hook or explicit hook instance
|
|
@@ -127,8 +127,8 @@ export default defineConfig({
|
|
|
127
127
|
The compat layer is intentionally separate from core so users who do not need
|
|
128
128
|
React ecosystem compatibility keep the smallest and fastest Refract bundles.
|
|
129
129
|
|
|
130
|
-
Compatibility status (last verified February
|
|
131
|
-
- `yarn test`:
|
|
130
|
+
Compatibility status (last verified February 23, 2026):
|
|
131
|
+
- `yarn test`: 15 files passed, 100 tests passed
|
|
132
132
|
- Compat-focused suites passed: `tests/compat.test.ts` (10), `tests/poc-compat.test.ts` (2), `tests/react-router-smoke.test.ts` (3)
|
|
133
133
|
- Verified behaviors include `forwardRef`, portals, `createRoot`, JSX runtimes, `useSyncExternalStore`, `flushSync`, react-router tree construction/dispatcher bridging, `React.use` (Promise + context), and Suspense boundary fallback rendering
|
|
134
134
|
|
package/package.json
CHANGED
package/src/refract/dom.ts
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
import type { Fiber } from "./types.js";
|
|
2
2
|
|
|
3
3
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
4
|
-
const XLINK_NS = "http://www.w3.org/1999/xlink";
|
|
5
|
-
const XML_NS = "http://www.w3.org/XML/1998/namespace";
|
|
6
|
-
const XMLNS_NS = "http://www.w3.org/2000/xmlns/";
|
|
7
|
-
const SVG_TAGS = new Set([
|
|
8
|
-
"svg", "circle", "ellipse", "line", "path", "polygon", "polyline",
|
|
9
|
-
"rect", "g", "defs", "use", "text", "tspan", "image", "clipPath",
|
|
10
|
-
"mask", "pattern", "marker", "linearGradient", "radialGradient", "stop",
|
|
11
|
-
"foreignObject", "symbol", "desc", "title",
|
|
12
|
-
]);
|
|
13
4
|
|
|
14
5
|
export type HtmlSanitizer = (html: string) => string;
|
|
15
6
|
export type UnsafeUrlPropChecker = (key: string, value: unknown) => boolean;
|
|
@@ -73,7 +64,7 @@ export function createDom(fiber: Fiber): Node {
|
|
|
73
64
|
return document.createTextNode(fiber.props.nodeValue as string);
|
|
74
65
|
}
|
|
75
66
|
const tag = fiber.type as string;
|
|
76
|
-
const isSvg =
|
|
67
|
+
const isSvg = tag === "svg" || isSvgContext(fiber);
|
|
77
68
|
const el = isSvg
|
|
78
69
|
? document.createElementNS(SVG_NS, tag)
|
|
79
70
|
: document.createElement(tag);
|
|
@@ -85,8 +76,8 @@ export function createDom(fiber: Fiber): Node {
|
|
|
85
76
|
function isSvgContext(fiber: Fiber): boolean {
|
|
86
77
|
let f = fiber.parent;
|
|
87
78
|
while (f) {
|
|
79
|
+
if (f.type === "foreignObject") return false;
|
|
88
80
|
if (f.type === "svg") return true;
|
|
89
|
-
if (typeof f.type === "string" && f.type !== "svg" && f.dom) return false;
|
|
90
81
|
f = f.parent;
|
|
91
82
|
}
|
|
92
83
|
return false;
|
|
@@ -221,103 +212,6 @@ function normalizeAttributeName(key: string, isSvgElement: boolean): NormalizedA
|
|
|
221
212
|
return { name: key, localName: key, namespaceURI: null };
|
|
222
213
|
}
|
|
223
214
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const localName = local.charAt(0).toLowerCase() + local.slice(1);
|
|
227
|
-
return { name: `xlink:${localName.toLowerCase()}`, localName: localName.toLowerCase(), namespaceURI: XLINK_NS };
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (key === "xmlnsXlink") {
|
|
231
|
-
return { name: "xmlns:xlink", localName: "xlink", namespaceURI: XMLNS_NS };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (key.startsWith("xml") && key.length > 3) {
|
|
235
|
-
const local = key.slice(3);
|
|
236
|
-
const localName = local.charAt(0).toLowerCase() + local.slice(1);
|
|
237
|
-
return { name: `xml:${localName}`, localName, namespaceURI: XML_NS };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (SVG_ATTR_CASE_PRESERVED.has(key) || key.startsWith("aria-") || key.startsWith("data-")) {
|
|
241
|
-
return { name: key, localName: key, namespaceURI: null };
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (SVG_ATTR_KEBAB_CASE.has(key)) {
|
|
245
|
-
const name = key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
|
246
|
-
return { name, localName: name, namespaceURI: null };
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
return { name: key, localName: key, namespaceURI: null };
|
|
215
|
+
const name = key.replace(/xlink(H|:h)/, "h").replace(/sName$/, "s");
|
|
216
|
+
return { name, localName: name, namespaceURI: null };
|
|
250
217
|
}
|
|
251
|
-
|
|
252
|
-
const SVG_ATTR_CASE_PRESERVED = new Set([
|
|
253
|
-
"viewBox",
|
|
254
|
-
"preserveAspectRatio",
|
|
255
|
-
"gradientUnits",
|
|
256
|
-
"gradientTransform",
|
|
257
|
-
"patternUnits",
|
|
258
|
-
"patternContentUnits",
|
|
259
|
-
"patternTransform",
|
|
260
|
-
"maskUnits",
|
|
261
|
-
"maskContentUnits",
|
|
262
|
-
"filterUnits",
|
|
263
|
-
"primitiveUnits",
|
|
264
|
-
"pointsAtX",
|
|
265
|
-
"pointsAtY",
|
|
266
|
-
"pointsAtZ",
|
|
267
|
-
"markerUnits",
|
|
268
|
-
"markerWidth",
|
|
269
|
-
"markerHeight",
|
|
270
|
-
"refX",
|
|
271
|
-
"refY",
|
|
272
|
-
"stdDeviation",
|
|
273
|
-
]);
|
|
274
|
-
|
|
275
|
-
const SVG_ATTR_KEBAB_CASE = new Set([
|
|
276
|
-
"clipPath",
|
|
277
|
-
"clipRule",
|
|
278
|
-
"fillOpacity",
|
|
279
|
-
"fillRule",
|
|
280
|
-
"floodColor",
|
|
281
|
-
"floodOpacity",
|
|
282
|
-
"strokeDasharray",
|
|
283
|
-
"strokeDashoffset",
|
|
284
|
-
"strokeLinecap",
|
|
285
|
-
"strokeLinejoin",
|
|
286
|
-
"strokeMiterlimit",
|
|
287
|
-
"strokeOpacity",
|
|
288
|
-
"strokeWidth",
|
|
289
|
-
"stopColor",
|
|
290
|
-
"stopOpacity",
|
|
291
|
-
"fontFamily",
|
|
292
|
-
"fontSize",
|
|
293
|
-
"fontSizeAdjust",
|
|
294
|
-
"fontStretch",
|
|
295
|
-
"fontStyle",
|
|
296
|
-
"fontVariant",
|
|
297
|
-
"fontWeight",
|
|
298
|
-
"glyphOrientationHorizontal",
|
|
299
|
-
"glyphOrientationVertical",
|
|
300
|
-
"letterSpacing",
|
|
301
|
-
"wordSpacing",
|
|
302
|
-
"textAnchor",
|
|
303
|
-
"textDecoration",
|
|
304
|
-
"textRendering",
|
|
305
|
-
"dominantBaseline",
|
|
306
|
-
"alignmentBaseline",
|
|
307
|
-
"baselineShift",
|
|
308
|
-
"colorInterpolation",
|
|
309
|
-
"colorInterpolationFilters",
|
|
310
|
-
"colorProfile",
|
|
311
|
-
"colorRendering",
|
|
312
|
-
"imageRendering",
|
|
313
|
-
"shapeRendering",
|
|
314
|
-
"pointerEvents",
|
|
315
|
-
"lightingColor",
|
|
316
|
-
"unicodeBidi",
|
|
317
|
-
"renderingIntent",
|
|
318
|
-
"vectorEffect",
|
|
319
|
-
"writingMode",
|
|
320
|
-
"markerStart",
|
|
321
|
-
"markerMid",
|
|
322
|
-
"markerEnd",
|
|
323
|
-
]);
|
|
@@ -37,6 +37,16 @@ export function useState<T>(initial: T | (() => T)): [T, (value: T | ((prev: T)
|
|
|
37
37
|
if (!hook._setter) {
|
|
38
38
|
hook._setter = (value: T | ((prev: T) => T)) => {
|
|
39
39
|
if (!hook._fiber) return;
|
|
40
|
+
// Bail out for same-value direct updates with no pending queue (matches React behavior).
|
|
41
|
+
// This prevents infinite loops when setState(sameValue) is called in effects —
|
|
42
|
+
// e.g. MUI FormControl/InputBase calling setAdornedStart(false) repeatedly.
|
|
43
|
+
if (
|
|
44
|
+
typeof value !== "function" &&
|
|
45
|
+
Object.is(value, hook.state) &&
|
|
46
|
+
(!hook.queue || (hook.queue as unknown[]).length === 0)
|
|
47
|
+
) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
40
50
|
const action = typeof value === "function"
|
|
41
51
|
? value as (prev: T) => T
|
|
42
52
|
: () => value;
|
package/tests/render.test.ts
CHANGED
|
@@ -91,10 +91,10 @@ describe("render", () => {
|
|
|
91
91
|
);
|
|
92
92
|
|
|
93
93
|
const use = container.querySelector("use")!;
|
|
94
|
-
expect(use.getAttribute("
|
|
94
|
+
expect(use.getAttribute("href")).toBeNull();
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
it("
|
|
97
|
+
it("keeps camelCase SVG attributes as-is like Preact", () => {
|
|
98
98
|
const vnode = createElement(
|
|
99
99
|
"svg",
|
|
100
100
|
{ viewBox: "0 0 100 100" },
|
|
@@ -115,11 +115,11 @@ describe("render", () => {
|
|
|
115
115
|
const path = container.querySelector("path")!;
|
|
116
116
|
|
|
117
117
|
expect(svg.getAttribute("viewBox")).toBe("0 0 100 100");
|
|
118
|
-
expect(group.getAttribute("
|
|
119
|
-
expect(path.getAttribute("
|
|
120
|
-
expect(path.getAttribute("
|
|
121
|
-
expect(path.getAttribute("
|
|
122
|
-
expect(path.getAttribute("
|
|
118
|
+
expect(group.getAttribute("clipPath")).toBe("url(#sector-mask)");
|
|
119
|
+
expect(path.getAttribute("strokeWidth")).toBe("6");
|
|
120
|
+
expect(path.getAttribute("strokeLinecap")).toBe("round");
|
|
121
|
+
expect(path.getAttribute("strokeLinejoin")).toBe("round");
|
|
122
|
+
expect(path.getAttribute("fillOpacity")).toBe("0.4");
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
it("maps xlinkHref on SVG use elements", () => {
|
|
@@ -132,10 +132,28 @@ describe("render", () => {
|
|
|
132
132
|
render(vnode, container);
|
|
133
133
|
|
|
134
134
|
const use = container.querySelector("use")!;
|
|
135
|
-
expect(use.getAttribute("
|
|
135
|
+
expect(use.getAttribute("href")).toBe("#slice");
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
-
it("
|
|
138
|
+
it("maps xlink:href prop alias on SVG use elements", () => {
|
|
139
|
+
const vnode = createElement(
|
|
140
|
+
"svg",
|
|
141
|
+
null,
|
|
142
|
+
createElement("use", { "xlink:href": "#slice" }),
|
|
143
|
+
);
|
|
144
|
+
render(vnode, container);
|
|
145
|
+
|
|
146
|
+
const use = container.querySelector("use")!;
|
|
147
|
+
expect(use.getAttribute("href")).toBe("#slice");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("maps className to class on SVG elements", () => {
|
|
151
|
+
render(createElement("svg", { className: "icon-wrap" }), container);
|
|
152
|
+
const svg = container.querySelector("svg")!;
|
|
153
|
+
expect(svg.getAttribute("class")).toBe("icon-wrap");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("preserves camelCase SVG attributes that are already camelCase in SVG", () => {
|
|
139
157
|
const vnode = createElement(
|
|
140
158
|
"svg",
|
|
141
159
|
null,
|
|
@@ -152,4 +170,66 @@ describe("render", () => {
|
|
|
152
170
|
expect(gradient.getAttribute("gradient-units")).toBeNull();
|
|
153
171
|
expect(gradient.getAttribute("gradient-transform")).toBeNull();
|
|
154
172
|
});
|
|
173
|
+
|
|
174
|
+
it("keeps nested unknown SVG tags in SVG namespace", () => {
|
|
175
|
+
const vnode = createElement(
|
|
176
|
+
"svg",
|
|
177
|
+
null,
|
|
178
|
+
createElement("g", null, createElement("feSpotLight", { pointsAtX: 30 })),
|
|
179
|
+
);
|
|
180
|
+
render(vnode, container);
|
|
181
|
+
|
|
182
|
+
const node = container.querySelector("feSpotLight")!;
|
|
183
|
+
expect(node.namespaceURI).toBe("http://www.w3.org/2000/svg");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("switches back to HTML namespace under foreignObject", () => {
|
|
187
|
+
const vnode = createElement(
|
|
188
|
+
"svg",
|
|
189
|
+
null,
|
|
190
|
+
createElement(
|
|
191
|
+
"foreignObject",
|
|
192
|
+
null,
|
|
193
|
+
createElement("div", { className: "inside-fo" }, "hello"),
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
render(vnode, container);
|
|
197
|
+
|
|
198
|
+
const div = container.querySelector("div")!;
|
|
199
|
+
expect(div.namespaceURI).toBe("http://www.w3.org/1999/xhtml");
|
|
200
|
+
expect(div.getAttribute("class")).toBe("inside-fo");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("re-enters SVG namespace for nested svg inside foreignObject", () => {
|
|
204
|
+
const vnode = createElement(
|
|
205
|
+
"svg",
|
|
206
|
+
null,
|
|
207
|
+
createElement(
|
|
208
|
+
"foreignObject",
|
|
209
|
+
null,
|
|
210
|
+
createElement("div", null, createElement("svg", null, createElement("path", { d: "M0 0" }))),
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
render(vnode, container);
|
|
214
|
+
|
|
215
|
+
const nestedSvg = container.querySelector("foreignObject svg")!;
|
|
216
|
+
const path = container.querySelector("foreignObject svg path")!;
|
|
217
|
+
expect(nestedSvg.namespaceURI).toBe("http://www.w3.org/2000/svg");
|
|
218
|
+
expect(path.namespaceURI).toBe("http://www.w3.org/2000/svg");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("removes stale SVG attributes on rerender", () => {
|
|
222
|
+
render(
|
|
223
|
+
createElement("svg", null, createElement("path", { strokeWidth: 4, fillOpacity: 0.5 })),
|
|
224
|
+
container,
|
|
225
|
+
);
|
|
226
|
+
render(
|
|
227
|
+
createElement("svg", null, createElement("path", { fillOpacity: 1 })),
|
|
228
|
+
container,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const path = container.querySelector("path")!;
|
|
232
|
+
expect(path.getAttribute("strokeWidth")).toBeNull();
|
|
233
|
+
expect(path.getAttribute("fillOpacity")).toBe("1");
|
|
234
|
+
});
|
|
155
235
|
});
|