@ipxjs/refract 0.12.0 → 0.14.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.12.0",
3
+ "version": "0.14.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",
@@ -91,11 +91,13 @@ function childrenToArray(children: unknown): unknown[] {
91
91
  if (children === undefined || children === null) return [];
92
92
  if (!Array.isArray(children)) return [children];
93
93
  const out: unknown[] = [];
94
- const stack = [...children];
94
+ const stack: unknown[] = [children];
95
95
  while (stack.length > 0) {
96
- const child = stack.shift();
96
+ const child = stack.pop();
97
97
  if (Array.isArray(child)) {
98
- stack.unshift(...child);
98
+ for (let i = child.length - 1; i >= 0; i--) {
99
+ stack.push(child[i]);
100
+ }
99
101
  continue;
100
102
  }
101
103
  if (child === undefined || child === null || typeof child === "boolean") {
@@ -107,6 +107,7 @@ const secretInternals: ReactSecretInternalsCompat = {
107
107
  const externalClientInternals = new Set<ReactClientInternalsCompat>();
108
108
  const externalSecretInternals = new Set<ReactSecretInternalsCompat>();
109
109
  const dispatcherStack: (RefractHookDispatcher | null)[] = [];
110
+ const MAX_EXTERNAL_INTERNALS = 8;
110
111
 
111
112
  let runtimeInitialized = false;
112
113
 
@@ -135,7 +136,7 @@ export function registerExternalReactModule(moduleValue: unknown): void {
135
136
 
136
137
  const candidateClient = moduleRecord.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
137
138
  if (candidateClient && typeof candidateClient === "object" && "H" in (candidateClient as Record<string, unknown>)) {
138
- externalClientInternals.add(candidateClient as ReactClientInternalsCompat);
139
+ addBoundedInternal(externalClientInternals, candidateClient as ReactClientInternalsCompat);
139
140
  }
140
141
 
141
142
  const candidateSecret = moduleRecord.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
@@ -147,12 +148,23 @@ export function registerExternalReactModule(moduleValue: unknown): void {
147
148
  && typeof dispatcherHolder.ReactCurrentDispatcher === "object"
148
149
  && "current" in (dispatcherHolder.ReactCurrentDispatcher as Record<string, unknown>)
149
150
  ) {
150
- externalSecretInternals.add(candidateSecret as ReactSecretInternalsCompat);
151
+ addBoundedInternal(externalSecretInternals, candidateSecret as ReactSecretInternalsCompat);
151
152
  }
152
153
 
153
154
  syncDispatcherToExternal();
154
155
  }
155
156
 
157
+ function addBoundedInternal<T>(set: Set<T>, value: T): void {
158
+ if (set.has(value)) return;
159
+ if (set.size >= MAX_EXTERNAL_INTERNALS) {
160
+ const oldest = set.values().next().value as T | undefined;
161
+ if (oldest !== undefined) {
162
+ set.delete(oldest);
163
+ }
164
+ }
165
+ set.add(value);
166
+ }
167
+
156
168
  function beforeComponentRender(): void {
157
169
  dispatcherStack.push(clientInternals.H);
158
170
  setDispatcher(dispatcher);
@@ -54,8 +54,12 @@ export function renderFiber(vnode: VNode, container: Node): void {
54
54
  };
55
55
  deletions = [];
56
56
  isRendering = true;
57
- performWork(rootFiber);
58
- isRendering = false;
57
+ try {
58
+ performWork(rootFiber);
59
+ } finally {
60
+ isRendering = false;
61
+ currentFiber = null;
62
+ }
59
63
  const committedDeletions = deletions.slice();
60
64
  commitRoot(rootFiber);
61
65
  clearAlternates(rootFiber);
@@ -90,6 +94,7 @@ function processWorkUnit(fiber: Fiber): boolean {
90
94
  if (!tryHandleRenderError(fiber, error)) throw error;
91
95
  } finally {
92
96
  runAfterComponentRenderHandlers(fiber);
97
+ currentFiber = null;
93
98
  }
94
99
  } else if (isFragment) {
95
100
  reconcileChildren(fiber, normalizeChildrenProp(fiber.props.children));
@@ -254,33 +259,57 @@ function getNextDomSibling(fiber: Fiber): Node | null {
254
259
  /** Collect all DOM nodes from a component/fragment fiber's subtree */
255
260
  function collectChildDomNodes(fiber: Fiber): Node[] {
256
261
  const nodes: Node[] = [];
257
- function walk(f: Fiber | null): void {
258
- while (f) {
259
- if (isPortalFiber(f)) {
260
- f = f.sibling;
261
- continue;
262
- }
263
- if (f.dom) {
264
- nodes.push(f.dom);
265
- } else {
266
- walk(f.child);
267
- }
268
- f = f.sibling;
262
+ const stack: Fiber[] = [];
263
+ const rootChildren: Fiber[] = [];
264
+ let child = fiber.child;
265
+ while (child) {
266
+ rootChildren.push(child);
267
+ child = child.sibling;
268
+ }
269
+ for (let i = rootChildren.length - 1; i >= 0; i--) {
270
+ stack.push(rootChildren[i]);
271
+ }
272
+
273
+ while (stack.length > 0) {
274
+ const current = stack.pop()!;
275
+ if (isPortalFiber(current)) {
276
+ continue;
277
+ }
278
+ if (current.dom) {
279
+ nodes.push(current.dom);
280
+ continue;
281
+ }
282
+ const children: Fiber[] = [];
283
+ let next = current.child;
284
+ while (next) {
285
+ children.push(next);
286
+ next = next.sibling;
287
+ }
288
+ for (let i = children.length - 1; i >= 0; i--) {
289
+ stack.push(children[i]);
269
290
  }
270
291
  }
271
- walk(fiber.child);
272
292
  return nodes;
273
293
  }
274
294
 
275
295
  /** Get the first committed DOM node in a fiber subtree */
276
296
  function getFirstCommittedDom(fiber: Fiber): Node | null {
277
- if (isPortalFiber(fiber)) return null;
278
- if (fiber.dom && !(fiber.flags & PLACEMENT)) return fiber.dom;
279
- let child = fiber.child;
280
- while (child) {
281
- const dom = getFirstCommittedDom(child);
282
- if (dom) return dom;
283
- child = child.sibling;
297
+ const stack: Fiber[] = [fiber];
298
+ while (stack.length > 0) {
299
+ const current = stack.pop()!;
300
+ if (isPortalFiber(current)) continue;
301
+ if (current.dom && !(current.flags & PLACEMENT)) {
302
+ return current.dom;
303
+ }
304
+ const children: Fiber[] = [];
305
+ let child = current.child;
306
+ while (child) {
307
+ children.push(child);
308
+ child = child.sibling;
309
+ }
310
+ for (let i = children.length - 1; i >= 0; i--) {
311
+ stack.push(children[i]);
312
+ }
284
313
  }
285
314
  return null;
286
315
  }
@@ -296,69 +325,74 @@ function commitRoot(rootFiber: Fiber): void {
296
325
  }
297
326
 
298
327
  function commitWork(fiber: Fiber): void {
299
- if (isPortalFiber(fiber)) {
300
- fiber.flags = 0;
301
- if (fiber.child) commitWork(fiber.child);
302
- if (fiber.sibling) commitWork(fiber.sibling);
303
- return;
304
- }
328
+ const stack: Fiber[] = [fiber];
329
+ while (stack.length > 0) {
330
+ const current = stack.pop()!;
331
+ if (isPortalFiber(current)) {
332
+ current.flags = 0;
333
+ if (current.sibling) stack.push(current.sibling);
334
+ if (current.child) stack.push(current.child);
335
+ continue;
336
+ }
305
337
 
306
- let parentFiber = fiber.parent;
307
- while (parentFiber && !parentFiber.dom) {
308
- parentFiber = parentFiber.parent;
309
- }
310
- const parentDom = parentFiber!.dom!;
311
-
312
- if (fiber.flags & PLACEMENT) {
313
- if (fiber.dom) {
314
- const before = getNextDomSibling(fiber);
315
- if (before) {
316
- parentDom.insertBefore(fiber.dom, before);
317
- } else {
318
- parentDom.appendChild(fiber.dom);
319
- }
320
- } else {
321
- // Component/fragment: move all child DOM nodes
322
- const domNodes = collectChildDomNodes(fiber);
323
- const before = getNextDomSibling(fiber);
324
- for (const dom of domNodes) {
325
- if (before) {
326
- parentDom.insertBefore(dom, before);
338
+ let parentFiber = current.parent;
339
+ while (parentFiber && !parentFiber.dom) {
340
+ parentFiber = parentFiber.parent;
341
+ }
342
+ const parentDom = parentFiber?.dom;
343
+
344
+ if (parentDom) {
345
+ if (current.flags & PLACEMENT) {
346
+ if (current.dom) {
347
+ const before = getNextDomSibling(current);
348
+ if (before) {
349
+ parentDom.insertBefore(current.dom, before);
350
+ } else {
351
+ parentDom.appendChild(current.dom);
352
+ }
327
353
  } else {
328
- parentDom.appendChild(dom);
354
+ // Component/fragment: move all child DOM nodes
355
+ const domNodes = collectChildDomNodes(current);
356
+ const before = getNextDomSibling(current);
357
+ for (const dom of domNodes) {
358
+ if (before) {
359
+ parentDom.insertBefore(dom, before);
360
+ } else {
361
+ parentDom.appendChild(dom);
362
+ }
363
+ }
364
+ }
365
+ } else if (current.flags & UPDATE && current.dom) {
366
+ if (current.type === "TEXT") {
367
+ const oldValue = current.alternate?.props.nodeValue;
368
+ if (oldValue !== current.props.nodeValue) {
369
+ current.dom.textContent = current.props.nodeValue as string;
370
+ }
371
+ } else {
372
+ applyProps(
373
+ current.dom as HTMLElement,
374
+ current.alternate?.props ?? {},
375
+ current.props,
376
+ );
329
377
  }
330
378
  }
331
379
  }
332
- } else if (fiber.flags & UPDATE && fiber.dom) {
333
- if (fiber.type === "TEXT") {
334
- const oldValue = fiber.alternate?.props.nodeValue;
335
- if (oldValue !== fiber.props.nodeValue) {
336
- fiber.dom.textContent = fiber.props.nodeValue as string;
337
- }
338
- } else {
339
- applyProps(
340
- fiber.dom as HTMLElement,
341
- fiber.alternate?.props ?? {},
342
- fiber.props,
343
- );
344
- }
345
- }
346
380
 
347
- // Handle ref prop — only on mount or when ref changes (like React)
348
- if (fiber.dom && fiber.props.ref) {
349
- const oldRef = fiber.alternate?.props.ref;
350
- if (fiber.flags & PLACEMENT || fiber.props.ref !== oldRef) {
351
- if (oldRef && oldRef !== fiber.props.ref) {
352
- setRef(oldRef, null);
381
+ // Handle ref prop — only on mount or when ref changes (like React)
382
+ if (current.dom && current.props.ref) {
383
+ const oldRef = current.alternate?.props.ref;
384
+ if (current.flags & PLACEMENT || current.props.ref !== oldRef) {
385
+ if (oldRef && oldRef !== current.props.ref) {
386
+ setRef(oldRef, null);
387
+ }
388
+ setRef(current.props.ref, current.dom);
353
389
  }
354
- setRef(fiber.props.ref, fiber.dom);
355
390
  }
356
- }
357
-
358
- fiber.flags = 0;
359
391
 
360
- if (fiber.child) commitWork(fiber.child);
361
- if (fiber.sibling) commitWork(fiber.sibling);
392
+ current.flags = 0;
393
+ if (current.sibling) stack.push(current.sibling);
394
+ if (current.child) stack.push(current.child);
395
+ }
362
396
  }
363
397
 
364
398
  function setRef(ref: unknown, value: Node | null): void {
@@ -373,41 +407,51 @@ function setRef(ref: unknown, value: Node | null): void {
373
407
  * Alternates are only needed during reconciliation; retaining them
374
408
  * creates an ever-growing chain of old fiber trees. */
375
409
  function clearAlternates(fiber: Fiber | null): void {
376
- while (fiber) {
377
- fiber.alternate = null;
378
- if (fiber.child) clearAlternates(fiber.child);
379
- fiber = fiber.sibling;
410
+ if (!fiber) return;
411
+ const stack: Fiber[] = [fiber];
412
+ while (stack.length > 0) {
413
+ const current = stack.pop()!;
414
+ current.alternate = null;
415
+ if (current.sibling) stack.push(current.sibling);
416
+ if (current.child) stack.push(current.child);
380
417
  }
381
418
  }
382
419
 
383
420
  function commitDeletion(fiber: Fiber): void {
384
421
  runCleanups(fiber);
385
- // Clear ref on unmount
386
- if (fiber.dom && fiber.props.ref) {
387
- setRef(fiber.props.ref, null);
388
- }
389
- if (isPortalFiber(fiber)) {
390
- let child: Fiber | null = fiber.child;
391
- while (child) {
392
- commitDeletion(child);
393
- child = child.sibling;
422
+ walkSubtree(fiber, (node) => {
423
+ if (node.dom && node.props.ref) {
424
+ setRef(node.props.ref, null);
394
425
  }
395
- } else if (fiber.dom) {
396
- fiber.dom.parentNode?.removeChild(fiber.dom);
397
- } else if (fiber.child) {
398
- // Fragment/component — delete children
399
- let child: Fiber | null = fiber.child;
400
- while (child) {
401
- commitDeletion(child);
402
- child = child.sibling;
426
+ });
427
+ walkSubtree(fiber, (node) => {
428
+ if (!isPortalFiber(node) && node.dom) {
429
+ node.dom.parentNode?.removeChild(node.dom);
403
430
  }
404
- }
431
+ });
405
432
  }
406
433
 
407
434
  function runCleanups(fiber: Fiber): void {
408
- runFiberCleanupHandlers(fiber);
409
- if (fiber.child) runCleanups(fiber.child);
410
- if (fiber.sibling) runCleanups(fiber.sibling);
435
+ walkSubtree(fiber, (node) => {
436
+ runFiberCleanupHandlers(node);
437
+ });
438
+ }
439
+
440
+ function walkSubtree(root: Fiber, visit: (fiber: Fiber) => void): void {
441
+ const stack: Fiber[] = [root];
442
+ while (stack.length > 0) {
443
+ const current = stack.pop()!;
444
+ visit(current);
445
+ const children: Fiber[] = [];
446
+ let child = current.child;
447
+ while (child) {
448
+ children.push(child);
449
+ child = child.sibling;
450
+ }
451
+ for (let i = children.length - 1; i >= 0; i--) {
452
+ stack.push(children[i]);
453
+ }
454
+ }
411
455
  }
412
456
 
413
457
  const pendingContainers = new Set<Node>();
@@ -473,8 +517,12 @@ function flushRenders(): void {
473
517
  };
474
518
  deletions = [];
475
519
  isRendering = true;
476
- performWork(newRoot);
477
- isRendering = false;
520
+ try {
521
+ performWork(newRoot);
522
+ } finally {
523
+ isRendering = false;
524
+ currentFiber = null;
525
+ }
478
526
  const committedDeletions = deletions.slice();
479
527
  commitRoot(newRoot);
480
528
  clearAlternates(newRoot);
@@ -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
- ]);
@@ -36,9 +36,11 @@ export function useState<T>(initial: T | (() => T)): [T, (value: T | ((prev: T)
36
36
  // Create a stable setter that is reused across renders (like React)
37
37
  if (!hook._setter) {
38
38
  hook._setter = (value: T | ((prev: T) => T)) => {
39
+ if (!hook._fiber) return;
39
40
  const action = typeof value === "function"
40
41
  ? value as (prev: T) => T
41
42
  : () => value;
43
+ if (!hook.queue) hook.queue = [];
42
44
  (hook.queue as ((prev: T) => T)[]).push(action);
43
45
  scheduleRender(hook._fiber!);
44
46
  };
@@ -191,6 +193,8 @@ export function useReducer<S, A, I>(
191
193
  // Create stable dispatch (like React)
192
194
  if (!hook._dispatch) {
193
195
  hook._dispatch = (action: A) => {
196
+ if (!hook._fiber) return;
197
+ if (!hook.queue) hook.queue = [];
194
198
  (hook.queue as A[]).push(action);
195
199
  scheduleRender(hook._fiber!);
196
200
  };
@@ -33,6 +33,8 @@ function cleanupFiberEffects(fiber: Fiber): void {
33
33
  if (!fiber.hooks) return;
34
34
 
35
35
  for (const hook of fiber.hooks) {
36
+ hook.queue = undefined;
37
+ hook._fiber = undefined;
36
38
  const state = hook.state;
37
39
  if (!state || typeof state !== "object") continue;
38
40
  const effectState = state as { cleanup?: () => void; pending?: boolean };
@@ -71,6 +71,25 @@ describe("hooks", () => {
71
71
  expect(renderCount).toBe(2);
72
72
  expect(container.querySelector("span")!.textContent).toBe("3");
73
73
  });
74
+
75
+ it("ignores setState after unmount", async () => {
76
+ let setCount!: (v: number | ((p: number) => number)) => void;
77
+ let renders = 0;
78
+ function Counter() {
79
+ const [count, sc] = useState(0);
80
+ setCount = sc;
81
+ renders++;
82
+ return createElement("span", null, String(count));
83
+ }
84
+
85
+ render(createElement(Counter, null), container);
86
+ render(createElement("div", null, "gone"), container);
87
+
88
+ expect(() => setCount(1)).not.toThrow();
89
+ await new Promise((r) => setTimeout(r, 10));
90
+ expect(container.textContent).toBe("gone");
91
+ expect(renders).toBe(1);
92
+ });
74
93
  });
75
94
 
76
95
  describe("useEffect", () => {
@@ -269,5 +288,69 @@ describe("hooks", () => {
269
288
  await new Promise((r) => setTimeout(r, 10));
270
289
  expect(container.querySelector("span")!.textContent).toBe("2");
271
290
  });
291
+
292
+ it("ignores dispatch after unmount", async () => {
293
+ type Action = { type: "inc" };
294
+ let dispatch!: (action: Action) => void;
295
+ let renders = 0;
296
+ function Counter() {
297
+ const [count, d] = useReducer((state: number, action: Action) => {
298
+ if (action.type === "inc") return state + 1;
299
+ return state;
300
+ }, 0);
301
+ dispatch = d;
302
+ renders++;
303
+ return createElement("span", null, String(count));
304
+ }
305
+
306
+ render(createElement(Counter, null), container);
307
+ render(createElement("div", null, "gone"), container);
308
+
309
+ expect(() => dispatch({ type: "inc" })).not.toThrow();
310
+ await new Promise((r) => setTimeout(r, 10));
311
+ expect(container.textContent).toBe("gone");
312
+ expect(renders).toBe(1);
313
+ });
314
+ });
315
+
316
+ describe("cleanup scoping", () => {
317
+ it("only cleans up deleted subtree effects", async () => {
318
+ const leftCleanup = vi.fn();
319
+ const rightCleanup = vi.fn();
320
+ let setShowLeft!: (v: boolean) => void;
321
+
322
+ function Left() {
323
+ useEffect(() => leftCleanup, []);
324
+ return createElement("span", null, "left");
325
+ }
326
+
327
+ function Right() {
328
+ useEffect(() => rightCleanup, []);
329
+ return createElement("span", null, "right");
330
+ }
331
+
332
+ function App() {
333
+ const [showLeft, ss] = useState(true);
334
+ setShowLeft = ss;
335
+ return createElement(
336
+ "div",
337
+ null,
338
+ showLeft ? createElement(Left, { key: "left" }) : null,
339
+ createElement(Right, { key: "right" }),
340
+ );
341
+ }
342
+
343
+ render(createElement(App, null), container);
344
+ flushPassiveEffects();
345
+ expect(leftCleanup).toHaveBeenCalledTimes(0);
346
+ expect(rightCleanup).toHaveBeenCalledTimes(0);
347
+
348
+ setShowLeft(false);
349
+ await new Promise((r) => setTimeout(r, 10));
350
+ flushPassiveEffects();
351
+
352
+ expect(leftCleanup).toHaveBeenCalledTimes(1);
353
+ expect(rightCleanup).toHaveBeenCalledTimes(0);
354
+ });
272
355
  });
273
356
  });
@@ -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
  });