@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 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 22, 2026):
131
- - `yarn test`: 14 files passed, 91 tests passed
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ipxjs/refract",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "A minimal React-like virtual DOM library with an optional React compat layer",
5
5
  "type": "module",
6
6
  "main": "src/refract/index.ts",
@@ -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 = SVG_TAGS.has(tag) || isSvgContext(fiber);
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
- if (key.startsWith("xlink") && key.length > 5) {
225
- const local = key.slice(5);
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;
@@ -91,10 +91,10 @@ describe("render", () => {
91
91
  );
92
92
 
93
93
  const use = container.querySelector("use")!;
94
- expect(use.getAttribute("xlink:href")).toBeNull();
94
+ expect(use.getAttribute("href")).toBeNull();
95
95
  });
96
96
 
97
- it("normalizes camelCase SVG attributes used by chart paths", () => {
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("clip-path")).toBe("url(#sector-mask)");
119
- expect(path.getAttribute("stroke-width")).toBe("6");
120
- expect(path.getAttribute("stroke-linecap")).toBe("round");
121
- expect(path.getAttribute("stroke-linejoin")).toBe("round");
122
- expect(path.getAttribute("fill-opacity")).toBe("0.4");
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("xlink:href")).toBe("#slice");
135
+ expect(use.getAttribute("href")).toBe("#slice");
136
136
  });
137
137
 
138
- it("preserves camelCase SVG attributes that are not hyphenated in SVG", () => {
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
  });