@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 +3 -3
- package/package.json +1 -1
- package/src/refract/compat/react.ts +5 -3
- package/src/refract/compat/sharedInternals.ts +14 -2
- package/src/refract/coreRenderer.ts +151 -103
- package/src/refract/dom.ts +4 -110
- package/src/refract/features/hooks.ts +4 -0
- package/src/refract/hooksRuntime.ts +2 -0
- package/tests/hooks.test.ts +83 -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
|
@@ -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 = [
|
|
94
|
+
const stack: unknown[] = [children];
|
|
95
95
|
while (stack.length > 0) {
|
|
96
|
-
const child = stack.
|
|
96
|
+
const child = stack.pop();
|
|
97
97
|
if (Array.isArray(child)) {
|
|
98
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (
|
|
303
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
477
|
-
|
|
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);
|
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
|
-
]);
|
|
@@ -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 };
|
package/tests/hooks.test.ts
CHANGED
|
@@ -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
|
});
|
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
|
});
|