@ipxjs/refract 0.10.1 → 0.11.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 +6 -5
- package/package.json +1 -1
- package/src/refract/devtools.ts +7 -1
- package/src/refract/dom.ts +137 -17
- package/tests/render.test.ts +32 -0
package/README.md
CHANGED
|
@@ -158,9 +158,10 @@ subsequent renders to the same container.
|
|
|
158
158
|
|
|
159
159
|
### DevTools hook integration
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
Call `setDevtoolsHook(...)` to enable commit and unmount events. Refract uses
|
|
162
|
+
the explicit hook you provide, or falls back to
|
|
162
163
|
`window.__REFRACT_DEVTOOLS_GLOBAL_HOOK__` (or `globalThis` in non-browser
|
|
163
|
-
environments)
|
|
164
|
+
environments) when called with no argument.
|
|
164
165
|
|
|
165
166
|
```ts
|
|
166
167
|
import { setDevtoolsHook } from "refract";
|
|
@@ -229,7 +230,7 @@ The values below are from a local run on February 17, 2026.
|
|
|
229
230
|
| Refract (`core+context`) | 10.66 kB | 4.00 kB |
|
|
230
231
|
| Refract (`core+memo`) | 10.70 kB | 3.97 kB |
|
|
231
232
|
| Refract (`core+security`) | 10.93 kB | 4.03 kB |
|
|
232
|
-
| Refract (`refract`) |
|
|
233
|
+
| Refract (`refract`) | 14.45 kB | 5.28 kB |
|
|
233
234
|
| React | 189.74 kB | 59.52 kB |
|
|
234
235
|
| Preact | 14.46 kB | 5.95 kB |
|
|
235
236
|
|
|
@@ -239,7 +240,7 @@ The CI preset (`make bench-ci`, 40 measured + 5 warmup runs) enforces default
|
|
|
239
240
|
guardrails (`DCL p95 <= 16ms`, `DCL sd <= 2ms`).
|
|
240
241
|
|
|
241
242
|
From this snapshot, Refract `core` gzip JS is about 15.9x smaller than React,
|
|
242
|
-
and the full `refract` entrypoint is about
|
|
243
|
+
and the full `refract` entrypoint is about 11.3x smaller.
|
|
243
244
|
|
|
244
245
|
### Component Combination Benchmarks (Vitest)
|
|
245
246
|
|
|
@@ -344,7 +345,7 @@ How Refract compares to React and Preact:
|
|
|
344
345
|
| **Ecosystem** | | | |
|
|
345
346
|
| DevTools | Basic (hook API) | Yes | Yes |
|
|
346
347
|
| React compatibility layer | Partial⁶ | N/A | Yes⁷ |
|
|
347
|
-
| **Bundle Size (gzip, JS)** | ~3.7-
|
|
348
|
+
| **Bundle Size (gzip, JS)** | ~3.7-5.3 kB⁴ | ~59.5 kB | ~6.0 kB |
|
|
348
349
|
|
|
349
350
|
¹ Preact supports both `class` and `className`.
|
|
350
351
|
² Preact has partial Suspense support via `preact/compat`.
|
package/package.json
CHANGED
package/src/refract/devtools.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface RefractDevtoolsHook {
|
|
|
32
32
|
let explicitHook: RefractDevtoolsHook | null | undefined = undefined;
|
|
33
33
|
let activeHook: RefractDevtoolsHook | null = null;
|
|
34
34
|
let activeRendererId = 1;
|
|
35
|
+
let commitReporterRegistered = false;
|
|
35
36
|
|
|
36
37
|
const containerIds = new WeakMap<Node, number>();
|
|
37
38
|
let nextContainerId = 1;
|
|
@@ -40,6 +41,7 @@ const fiberIds = new WeakMap<Fiber, number>();
|
|
|
40
41
|
let nextFiberId = 1;
|
|
41
42
|
|
|
42
43
|
export function setDevtoolsHook(hook?: RefractDevtoolsHook | null): void {
|
|
44
|
+
ensureDevtoolsCommitReporting();
|
|
43
45
|
explicitHook = hook;
|
|
44
46
|
activeHook = null;
|
|
45
47
|
activeRendererId = 1;
|
|
@@ -68,7 +70,11 @@ export function reportDevtoolsCommit(rootFiber: Fiber, deletions: Fiber[]): void
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
function ensureDevtoolsCommitReporting(): void {
|
|
74
|
+
if (commitReporterRegistered) return;
|
|
75
|
+
commitReporterRegistered = true;
|
|
76
|
+
registerCommitHandler(reportDevtoolsCommit);
|
|
77
|
+
}
|
|
72
78
|
|
|
73
79
|
function resolveHook(): RefractDevtoolsHook | null {
|
|
74
80
|
if (explicitHook !== undefined) return explicitHook;
|
package/src/refract/dom.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
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/";
|
|
4
7
|
const SVG_TAGS = new Set([
|
|
5
8
|
"svg", "circle", "ellipse", "line", "path", "polygon", "polyline",
|
|
6
9
|
"rect", "g", "defs", "use", "text", "tspan", "image", "clipPath",
|
|
@@ -103,7 +106,12 @@ export function applyProps(
|
|
|
103
106
|
if (key.startsWith("on")) {
|
|
104
107
|
el.removeEventListener(key.slice(2).toLowerCase(), getEventListener(oldProps[key]));
|
|
105
108
|
} else {
|
|
106
|
-
|
|
109
|
+
const attr = normalizeAttributeName(key, isSvgElement);
|
|
110
|
+
if (attr.namespaceURI) {
|
|
111
|
+
el.removeAttributeNS(attr.namespaceURI, attr.localName);
|
|
112
|
+
} else {
|
|
113
|
+
el.removeAttribute(attr.name);
|
|
114
|
+
}
|
|
107
115
|
}
|
|
108
116
|
}
|
|
109
117
|
}
|
|
@@ -167,17 +175,34 @@ export function applyProps(
|
|
|
167
175
|
el.addEventListener(event, getEventListener(newProps[key]));
|
|
168
176
|
} else {
|
|
169
177
|
const value = newProps[key];
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
178
|
+
const attr = normalizeAttributeName(key, isSvgElement);
|
|
179
|
+
const securityKey = isSvgElement ? attr.name : key;
|
|
180
|
+
if (unsafeUrlPropChecker(securityKey, value)) {
|
|
181
|
+
if (attr.namespaceURI) {
|
|
182
|
+
el.removeAttributeNS(attr.namespaceURI, attr.localName);
|
|
183
|
+
} else {
|
|
184
|
+
el.removeAttribute(attr.name);
|
|
185
|
+
}
|
|
173
186
|
continue;
|
|
174
187
|
}
|
|
175
188
|
if (value == null || value === false) {
|
|
176
|
-
|
|
189
|
+
if (attr.namespaceURI) {
|
|
190
|
+
el.removeAttributeNS(attr.namespaceURI, attr.localName);
|
|
191
|
+
} else {
|
|
192
|
+
el.removeAttribute(attr.name);
|
|
193
|
+
}
|
|
177
194
|
} else if (value === true) {
|
|
178
|
-
|
|
195
|
+
if (attr.namespaceURI) {
|
|
196
|
+
el.setAttributeNS(attr.namespaceURI, attr.name, "true");
|
|
197
|
+
} else {
|
|
198
|
+
el.setAttribute(attr.name, "true");
|
|
199
|
+
}
|
|
179
200
|
} else {
|
|
180
|
-
|
|
201
|
+
if (attr.namespaceURI) {
|
|
202
|
+
el.setAttributeNS(attr.namespaceURI, attr.name, String(value));
|
|
203
|
+
} else {
|
|
204
|
+
el.setAttribute(attr.name, String(value));
|
|
205
|
+
}
|
|
181
206
|
}
|
|
182
207
|
}
|
|
183
208
|
break;
|
|
@@ -185,19 +210,114 @@ export function applyProps(
|
|
|
185
210
|
}
|
|
186
211
|
}
|
|
187
212
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
213
|
+
type NormalizedAttribute = {
|
|
214
|
+
name: string;
|
|
215
|
+
localName: string;
|
|
216
|
+
namespaceURI: string | null;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
function normalizeAttributeName(key: string, isSvgElement: boolean): NormalizedAttribute {
|
|
220
|
+
if (!isSvgElement) {
|
|
221
|
+
return { name: key, localName: key, namespaceURI: null };
|
|
222
|
+
}
|
|
223
|
+
|
|
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 };
|
|
198
250
|
}
|
|
199
251
|
|
|
200
252
|
const SVG_ATTR_CASE_PRESERVED = new Set([
|
|
201
253
|
"viewBox",
|
|
202
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",
|
|
203
323
|
]);
|
package/tests/render.test.ts
CHANGED
|
@@ -80,6 +80,20 @@ describe("render", () => {
|
|
|
80
80
|
expect(link.getAttribute("href")).toBe("https://example.com");
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
it("blocks javascript: URLs on SVG xlinkHref", () => {
|
|
84
|
+
render(
|
|
85
|
+
createElement(
|
|
86
|
+
"svg",
|
|
87
|
+
null,
|
|
88
|
+
createElement("use", { xlinkHref: "javascript:alert('xss')" }),
|
|
89
|
+
),
|
|
90
|
+
container,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const use = container.querySelector("use")!;
|
|
94
|
+
expect(use.getAttribute("xlink:href")).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
83
97
|
it("normalizes camelCase SVG attributes used by chart paths", () => {
|
|
84
98
|
const vnode = createElement(
|
|
85
99
|
"svg",
|
|
@@ -120,4 +134,22 @@ describe("render", () => {
|
|
|
120
134
|
const use = container.querySelector("use")!;
|
|
121
135
|
expect(use.getAttribute("xlink:href")).toBe("#slice");
|
|
122
136
|
});
|
|
137
|
+
|
|
138
|
+
it("preserves camelCase SVG attributes that are not hyphenated in SVG", () => {
|
|
139
|
+
const vnode = createElement(
|
|
140
|
+
"svg",
|
|
141
|
+
null,
|
|
142
|
+
createElement(
|
|
143
|
+
"linearGradient",
|
|
144
|
+
{ id: "g", gradientUnits: "userSpaceOnUse", gradientTransform: "rotate(25)" },
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
render(vnode, container);
|
|
148
|
+
|
|
149
|
+
const gradient = container.querySelector("linearGradient")!;
|
|
150
|
+
expect(gradient.getAttribute("gradientUnits")).toBe("userSpaceOnUse");
|
|
151
|
+
expect(gradient.getAttribute("gradientTransform")).toBe("rotate(25)");
|
|
152
|
+
expect(gradient.getAttribute("gradient-units")).toBeNull();
|
|
153
|
+
expect(gradient.getAttribute("gradient-transform")).toBeNull();
|
|
154
|
+
});
|
|
123
155
|
});
|