@ipxjs/refract 0.9.0 → 0.10.1
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 +32 -29
- package/package.json +5 -1
- package/src/refract/compat/react-jsx-runtime.ts +3 -3
- package/src/refract/compat/react.ts +77 -3
- package/src/refract/coreRenderer.ts +63 -4
- package/src/refract/dom.ts +28 -8
- package/src/refract/features/context.ts +31 -3
- package/src/refract/reconcile.ts +2 -2
- package/src/refract/types.ts +1 -0
- package/tests/mui-pie-smoke.test.ts +54 -0
- package/tests/render.test.ts +41 -0
package/README.md
CHANGED
|
@@ -224,22 +224,22 @@ The values below are from a local run on February 17, 2026.
|
|
|
224
224
|
|
|
225
225
|
| Framework | JS bundle (raw) | JS bundle (gzip) |
|
|
226
226
|
|---------------------------|----------------:|-----------------:|
|
|
227
|
-
| Refract (`core`) |
|
|
228
|
-
| Refract (`core+hooks`) |
|
|
229
|
-
| Refract (`core+context`) |
|
|
230
|
-
| Refract (`core+memo`) |
|
|
231
|
-
| Refract (`core+security`) |
|
|
232
|
-
| Refract (`refract`) | 15
|
|
233
|
-
| React | 189.74 kB | 59.
|
|
234
|
-
| Preact | 14.46 kB | 5.
|
|
227
|
+
| Refract (`core`) | 10.02 kB | 3.74 kB |
|
|
228
|
+
| Refract (`core+hooks`) | 12.08 kB | 4.49 kB |
|
|
229
|
+
| Refract (`core+context`) | 10.66 kB | 4.00 kB |
|
|
230
|
+
| Refract (`core+memo`) | 10.70 kB | 3.97 kB |
|
|
231
|
+
| Refract (`core+security`) | 10.93 kB | 4.03 kB |
|
|
232
|
+
| Refract (`refract`) | 17.15 kB | 6.23 kB |
|
|
233
|
+
| React | 189.74 kB | 59.52 kB |
|
|
234
|
+
| Preact | 14.46 kB | 5.95 kB |
|
|
235
235
|
|
|
236
236
|
Load-time metrics are machine-dependent, so the benchmark script prints a fresh
|
|
237
237
|
per-run timing table (median, p95, min/max, sd) for every framework.
|
|
238
|
-
The CI preset (`make bench-ci`, 40 measured + 5 warmup runs)
|
|
239
|
-
|
|
238
|
+
The CI preset (`make bench-ci`, 40 measured + 5 warmup runs) enforces default
|
|
239
|
+
guardrails (`DCL p95 <= 16ms`, `DCL sd <= 2ms`).
|
|
240
240
|
|
|
241
|
-
From this snapshot, Refract `core` gzip JS is about
|
|
242
|
-
and the full `refract` entrypoint is about
|
|
241
|
+
From this snapshot, Refract `core` gzip JS is about 15.9x smaller than React,
|
|
242
|
+
and the full `refract` entrypoint is about 9.6x smaller.
|
|
243
243
|
|
|
244
244
|
### Component Combination Benchmarks (Vitest)
|
|
245
245
|
|
|
@@ -249,15 +249,15 @@ Higher `hz` is better.
|
|
|
249
249
|
|
|
250
250
|
| Component usage profile | Mount (hz) | Mount vs base | Reconcile (hz) | Reconcile vs base |
|
|
251
251
|
|-------------------------|------------|---------------|----------------|-------------------|
|
|
252
|
-
| `base` |
|
|
253
|
-
| `memo` |
|
|
254
|
-
| `context` |
|
|
255
|
-
| `fragment` |
|
|
256
|
-
| `keyed` |
|
|
257
|
-
| `memo+context` |
|
|
258
|
-
| `memo+context+keyed` |
|
|
259
|
-
|
|
260
|
-
In this run, `
|
|
252
|
+
| `base` | 5160.53 | baseline | 3262.99 | baseline |
|
|
253
|
+
| `memo` | 4858.51 | -5.9% | 5092.55 | +56.1% |
|
|
254
|
+
| `context` | 3862.01 | -25.2% | 4954.24 | +51.8% |
|
|
255
|
+
| `fragment` | 4656.36 | -9.8% | 3760.97 | +15.3% |
|
|
256
|
+
| `keyed` | 5754.75 | +11.5% | 4311.45 | +32.1% |
|
|
257
|
+
| `memo+context` | 5872.59 | +13.8% | 4621.45 | +41.6% |
|
|
258
|
+
| `memo+context+keyed` | 5555.42 | +7.7% | 4509.78 | +38.2% |
|
|
259
|
+
|
|
260
|
+
In this run, `memo+context` was the fastest mount profile, while
|
|
261
261
|
`memo` was the fastest reconcile profile.
|
|
262
262
|
|
|
263
263
|
### Running the Benchmark
|
|
@@ -304,7 +304,7 @@ How Refract compares to React and Preact:
|
|
|
304
304
|
| SVG support | Yes | Yes | Yes |
|
|
305
305
|
| **Components** | | | |
|
|
306
306
|
| Functional components | Yes | Yes | Yes |
|
|
307
|
-
| Class components |
|
|
307
|
+
| Class components | Partial⁶ | Yes | Yes |
|
|
308
308
|
| **Hooks** | | | |
|
|
309
309
|
| useState | Yes | Yes | Yes |
|
|
310
310
|
| useEffect | Yes | Yes | Yes |
|
|
@@ -329,10 +329,10 @@ How Refract compares to React and Preact:
|
|
|
329
329
|
| className prop | Yes | Yes | Yes¹ |
|
|
330
330
|
| dangerouslySetInnerHTML | Yes | Yes | Yes |
|
|
331
331
|
| Portals | Yes | Yes | Yes |
|
|
332
|
-
| Suspense / lazy | No
|
|
332
|
+
| Suspense / lazy | No⁸ | Yes | Yes² |
|
|
333
333
|
| Error boundaries | Yes³ | Yes | Yes |
|
|
334
334
|
| Server-side rendering | No | Yes | Yes |
|
|
335
|
-
| Hydration | No
|
|
335
|
+
| Hydration | No⁹ | Yes | Yes |
|
|
336
336
|
| **Security** | | | |
|
|
337
337
|
| Default HTML sanitizer for `dangerouslySetInnerHTML` | Yes | No | No |
|
|
338
338
|
| Configurable HTML sanitizer hook (`setHtmlSanitizer`) | Yes | No | No |
|
|
@@ -340,19 +340,22 @@ How Refract compares to React and Preact:
|
|
|
340
340
|
| Fiber architecture | Yes | Yes | No |
|
|
341
341
|
| Concurrent rendering | No | Yes | No |
|
|
342
342
|
| Automatic batching | Yes | Yes | Yes |
|
|
343
|
-
| memo / PureComponent |
|
|
343
|
+
| memo / PureComponent | Partial¹⁰ | Yes | Yes |
|
|
344
344
|
| **Ecosystem** | | | |
|
|
345
345
|
| DevTools | Basic (hook API) | Yes | Yes |
|
|
346
|
-
| React compatibility layer |
|
|
347
|
-
| **Bundle Size (gzip, JS)** | ~3.
|
|
346
|
+
| React compatibility layer | Partial⁶ | N/A | Yes⁷ |
|
|
347
|
+
| **Bundle Size (gzip, JS)** | ~3.7-6.3 kB⁴ | ~59.5 kB | ~6.0 kB |
|
|
348
348
|
|
|
349
349
|
¹ Preact supports both `class` and `className`.
|
|
350
350
|
² Preact has partial Suspense support via `preact/compat`.
|
|
351
|
-
³ Refract uses the `useErrorBoundary` hook
|
|
351
|
+
³ Refract core uses the `useErrorBoundary` hook; compat wrappers can emulate class-style boundaries.
|
|
352
352
|
⁴ Refract size depends on entrypoint (`refract/core` vs `refract` full).
|
|
353
353
|
⁵ Refract exposes `useTransition` / `useDeferredValue` but currently runs both synchronously (no concurrent scheduling).
|
|
354
|
-
⁶ Available via opt-in compat entrypoints (`refract/compat/react*`).
|
|
354
|
+
⁶ Available via opt-in compat entrypoints (`refract/compat/react*`) with partial React API parity.
|
|
355
355
|
⁷ Preact compatibility is provided through `preact/compat`.
|
|
356
|
+
⁸ Compat exports `Suspense`/`lazy`, but full suspension/fallback semantics are not implemented.
|
|
357
|
+
⁹ `hydrateRoot` is exposed in compat, but currently performs client render rather than true SSR hydration.
|
|
358
|
+
¹⁰ `memo` is supported; `PureComponent` is compat-oriented and does not guarantee full React shallow-compare behavior.
|
|
356
359
|
|
|
357
360
|
## License
|
|
358
361
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ipxjs/refract",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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",
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
},
|
|
29
29
|
"license": "MIT",
|
|
30
30
|
"devDependencies": {
|
|
31
|
+
"@emotion/react": "^11.14.0",
|
|
32
|
+
"@emotion/styled": "^11.14.1",
|
|
33
|
+
"@mui/material": "^7.3.8",
|
|
34
|
+
"@mui/x-charts": "^8.27.0",
|
|
31
35
|
"@types/jsdom": "^27.0.0",
|
|
32
36
|
"jsdom": "^28.0.0",
|
|
33
37
|
"react": "^19.2.4",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createElement, Fragment } from "../createElement.js";
|
|
2
2
|
import type { VNode, VNodeType } from "../types.js";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveCompatType, getWrappedHandler } from "./react.js";
|
|
4
4
|
|
|
5
5
|
type JsxProps = Record<string, unknown> | null | undefined;
|
|
6
6
|
type JsxChild = VNode | string | number | boolean | null | undefined | JsxChild[];
|
|
@@ -19,7 +19,7 @@ function normalizeVNodeChildren(vnode: VNode): VNode {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function createJsxElement(type: VNodeType, rawProps: JsxProps, key?: string): ReturnType<typeof createElement> {
|
|
22
|
-
const effectiveType =
|
|
22
|
+
const effectiveType = resolveCompatType(type);
|
|
23
23
|
|
|
24
24
|
const props = { ...(rawProps ?? {}) };
|
|
25
25
|
if (key !== undefined) {
|
|
@@ -56,4 +56,4 @@ export function jsxs(type: VNodeType, props: JsxProps, key?: string): ReturnType
|
|
|
56
56
|
return createJsxElement(type, props, key);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export { Fragment };
|
|
59
|
+
export { Fragment };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createElement as createElementImpl, Fragment } from "../createElement.js";
|
|
2
2
|
import { memo } from "../memo.js";
|
|
3
|
-
import { createContext as createContextImpl } from "../features/context.js";
|
|
3
|
+
import { createContext as createContextImpl, setContextValue } from "../features/context.js";
|
|
4
4
|
import {
|
|
5
5
|
createRef,
|
|
6
6
|
useErrorBoundary,
|
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
} from "./sharedInternals.js";
|
|
16
16
|
|
|
17
17
|
const REACT_ELEMENT_TYPE = Symbol.for("react.element");
|
|
18
|
+
const REACT_FORWARD_REF_TYPE = Symbol.for("react.forward_ref");
|
|
19
|
+
const REACT_MEMO_TYPE = Symbol.for("react.memo");
|
|
20
|
+
const REACT_CONTEXT_TYPE = Symbol.for("react.context");
|
|
18
21
|
type ElementChild = VNode | string | number | boolean | null | undefined | ElementChild[];
|
|
19
22
|
|
|
20
23
|
ensureHookDispatcherRuntime();
|
|
@@ -292,6 +295,7 @@ export function lazy<T extends { default: (...args: any[]) => any }>(
|
|
|
292
295
|
// ---------------------------------------------------------------------------
|
|
293
296
|
|
|
294
297
|
const classWrapperCache = new WeakMap<Function, Function>();
|
|
298
|
+
const exoticTypeCache = new WeakMap<object, unknown>();
|
|
295
299
|
|
|
296
300
|
export function isClassComponent(type: unknown): boolean {
|
|
297
301
|
return (
|
|
@@ -389,6 +393,76 @@ export function wrapClassComponent(ClassComp: Function): (props: any) => unknown
|
|
|
389
393
|
return Wrapper;
|
|
390
394
|
}
|
|
391
395
|
|
|
396
|
+
export function resolveCompatType(type: unknown): unknown {
|
|
397
|
+
if (isClassComponent(type)) {
|
|
398
|
+
return wrapClassComponent(type as Function);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (!type || typeof type !== "object") {
|
|
402
|
+
return type;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const cached = exoticTypeCache.get(type as object);
|
|
406
|
+
if (cached) return cached;
|
|
407
|
+
|
|
408
|
+
const exoticType = type as {
|
|
409
|
+
$$typeof?: symbol;
|
|
410
|
+
compare?: (a: Record<string, unknown>, b: Record<string, unknown>) => boolean;
|
|
411
|
+
displayName?: string;
|
|
412
|
+
render?: (props: Props, ref: { current: unknown } | ((value: unknown) => void) | null) => VNode;
|
|
413
|
+
type?: unknown;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
if (exoticType.$$typeof === REACT_FORWARD_REF_TYPE && typeof exoticType.render === "function") {
|
|
417
|
+
const render = exoticType.render;
|
|
418
|
+
const wrappedForwardRef: RefractComponent = (props: Props) => {
|
|
419
|
+
const { ref, ...rest } = props as Props & { ref?: { current: unknown } | ((value: unknown) => void) | null };
|
|
420
|
+
return render(rest, ref ?? null);
|
|
421
|
+
};
|
|
422
|
+
(wrappedForwardRef as any).displayName = exoticType.displayName
|
|
423
|
+
?? `ForwardRef(${(render as any).name || "anonymous"})`;
|
|
424
|
+
exoticTypeCache.set(type as object, wrappedForwardRef);
|
|
425
|
+
return wrappedForwardRef;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (exoticType.$$typeof === REACT_MEMO_TYPE && exoticType.type != null) {
|
|
429
|
+
const resolvedInner = resolveCompatType(exoticType.type);
|
|
430
|
+
if (typeof resolvedInner === "function") {
|
|
431
|
+
const wrappedMemo = memo(
|
|
432
|
+
resolvedInner as (props: Props) => VNode,
|
|
433
|
+
typeof exoticType.compare === "function" ? exoticType.compare : undefined,
|
|
434
|
+
);
|
|
435
|
+
(wrappedMemo as any).displayName = exoticType.displayName
|
|
436
|
+
?? (resolvedInner as any).displayName
|
|
437
|
+
?? (resolvedInner as any).name
|
|
438
|
+
?? "Memo";
|
|
439
|
+
exoticTypeCache.set(type as object, wrappedMemo);
|
|
440
|
+
return wrappedMemo;
|
|
441
|
+
}
|
|
442
|
+
exoticTypeCache.set(type as object, resolvedInner);
|
|
443
|
+
return resolvedInner;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (exoticType.$$typeof === REACT_CONTEXT_TYPE) {
|
|
447
|
+
const contextObject = type as object;
|
|
448
|
+
const wrappedContextProvider: RefractComponent = (props: Props) => {
|
|
449
|
+
setContextValue(contextObject, props.value);
|
|
450
|
+
const children = props.children ?? [];
|
|
451
|
+
if (Array.isArray(children)) {
|
|
452
|
+
return children.length === 1
|
|
453
|
+
? children[0] as VNode
|
|
454
|
+
: createElementImpl(Fragment, null, ...(children as ElementChild[]));
|
|
455
|
+
}
|
|
456
|
+
return children as VNode;
|
|
457
|
+
};
|
|
458
|
+
(wrappedContextProvider as any).displayName = (exoticType as any).displayName ?? "ContextProvider";
|
|
459
|
+
exoticTypeCache.set(type as object, wrappedContextProvider);
|
|
460
|
+
return wrappedContextProvider;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return type;
|
|
464
|
+
}
|
|
465
|
+
|
|
392
466
|
// ---------------------------------------------------------------------------
|
|
393
467
|
// Event Wrapping
|
|
394
468
|
// ---------------------------------------------------------------------------
|
|
@@ -457,7 +531,7 @@ function normalizeChildrenSingle(vnode: any): any {
|
|
|
457
531
|
}
|
|
458
532
|
|
|
459
533
|
export function createElement(type: unknown, props?: unknown, ...children: unknown[]): VNode {
|
|
460
|
-
const effectiveType =
|
|
534
|
+
const effectiveType = resolveCompatType(type);
|
|
461
535
|
|
|
462
536
|
if (typeof type === 'string' && props && typeof props === 'object') {
|
|
463
537
|
const newProps = { ...props } as Record<string, unknown>;
|
|
@@ -527,4 +601,4 @@ const ReactCompat = {
|
|
|
527
601
|
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,
|
|
528
602
|
};
|
|
529
603
|
|
|
530
|
-
export default ReactCompat;
|
|
604
|
+
export default ReactCompat;
|
|
@@ -4,6 +4,7 @@ import { reconcileChildren } from "./reconcile.js";
|
|
|
4
4
|
import { Fragment } from "./createElement.js";
|
|
5
5
|
import { Portal } from "./portal.js";
|
|
6
6
|
import { createDom, applyProps } from "./dom.js";
|
|
7
|
+
import { setContextValue } from "./features/context.js";
|
|
7
8
|
import {
|
|
8
9
|
runAfterComponentRenderHandlers,
|
|
9
10
|
runAfterCommitHandlers,
|
|
@@ -24,6 +25,11 @@ export let isRendering = false;
|
|
|
24
25
|
/** Store root fiber per container */
|
|
25
26
|
const roots = new WeakMap<Node, Fiber>();
|
|
26
27
|
let deletions: Fiber[] = [];
|
|
28
|
+
const REACT_FORWARD_REF_TYPE = Symbol.for("react.forward_ref");
|
|
29
|
+
const REACT_MEMO_TYPE = Symbol.for("react.memo");
|
|
30
|
+
const REACT_CONTEXT_TYPE = Symbol.for("react.context");
|
|
31
|
+
const REACT_FRAGMENT_TYPE = Symbol.for("react.fragment");
|
|
32
|
+
const exoticTypeCache = new WeakMap<object, unknown>();
|
|
27
33
|
|
|
28
34
|
export function pushDeletion(fiber: Fiber): void {
|
|
29
35
|
deletions.push(fiber);
|
|
@@ -61,8 +67,9 @@ export function renderFiber(vnode: VNode, container: Node): void {
|
|
|
61
67
|
/** Process a single fiber: call component, diff children.
|
|
62
68
|
* Returns true if bailed out (children should be skipped). */
|
|
63
69
|
function processWorkUnit(fiber: Fiber): boolean {
|
|
64
|
-
const
|
|
65
|
-
const
|
|
70
|
+
const resolvedType = resolveExoticFiberType(fiber.type);
|
|
71
|
+
const isComponent = typeof resolvedType === "function";
|
|
72
|
+
const isFragment = fiber.type === Fragment || fiber.type === REACT_FRAGMENT_TYPE;
|
|
66
73
|
const isPortal = fiber.type === Portal;
|
|
67
74
|
|
|
68
75
|
if (isComponent) {
|
|
@@ -74,7 +81,7 @@ function processWorkUnit(fiber: Fiber): boolean {
|
|
|
74
81
|
fiber._hookIndex = 0;
|
|
75
82
|
if (!fiber.hooks) fiber.hooks = [];
|
|
76
83
|
|
|
77
|
-
const comp =
|
|
84
|
+
const comp = resolvedType as (props: Props) => unknown;
|
|
78
85
|
runBeforeComponentRenderHandlers(fiber);
|
|
79
86
|
try {
|
|
80
87
|
const children = normalizeRenderedChildren(comp(fiber.props));
|
|
@@ -106,6 +113,46 @@ function processWorkUnit(fiber: Fiber): boolean {
|
|
|
106
113
|
return false;
|
|
107
114
|
}
|
|
108
115
|
|
|
116
|
+
function resolveExoticFiberType(type: Fiber["type"]): unknown {
|
|
117
|
+
if (!type || type === "TEXT" || typeof type !== "object") {
|
|
118
|
+
return type;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const cached = exoticTypeCache.get(type as object);
|
|
122
|
+
if (cached) return cached;
|
|
123
|
+
|
|
124
|
+
const marker = (type as { $$typeof?: symbol }).$$typeof;
|
|
125
|
+
|
|
126
|
+
if (marker === REACT_MEMO_TYPE) {
|
|
127
|
+
const inner = (type as { type?: unknown }).type;
|
|
128
|
+
const resolved = inner == null ? type : resolveExoticFiberType(inner as Fiber["type"]);
|
|
129
|
+
exoticTypeCache.set(type as object, resolved);
|
|
130
|
+
return resolved;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (marker === REACT_FORWARD_REF_TYPE && typeof (type as { render?: unknown }).render === "function") {
|
|
134
|
+
const render = (type as { render: (props: Props, ref: unknown) => unknown }).render;
|
|
135
|
+
const wrapped = (props: Props) => {
|
|
136
|
+
const { ref, ...rest } = props as Props & { ref?: unknown };
|
|
137
|
+
return render(rest, ref ?? null);
|
|
138
|
+
};
|
|
139
|
+
exoticTypeCache.set(type as object, wrapped);
|
|
140
|
+
return wrapped;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (marker === REACT_CONTEXT_TYPE) {
|
|
144
|
+
const contextObject = type as object;
|
|
145
|
+
const wrapped = (props: Props) => {
|
|
146
|
+
setContextValue(contextObject, props.value);
|
|
147
|
+
return props.children ?? null;
|
|
148
|
+
};
|
|
149
|
+
exoticTypeCache.set(type as object, wrapped);
|
|
150
|
+
return wrapped;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return type;
|
|
154
|
+
}
|
|
155
|
+
|
|
109
156
|
/** Iterative depth-first work loop (avoids stack overflow on deep trees) */
|
|
110
157
|
function performWork(rootFiber: Fiber): void {
|
|
111
158
|
let fiber: Fiber | null = rootFiber;
|
|
@@ -160,7 +207,19 @@ function flattenRenderedChildren(raw: RenderedChild[]): VNode[] {
|
|
|
160
207
|
result.push({ type: "TEXT", props: { nodeValue: String(child) }, key: null });
|
|
161
208
|
continue;
|
|
162
209
|
}
|
|
163
|
-
|
|
210
|
+
if (typeof child === "object") {
|
|
211
|
+
const record = child as Record<string, unknown>;
|
|
212
|
+
if ("type" in record && "props" in record) {
|
|
213
|
+
const rawKey = record.key;
|
|
214
|
+
result.push({
|
|
215
|
+
type: record.type as VNode["type"],
|
|
216
|
+
props: (record.props && typeof record.props === "object")
|
|
217
|
+
? record.props as Props
|
|
218
|
+
: {},
|
|
219
|
+
key: typeof rawKey === "string" || typeof rawKey === "number" ? rawKey : null,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
164
223
|
}
|
|
165
224
|
return result;
|
|
166
225
|
}
|
package/src/refract/dom.ts
CHANGED
|
@@ -91,17 +91,19 @@ function isSvgContext(fiber: Fiber): boolean {
|
|
|
91
91
|
|
|
92
92
|
/** Apply props to a DOM element, diffing against old props */
|
|
93
93
|
export function applyProps(
|
|
94
|
-
el:
|
|
94
|
+
el: Element,
|
|
95
95
|
oldProps: Record<string, unknown>,
|
|
96
96
|
newProps: Record<string, unknown>,
|
|
97
97
|
): void {
|
|
98
|
+
const isSvgElement = el.namespaceURI === SVG_NS;
|
|
99
|
+
|
|
98
100
|
for (const key of Object.keys(oldProps)) {
|
|
99
101
|
if (key === "children" || key === "key" || key === "ref") continue;
|
|
100
102
|
if (!(key in newProps)) {
|
|
101
103
|
if (key.startsWith("on")) {
|
|
102
104
|
el.removeEventListener(key.slice(2).toLowerCase(), getEventListener(oldProps[key]));
|
|
103
105
|
} else {
|
|
104
|
-
el.removeAttribute(key);
|
|
106
|
+
el.removeAttribute(normalizeAttributeName(key, isSvgElement));
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
}
|
|
@@ -134,11 +136,11 @@ export function applyProps(
|
|
|
134
136
|
const styles = newProps[key] as Record<string, unknown>;
|
|
135
137
|
for (const prop of Object.keys(prevStyles)) {
|
|
136
138
|
if (!(prop in styles)) {
|
|
137
|
-
(el
|
|
139
|
+
(el as HTMLElement).style[prop as any] = "";
|
|
138
140
|
}
|
|
139
141
|
}
|
|
140
142
|
for (const [prop, val] of Object.entries(styles)) {
|
|
141
|
-
(el
|
|
143
|
+
(el as HTMLElement).style[prop as any] = val == null ? "" : String(val);
|
|
142
144
|
}
|
|
143
145
|
} else {
|
|
144
146
|
el.removeAttribute("style");
|
|
@@ -165,19 +167,37 @@ export function applyProps(
|
|
|
165
167
|
el.addEventListener(event, getEventListener(newProps[key]));
|
|
166
168
|
} else {
|
|
167
169
|
const value = newProps[key];
|
|
170
|
+
const attrName = normalizeAttributeName(key, isSvgElement);
|
|
168
171
|
if (unsafeUrlPropChecker(key, value)) {
|
|
169
|
-
el.removeAttribute(
|
|
172
|
+
el.removeAttribute(attrName);
|
|
170
173
|
continue;
|
|
171
174
|
}
|
|
172
175
|
if (value == null || value === false) {
|
|
173
|
-
el.removeAttribute(
|
|
176
|
+
el.removeAttribute(attrName);
|
|
174
177
|
} else if (value === true) {
|
|
175
|
-
el.setAttribute(
|
|
178
|
+
el.setAttribute(attrName, "true");
|
|
176
179
|
} else {
|
|
177
|
-
el.setAttribute(
|
|
180
|
+
el.setAttribute(attrName, String(value));
|
|
178
181
|
}
|
|
179
182
|
}
|
|
180
183
|
break;
|
|
181
184
|
}
|
|
182
185
|
}
|
|
183
186
|
}
|
|
187
|
+
|
|
188
|
+
function normalizeAttributeName(key: string, isSvgElement: boolean): string {
|
|
189
|
+
if (!isSvgElement) return key;
|
|
190
|
+
if (key === "xlinkHref") return "xlink:href";
|
|
191
|
+
if (key === "xmlnsXlink") return "xmlns:xlink";
|
|
192
|
+
if (key === "xmlSpace") return "xml:space";
|
|
193
|
+
if (key === "xmlLang") return "xml:lang";
|
|
194
|
+
if (key === "xmlBase") return "xml:base";
|
|
195
|
+
if (SVG_ATTR_CASE_PRESERVED.has(key)) return key;
|
|
196
|
+
if (key.startsWith("aria-") || key.startsWith("data-")) return key;
|
|
197
|
+
return key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const SVG_ATTR_CASE_PRESERVED = new Set([
|
|
201
|
+
"viewBox",
|
|
202
|
+
"preserveAspectRatio",
|
|
203
|
+
]);
|
|
@@ -10,14 +10,27 @@ export interface Context<T> {
|
|
|
10
10
|
_defaultValue: T;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export function setContextValue(context: unknown, value: unknown): void {
|
|
14
|
+
const fiber = currentFiber!;
|
|
15
|
+
if (typeof context === "object" && context !== null) {
|
|
16
|
+
const maybeId = (context as { _id?: unknown })._id;
|
|
17
|
+
if (typeof maybeId === "number") {
|
|
18
|
+
if (!fiber._contexts) fiber._contexts = new Map();
|
|
19
|
+
fiber._contexts.set(maybeId, value);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!fiber._objectContexts) fiber._objectContexts = new Map();
|
|
24
|
+
fiber._objectContexts.set(context, value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
export function createContext<T>(defaultValue: T): Context<T> {
|
|
14
29
|
const id = contextId++;
|
|
15
30
|
|
|
16
31
|
const Provider = (props: Props) => {
|
|
17
32
|
// Store the context value on the fiber during render
|
|
18
|
-
|
|
19
|
-
if (!fiber._contexts) fiber._contexts = new Map();
|
|
20
|
-
fiber._contexts.set(id, props.value);
|
|
33
|
+
setContextValue({ _id: id }, props.value);
|
|
21
34
|
|
|
22
35
|
const children = props.children ?? [];
|
|
23
36
|
if (Array.isArray(children)) {
|
|
@@ -32,6 +45,21 @@ export function createContext<T>(defaultValue: T): Context<T> {
|
|
|
32
45
|
|
|
33
46
|
export function useContext<T>(context: Context<T>): T {
|
|
34
47
|
const fiber = currentFiber!;
|
|
48
|
+
if (typeof context === "object" && context !== null && !("_id" in context)) {
|
|
49
|
+
let f = fiber.parent;
|
|
50
|
+
while (f) {
|
|
51
|
+
if (f._objectContexts?.has(context as unknown as object)) {
|
|
52
|
+
return f._objectContexts.get(context as unknown as object) as T;
|
|
53
|
+
}
|
|
54
|
+
f = f.parent;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if ("_currentValue" in (context as unknown as Record<string, unknown>)) {
|
|
58
|
+
return (context as unknown as { _currentValue: T })._currentValue;
|
|
59
|
+
}
|
|
60
|
+
return undefined as T;
|
|
61
|
+
}
|
|
62
|
+
|
|
35
63
|
// Walk up the fiber tree to find the nearest Provider
|
|
36
64
|
let f = fiber.parent;
|
|
37
65
|
while (f) {
|
package/src/refract/reconcile.ts
CHANGED
|
@@ -20,7 +20,7 @@ function createFiber(
|
|
|
20
20
|
if (oldFiber && oldFiber.type === child.type) {
|
|
21
21
|
return {
|
|
22
22
|
type: oldFiber.type,
|
|
23
|
-
props: child.props,
|
|
23
|
+
props: child.props ?? {},
|
|
24
24
|
key: child.key,
|
|
25
25
|
dom: oldFiber.dom,
|
|
26
26
|
parentDom: parentFiber.dom ?? parentFiber.parentDom,
|
|
@@ -34,7 +34,7 @@ function createFiber(
|
|
|
34
34
|
}
|
|
35
35
|
return {
|
|
36
36
|
type: child.type,
|
|
37
|
-
props: child.props,
|
|
37
|
+
props: child.props ?? {},
|
|
38
38
|
key: child.key,
|
|
39
39
|
dom: null,
|
|
40
40
|
parentDom: parentFiber.dom ?? parentFiber.parentDom,
|
package/src/refract/types.ts
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { PieChart } from "@mui/x-charts/PieChart";
|
|
6
|
+
import { registerExternalReactModule } from "../src/refract/compat/react.js";
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const externalReact = require("react") as typeof React;
|
|
10
|
+
registerExternalReactModule(externalReact);
|
|
11
|
+
|
|
12
|
+
describe("@mui/x-charts PieChart compatibility smoke", () => {
|
|
13
|
+
it("mounts a PieChart tree with legend and surface", async () => {
|
|
14
|
+
const container = document.createElement("div");
|
|
15
|
+
document.body.appendChild(container);
|
|
16
|
+
const root = createRoot(container);
|
|
17
|
+
|
|
18
|
+
root.render(
|
|
19
|
+
React.createElement(PieChart, {
|
|
20
|
+
width: 240,
|
|
21
|
+
height: 180,
|
|
22
|
+
skipAnimation: true,
|
|
23
|
+
series: [
|
|
24
|
+
{
|
|
25
|
+
data: [
|
|
26
|
+
{ id: 0, value: 40, label: "A" },
|
|
27
|
+
{ id: 1, value: 60, label: "B" },
|
|
28
|
+
],
|
|
29
|
+
innerRadius: 40,
|
|
30
|
+
outerRadius: 70,
|
|
31
|
+
paddingAngle: 2,
|
|
32
|
+
cornerRadius: 4,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
|
39
|
+
|
|
40
|
+
const svg = container.querySelector("svg");
|
|
41
|
+
expect(svg).not.toBeNull();
|
|
42
|
+
|
|
43
|
+
const legendItems = container.querySelectorAll(".MuiChartsLegend-item");
|
|
44
|
+
expect(legendItems.length).toBe(2);
|
|
45
|
+
|
|
46
|
+
const surface = container.querySelector(".MuiChartsSurface-root");
|
|
47
|
+
expect(surface).not.toBeNull();
|
|
48
|
+
expect(container.querySelector("svg g")).not.toBeNull();
|
|
49
|
+
expect(container.querySelector("undefined")).toBeNull();
|
|
50
|
+
|
|
51
|
+
root.unmount();
|
|
52
|
+
container.remove();
|
|
53
|
+
});
|
|
54
|
+
});
|
package/tests/render.test.ts
CHANGED
|
@@ -79,4 +79,45 @@ describe("render", () => {
|
|
|
79
79
|
const link = container.querySelector("a")!;
|
|
80
80
|
expect(link.getAttribute("href")).toBe("https://example.com");
|
|
81
81
|
});
|
|
82
|
+
|
|
83
|
+
it("normalizes camelCase SVG attributes used by chart paths", () => {
|
|
84
|
+
const vnode = createElement(
|
|
85
|
+
"svg",
|
|
86
|
+
{ viewBox: "0 0 100 100" },
|
|
87
|
+
createElement("g", { clipPath: "url(#sector-mask)" },
|
|
88
|
+
createElement("path", {
|
|
89
|
+
d: "M 10 10 L 20 20",
|
|
90
|
+
strokeWidth: 6,
|
|
91
|
+
strokeLinecap: "round",
|
|
92
|
+
strokeLinejoin: "round",
|
|
93
|
+
fillOpacity: 0.4,
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
render(vnode, container);
|
|
98
|
+
|
|
99
|
+
const svg = container.querySelector("svg")!;
|
|
100
|
+
const group = container.querySelector("g")!;
|
|
101
|
+
const path = container.querySelector("path")!;
|
|
102
|
+
|
|
103
|
+
expect(svg.getAttribute("viewBox")).toBe("0 0 100 100");
|
|
104
|
+
expect(group.getAttribute("clip-path")).toBe("url(#sector-mask)");
|
|
105
|
+
expect(path.getAttribute("stroke-width")).toBe("6");
|
|
106
|
+
expect(path.getAttribute("stroke-linecap")).toBe("round");
|
|
107
|
+
expect(path.getAttribute("stroke-linejoin")).toBe("round");
|
|
108
|
+
expect(path.getAttribute("fill-opacity")).toBe("0.4");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("maps xlinkHref on SVG use elements", () => {
|
|
112
|
+
const vnode = createElement(
|
|
113
|
+
"svg",
|
|
114
|
+
null,
|
|
115
|
+
createElement("defs", null, createElement("path", { id: "slice", d: "M 0 0 L 5 5" })),
|
|
116
|
+
createElement("use", { xlinkHref: "#slice" }),
|
|
117
|
+
);
|
|
118
|
+
render(vnode, container);
|
|
119
|
+
|
|
120
|
+
const use = container.querySelector("use")!;
|
|
121
|
+
expect(use.getAttribute("xlink:href")).toBe("#slice");
|
|
122
|
+
});
|
|
82
123
|
});
|