@ipxjs/refract 0.5.0 → 0.6.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 +40 -27
- package/package.json +1 -1
- package/src/refract/compat/react-jsx-runtime.ts +16 -4
- package/src/refract/compat/react.ts +292 -7
- package/src/refract/dom.ts +13 -1
- package/src/refract/features/hooks.ts +7 -2
- package/tests/createElement.test.ts +6 -6
- package/tests/poc-compat.test.ts +69 -0
- package/tests/react-router-smoke.test.ts +1 -1
package/README.md
CHANGED
|
@@ -121,6 +121,11 @@ export default defineConfig({
|
|
|
121
121
|
The compat layer is intentionally separate from core so users who do not need
|
|
122
122
|
React ecosystem compatibility keep the smallest and fastest Refract bundles.
|
|
123
123
|
|
|
124
|
+
Compatibility status (last verified February 17, 2026):
|
|
125
|
+
- `yarn test`: 14 files passed, 85 tests passed
|
|
126
|
+
- Compat-focused suites passed: `tests/compat.test.ts` (10), `tests/poc-compat.test.ts` (2), `tests/react-router-smoke.test.ts` (3)
|
|
127
|
+
- Verified behaviors include `forwardRef`, portals, `createRoot`, JSX runtimes, `useSyncExternalStore`, `flushSync`, and react-router tree construction/dispatcher bridging
|
|
128
|
+
|
|
124
129
|
## API
|
|
125
130
|
|
|
126
131
|
### createElement(type, props, ...children)
|
|
@@ -215,24 +220,26 @@ image requests blocked.
|
|
|
215
220
|
|
|
216
221
|
### Bundle Size Snapshot
|
|
217
222
|
|
|
218
|
-
The values below are from a local run on February
|
|
223
|
+
The values below are from a local run on February 17, 2026.
|
|
219
224
|
|
|
220
225
|
| Framework | JS bundle (raw) | JS bundle (gzip) |
|
|
221
226
|
|---------------------------|----------------:|-----------------:|
|
|
222
|
-
| Refract (`core`) | 8.
|
|
223
|
-
| Refract (`core+hooks`) |
|
|
224
|
-
| Refract (`core+context`) |
|
|
225
|
-
| Refract (`core+memo`) | 9.
|
|
226
|
-
| Refract (`core+security`) | 9.
|
|
227
|
-
| Refract (`refract`) |
|
|
228
|
-
| React | 189.74 kB | 59.
|
|
229
|
-
| Preact | 14.46 kB | 5.
|
|
227
|
+
| Refract (`core`) | 8.62 kB | 3.24 kB |
|
|
228
|
+
| Refract (`core+hooks`) | 10.27 kB | 3.84 kB |
|
|
229
|
+
| Refract (`core+context`) | 9.12 kB | 3.47 kB |
|
|
230
|
+
| Refract (`core+memo`) | 9.29 kB | 3.47 kB |
|
|
231
|
+
| Refract (`core+security`) | 9.53 kB | 3.54 kB |
|
|
232
|
+
| Refract (`refract`) | 15.20 kB | 5.55 kB |
|
|
233
|
+
| React | 189.74 kB | 59.58 kB |
|
|
234
|
+
| Preact | 14.46 kB | 5.96 kB |
|
|
230
235
|
|
|
231
236
|
Load-time metrics are machine-dependent, so the benchmark script prints a fresh
|
|
232
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) also passed on
|
|
239
|
+
February 17, 2026 with default guardrails (`DCL p95 <= 16ms`, `DCL sd <= 2ms`).
|
|
233
240
|
|
|
234
|
-
From this snapshot, Refract `core` gzip JS is about 18.
|
|
235
|
-
and the full `refract` entrypoint is about
|
|
241
|
+
From this snapshot, Refract `core` gzip JS is about 18.4x smaller than React,
|
|
242
|
+
and the full `refract` entrypoint is about 10.7x smaller.
|
|
236
243
|
|
|
237
244
|
### Component Combination Benchmarks (Vitest)
|
|
238
245
|
|
|
@@ -242,15 +249,15 @@ Higher `hz` is better.
|
|
|
242
249
|
|
|
243
250
|
| Component usage profile | Mount (hz) | Mount vs base | Reconcile (hz) | Reconcile vs base |
|
|
244
251
|
|-------------------------|------------|---------------|----------------|-------------------|
|
|
245
|
-
| `base` |
|
|
246
|
-
| `memo` |
|
|
247
|
-
| `context` |
|
|
248
|
-
| `fragment` |
|
|
249
|
-
| `keyed` |
|
|
250
|
-
| `memo+context` |
|
|
251
|
-
| `memo+context+keyed` |
|
|
252
|
-
|
|
253
|
-
In this run, `
|
|
252
|
+
| `base` | 6570.25 | baseline | 3413.29 | baseline |
|
|
253
|
+
| `memo` | 7257.38 | +10.5% | 7541.91 | +120.9% |
|
|
254
|
+
| `context` | 6427.45 | -2.2% | 7119.35 | +108.6% |
|
|
255
|
+
| `fragment` | 5935.06 | -9.7% | 3437.54 | +0.7% |
|
|
256
|
+
| `keyed` | 9022.55 | +37.3% | 6535.55 | +91.5% |
|
|
257
|
+
| `memo+context` | 7015.37 | +6.8% | 6782.87 | +98.7% |
|
|
258
|
+
| `memo+context+keyed` | 7992.82 | +21.7% | 6803.09 | +99.3% |
|
|
259
|
+
|
|
260
|
+
In this run, `keyed` was the fastest mount profile, while
|
|
254
261
|
`memo` was the fastest reconcile profile.
|
|
255
262
|
|
|
256
263
|
### Running the Benchmark
|
|
@@ -301,24 +308,27 @@ How Refract compares to React and Preact:
|
|
|
301
308
|
| **Hooks** | | | |
|
|
302
309
|
| useState | Yes | Yes | Yes |
|
|
303
310
|
| useEffect | Yes | Yes | Yes |
|
|
304
|
-
| useLayoutEffect |
|
|
311
|
+
| useLayoutEffect | Yes | Yes | Yes |
|
|
312
|
+
| useInsertionEffect | Yes | Yes | Yes |
|
|
305
313
|
| useRef | Yes | Yes | Yes |
|
|
306
314
|
| useMemo / useCallback | Yes | Yes | Yes |
|
|
307
315
|
| useReducer | Yes | Yes | Yes |
|
|
308
316
|
| useContext | Yes | Yes | Yes |
|
|
309
|
-
| useId |
|
|
310
|
-
|
|
|
317
|
+
| useId | Yes | Yes | Yes |
|
|
318
|
+
| useSyncExternalStore | Yes | Yes | Yes |
|
|
319
|
+
| useImperativeHandle | Yes | Yes | Yes |
|
|
320
|
+
| useTransition / useDeferredValue | Partial⁵ | Yes | Partial⁷ |
|
|
311
321
|
| **State & Data Flow** | | | |
|
|
312
322
|
| Built-in state management | Yes | Yes | Yes |
|
|
313
323
|
| Context API | Yes | Yes | Yes |
|
|
314
324
|
| Refs (createRef / ref prop) | Yes | Yes | Yes |
|
|
315
|
-
| forwardRef |
|
|
325
|
+
| forwardRef | Yes⁶ | Yes | Yes |
|
|
316
326
|
| **Rendering** | | | |
|
|
317
327
|
| Event handling | Yes | Yes | Yes |
|
|
318
328
|
| Style objects | Yes | Yes | Yes |
|
|
319
329
|
| className prop | Yes | Yes | Yes¹ |
|
|
320
330
|
| dangerouslySetInnerHTML | Yes | Yes | Yes |
|
|
321
|
-
| Portals |
|
|
331
|
+
| Portals | Yes | Yes | Yes |
|
|
322
332
|
| Suspense / lazy | No | Yes | Yes² |
|
|
323
333
|
| Error boundaries | Yes³ | Yes | Yes |
|
|
324
334
|
| Server-side rendering | No | Yes | Yes |
|
|
@@ -333,13 +343,16 @@ How Refract compares to React and Preact:
|
|
|
333
343
|
| memo / PureComponent | Yes | Yes | Yes |
|
|
334
344
|
| **Ecosystem** | | | |
|
|
335
345
|
| DevTools | Basic (hook API) | Yes | Yes |
|
|
336
|
-
| React compatibility layer |
|
|
337
|
-
| **Bundle Size (gzip, JS)** | ~2
|
|
346
|
+
| React compatibility layer | Yes⁶ | N/A | Yes⁷ |
|
|
347
|
+
| **Bundle Size (gzip, JS)** | ~3.2-5.6 kB⁴ | ~59.5 kB | ~6.0 kB |
|
|
338
348
|
|
|
339
349
|
¹ Preact supports both `class` and `className`.
|
|
340
350
|
² Preact has partial Suspense support via `preact/compat`.
|
|
341
351
|
³ Refract uses the `useErrorBoundary` hook rather than class-based error boundaries.
|
|
342
352
|
⁴ Refract size depends on entrypoint (`refract/core` vs `refract` full).
|
|
353
|
+
⁵ Refract exposes `useTransition` / `useDeferredValue` but currently runs both synchronously (no concurrent scheduling).
|
|
354
|
+
⁶ Available via opt-in compat entrypoints (`refract/compat/react*`).
|
|
355
|
+
⁷ Preact compatibility is provided through `preact/compat`.
|
|
343
356
|
|
|
344
357
|
## License
|
|
345
358
|
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createElement, Fragment } from "../createElement.js";
|
|
2
2
|
import type { VNode, VNodeType } from "../types.js";
|
|
3
|
+
import { isClassComponent, wrapClassComponent, getWrappedHandler } from "./react.js";
|
|
3
4
|
|
|
4
5
|
type JsxProps = Record<string, unknown> | null | undefined;
|
|
5
6
|
type JsxChild = VNode | string | number | boolean | null | undefined | JsxChild[];
|
|
@@ -18,6 +19,8 @@ function normalizeVNodeChildren(vnode: VNode): VNode {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
function createJsxElement(type: VNodeType, rawProps: JsxProps, key?: string): ReturnType<typeof createElement> {
|
|
22
|
+
const effectiveType = isClassComponent(type) ? wrapClassComponent(type as Function) : type;
|
|
23
|
+
|
|
21
24
|
const props = { ...(rawProps ?? {}) };
|
|
22
25
|
if (key !== undefined) {
|
|
23
26
|
props.key = key;
|
|
@@ -25,13 +28,22 @@ function createJsxElement(type: VNodeType, rawProps: JsxProps, key?: string): Re
|
|
|
25
28
|
const children = props.children as JsxChild | JsxChild[] | undefined;
|
|
26
29
|
delete props.children;
|
|
27
30
|
|
|
31
|
+
// Wrap event handlers
|
|
32
|
+
if (typeof type === 'string') {
|
|
33
|
+
for (const key in props) {
|
|
34
|
+
if (key.startsWith('on') && typeof props[key] === 'function') {
|
|
35
|
+
props[key] = getWrappedHandler(props[key] as Function);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
let vnode: VNode;
|
|
29
41
|
if (children === undefined) {
|
|
30
|
-
vnode = createElement(
|
|
42
|
+
vnode = createElement(effectiveType as VNodeType, props);
|
|
31
43
|
} else if (Array.isArray(children)) {
|
|
32
|
-
vnode = createElement(
|
|
44
|
+
vnode = createElement(effectiveType as VNodeType, props, ...(children as JsxChild[]));
|
|
33
45
|
} else {
|
|
34
|
-
vnode = createElement(
|
|
46
|
+
vnode = createElement(effectiveType as VNodeType, props, children as JsxChild);
|
|
35
47
|
}
|
|
36
48
|
return normalizeVNodeChildren(vnode);
|
|
37
49
|
}
|
|
@@ -44,4 +56,4 @@ export function jsxs(type: VNodeType, props: JsxProps, key?: string): ReturnType
|
|
|
44
56
|
return createJsxElement(type, props, key);
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
export { Fragment };
|
|
59
|
+
export { Fragment };
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { createElement, Fragment } from "../createElement.js";
|
|
1
|
+
import { createElement as createElementImpl, Fragment } from "../createElement.js";
|
|
2
2
|
import { memo } from "../memo.js";
|
|
3
|
-
import { createContext } from "../features/context.js";
|
|
3
|
+
import { createContext as createContextImpl } from "../features/context.js";
|
|
4
4
|
import {
|
|
5
5
|
createRef,
|
|
6
|
+
useErrorBoundary,
|
|
6
7
|
} from "../features/hooks.js";
|
|
7
|
-
import type { Component, Props, VNode } from "../types.js";
|
|
8
|
+
import type { Component as RefractComponent, Props, VNode } from "../types.js";
|
|
8
9
|
import {
|
|
9
10
|
__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,
|
|
10
11
|
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,
|
|
@@ -31,8 +32,8 @@ function parseChildrenArgs(children: unknown[]): unknown {
|
|
|
31
32
|
|
|
32
33
|
export function forwardRef<T, P extends Record<string, unknown> = Record<string, unknown>>(
|
|
33
34
|
render: (props: P, ref: { current: T | null } | ((value: T | null) => void) | null) => VNode,
|
|
34
|
-
):
|
|
35
|
-
const ForwardRefComponent:
|
|
35
|
+
): RefractComponent {
|
|
36
|
+
const ForwardRefComponent: RefractComponent = (props: Props) => {
|
|
36
37
|
const { ref, ...rest } = props as Props & { ref?: { current: T | null } | ((value: T | null) => void) | null };
|
|
37
38
|
return render(rest as unknown as P, ref ?? null);
|
|
38
39
|
};
|
|
@@ -199,7 +200,287 @@ export function useDeferredValue<T>(value: T, initialValue?: T): T {
|
|
|
199
200
|
return resolveDispatcher().useDeferredValue(value, initialValue);
|
|
200
201
|
}
|
|
201
202
|
|
|
202
|
-
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Context Wrapper
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
export function createContext<T>(defaultValue: T): any {
|
|
208
|
+
const ctx = createContextImpl(defaultValue) as any;
|
|
209
|
+
ctx._currentValue = defaultValue;
|
|
210
|
+
ctx._currentValue2 = defaultValue;
|
|
211
|
+
ctx.displayName = undefined;
|
|
212
|
+
ctx.Consumer = ctx;
|
|
213
|
+
if (ctx.Provider) {
|
|
214
|
+
ctx.Provider._context = ctx;
|
|
215
|
+
}
|
|
216
|
+
return ctx;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Class Component Support
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
export function Component(this: any, props: any, context?: any) {
|
|
224
|
+
this.props = props;
|
|
225
|
+
this.state = {};
|
|
226
|
+
this.context = context ?? undefined;
|
|
227
|
+
this._forceUpdate = null;
|
|
228
|
+
}
|
|
229
|
+
Component.prototype.setState = function (
|
|
230
|
+
this: any,
|
|
231
|
+
partial: any,
|
|
232
|
+
callback?: () => void,
|
|
233
|
+
): void {
|
|
234
|
+
if (typeof partial === 'function') {
|
|
235
|
+
const result = partial(this.state, this.props);
|
|
236
|
+
if (result != null) {
|
|
237
|
+
this.state = { ...this.state, ...result };
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
this.state = { ...this.state, ...partial };
|
|
241
|
+
}
|
|
242
|
+
if (this._forceUpdate) {
|
|
243
|
+
this._forceUpdate();
|
|
244
|
+
}
|
|
245
|
+
if (callback) queueMicrotask(callback);
|
|
246
|
+
};
|
|
247
|
+
Component.prototype.forceUpdate = function (this: any, callback?: () => void): void {
|
|
248
|
+
if (this._forceUpdate) {
|
|
249
|
+
this._forceUpdate();
|
|
250
|
+
}
|
|
251
|
+
if (callback) queueMicrotask(callback);
|
|
252
|
+
};
|
|
253
|
+
Component.prototype.render = function (): unknown {
|
|
254
|
+
return null;
|
|
255
|
+
};
|
|
256
|
+
Component.prototype.isReactComponent = {};
|
|
257
|
+
|
|
258
|
+
export function PureComponent(this: any, props: any) {
|
|
259
|
+
Component.call(this, props);
|
|
260
|
+
}
|
|
261
|
+
PureComponent.prototype = Object.create(Component.prototype);
|
|
262
|
+
PureComponent.prototype.constructor = PureComponent;
|
|
263
|
+
PureComponent.prototype.isPureReactComponent = true;
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Suspense / Lazy
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
export function Suspense(props: { children?: unknown; fallback?: unknown }): unknown {
|
|
270
|
+
return props.children ?? null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function lazy<T extends { default: (...args: any[]) => any }>(
|
|
274
|
+
factory: () => Promise<T>,
|
|
275
|
+
): (props: any) => unknown {
|
|
276
|
+
let resolved: ((...args: any[]) => any) | null = null;
|
|
277
|
+
let promise: Promise<void> | null = null;
|
|
278
|
+
const LazyComponent = (props: any) => {
|
|
279
|
+
if (resolved) return createElementImpl(resolved as any, props);
|
|
280
|
+
if (!promise) {
|
|
281
|
+
promise = factory().then((mod) => {
|
|
282
|
+
resolved = mod.default;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
throw promise;
|
|
286
|
+
};
|
|
287
|
+
return LazyComponent;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Class Wrapper Logic
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
const classWrapperCache = new WeakMap<Function, Function>();
|
|
295
|
+
|
|
296
|
+
export function isClassComponent(type: unknown): boolean {
|
|
297
|
+
return (
|
|
298
|
+
typeof type === 'function' &&
|
|
299
|
+
(type as any).prototype != null &&
|
|
300
|
+
typeof (type as any).prototype.render === 'function'
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function wrapClassComponent(ClassComp: Function): (props: any) => unknown {
|
|
305
|
+
const cached = classWrapperCache.get(ClassComp);
|
|
306
|
+
if (cached) return cached as (props: any) => unknown;
|
|
307
|
+
|
|
308
|
+
const Ctor = ClassComp as any;
|
|
309
|
+
const hasErrorBoundary = typeof Ctor.getDerivedStateFromError === 'function';
|
|
310
|
+
const hasLifecycles =
|
|
311
|
+
typeof Ctor.prototype.componentDidMount === 'function' ||
|
|
312
|
+
typeof Ctor.prototype.componentDidUpdate === 'function';
|
|
313
|
+
const hasWillUnmount = typeof Ctor.prototype.componentWillUnmount === 'function';
|
|
314
|
+
const contextType = Ctor.contextType ?? null;
|
|
315
|
+
|
|
316
|
+
const Wrapper = (rawProps: any): unknown => {
|
|
317
|
+
const props = Ctor.defaultProps ? { ...Ctor.defaultProps, ...rawProps } : rawProps;
|
|
318
|
+
const [, setTick] = useState(0);
|
|
319
|
+
const forceUpdate = useCallback(() => setTick((t: number) => t + 1), []);
|
|
320
|
+
const instanceRef = useRef<any>(null);
|
|
321
|
+
|
|
322
|
+
if (!instanceRef.current) {
|
|
323
|
+
instanceRef.current = new (ClassComp as any)(props);
|
|
324
|
+
}
|
|
325
|
+
const instance = instanceRef.current;
|
|
326
|
+
instance.props = props;
|
|
327
|
+
instance._forceUpdate = forceUpdate;
|
|
328
|
+
|
|
329
|
+
if (contextType) {
|
|
330
|
+
instance.context = useContext(contextType);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (typeof Ctor.getDerivedStateFromProps === 'function') {
|
|
334
|
+
const derived = Ctor.getDerivedStateFromProps(props, instance.state);
|
|
335
|
+
if (derived != null) {
|
|
336
|
+
instance.state = { ...instance.state, ...derived };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (hasErrorBoundary) {
|
|
341
|
+
const [error, resetError] = useErrorBoundary();
|
|
342
|
+
if (error) {
|
|
343
|
+
const newState = Ctor.getDerivedStateFromError(error);
|
|
344
|
+
instance.state = { ...instance.state, ...newState };
|
|
345
|
+
if (typeof (instance as any).componentDidCatch === 'function') {
|
|
346
|
+
(instance as any).componentDidCatch(error, { componentStack: '' });
|
|
347
|
+
}
|
|
348
|
+
resetError();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (hasLifecycles) {
|
|
353
|
+
const mountedRef = useRef(false);
|
|
354
|
+
const prevPropsRef = useRef<any>(null);
|
|
355
|
+
const prevStateRef = useRef<any>(null);
|
|
356
|
+
|
|
357
|
+
useLayoutEffect(() => {
|
|
358
|
+
if (!mountedRef.current) {
|
|
359
|
+
mountedRef.current = true;
|
|
360
|
+
if (typeof instance.componentDidMount === 'function') {
|
|
361
|
+
instance.componentDidMount();
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
if (typeof instance.componentDidUpdate === 'function') {
|
|
365
|
+
instance.componentDidUpdate(prevPropsRef.current, prevStateRef.current);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
prevPropsRef.current = instance.props;
|
|
369
|
+
prevStateRef.current = { ...instance.state };
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (hasWillUnmount) {
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
return () => {
|
|
376
|
+
instance.componentWillUnmount();
|
|
377
|
+
};
|
|
378
|
+
}, []);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return instance.render();
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
Object.defineProperty(Wrapper, 'name', {
|
|
385
|
+
value: (ClassComp as any).displayName || ClassComp.name || 'ClassWrapper',
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
classWrapperCache.set(ClassComp, Wrapper);
|
|
389
|
+
return Wrapper;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Event Wrapping
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
export function createSyntheticEvent(nativeEvent: Event) {
|
|
397
|
+
let isPropagationStopped = false;
|
|
398
|
+
let isDefaultPrevented = false;
|
|
399
|
+
|
|
400
|
+
const syntheticEvent = {
|
|
401
|
+
nativeEvent,
|
|
402
|
+
currentTarget: nativeEvent.currentTarget,
|
|
403
|
+
target: nativeEvent.target,
|
|
404
|
+
bubbles: nativeEvent.bubbles,
|
|
405
|
+
cancelable: nativeEvent.cancelable,
|
|
406
|
+
defaultPrevented: nativeEvent.defaultPrevented,
|
|
407
|
+
eventPhase: nativeEvent.eventPhase,
|
|
408
|
+
isTrusted: nativeEvent.isTrusted,
|
|
409
|
+
preventDefault: () => {
|
|
410
|
+
isDefaultPrevented = true;
|
|
411
|
+
nativeEvent.preventDefault();
|
|
412
|
+
},
|
|
413
|
+
isDefaultPrevented: () => isDefaultPrevented,
|
|
414
|
+
stopPropagation: () => {
|
|
415
|
+
isPropagationStopped = true;
|
|
416
|
+
nativeEvent.stopPropagation();
|
|
417
|
+
},
|
|
418
|
+
isPropagationStopped: () => isPropagationStopped,
|
|
419
|
+
persist: () => {},
|
|
420
|
+
timeStamp: nativeEvent.timeStamp,
|
|
421
|
+
type: nativeEvent.type,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
return new Proxy(syntheticEvent, {
|
|
425
|
+
get: (target, prop) => {
|
|
426
|
+
if (prop in target) return (target as any)[prop];
|
|
427
|
+
const val = (nativeEvent as any)[prop];
|
|
428
|
+
if (typeof val === 'function') return val.bind(nativeEvent);
|
|
429
|
+
return val;
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const wrappedHandlerCache = new WeakMap<Function, Function>();
|
|
435
|
+
|
|
436
|
+
export function getWrappedHandler(handler: Function) {
|
|
437
|
+
if (wrappedHandlerCache.has(handler)) return wrappedHandlerCache.get(handler);
|
|
438
|
+
|
|
439
|
+
const wrapped = (nativeEvent: Event) => {
|
|
440
|
+
if ((nativeEvent as any).nativeEvent && typeof (nativeEvent as any).isPropagationStopped === 'function') {
|
|
441
|
+
return handler(nativeEvent);
|
|
442
|
+
}
|
|
443
|
+
const synthetic = createSyntheticEvent(nativeEvent);
|
|
444
|
+
return handler(synthetic);
|
|
445
|
+
};
|
|
446
|
+
wrappedHandlerCache.set(handler, wrapped);
|
|
447
|
+
return wrapped;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function normalizeChildrenSingle(vnode: any): any {
|
|
451
|
+
if (!vnode || !vnode.props) return vnode;
|
|
452
|
+
const c = vnode.props.children;
|
|
453
|
+
if (Array.isArray(c) && c.length === 1) {
|
|
454
|
+
vnode.props.children = c[0];
|
|
455
|
+
}
|
|
456
|
+
return vnode;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function createElement(type: unknown, props?: unknown, ...children: unknown[]): VNode {
|
|
460
|
+
const effectiveType = isClassComponent(type) ? wrapClassComponent(type as Function) : type;
|
|
461
|
+
|
|
462
|
+
if (typeof type === 'string' && props && typeof props === 'object') {
|
|
463
|
+
const newProps = { ...props } as Record<string, unknown>;
|
|
464
|
+
let hasChanges = false;
|
|
465
|
+
for (const key in newProps) {
|
|
466
|
+
if (key.startsWith('on') && typeof newProps[key] === 'function') {
|
|
467
|
+
newProps[key] = getWrappedHandler(newProps[key] as Function);
|
|
468
|
+
hasChanges = true;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (hasChanges) {
|
|
472
|
+
return normalizeChildrenSingle(createElementImpl(effectiveType as any, newProps, ...(children as any[])));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return normalizeChildrenSingle(createElementImpl(effectiveType as any, props as any, ...(children as any[])));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
// Export
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
export { Fragment, memo };
|
|
203
484
|
export {
|
|
204
485
|
createRef,
|
|
205
486
|
registerExternalReactModule,
|
|
@@ -212,6 +493,10 @@ export const version = "19.0.0-refract-compat";
|
|
|
212
493
|
const ReactCompat = {
|
|
213
494
|
Children,
|
|
214
495
|
Fragment,
|
|
496
|
+
Component,
|
|
497
|
+
PureComponent,
|
|
498
|
+
Suspense,
|
|
499
|
+
lazy,
|
|
215
500
|
createContext,
|
|
216
501
|
createElement,
|
|
217
502
|
cloneElement,
|
|
@@ -242,4 +527,4 @@ const ReactCompat = {
|
|
|
242
527
|
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,
|
|
243
528
|
};
|
|
244
529
|
|
|
245
|
-
export default ReactCompat;
|
|
530
|
+
export default ReactCompat;
|
package/src/refract/dom.ts
CHANGED
|
@@ -146,7 +146,19 @@ export function applyProps(
|
|
|
146
146
|
break;
|
|
147
147
|
default:
|
|
148
148
|
if (key.startsWith("on")) {
|
|
149
|
-
|
|
149
|
+
let event = key.slice(2).toLowerCase();
|
|
150
|
+
if (reactCompatEventMode && event === "change") {
|
|
151
|
+
const tagName = el.tagName;
|
|
152
|
+
if (tagName === "TEXTAREA") {
|
|
153
|
+
event = "input";
|
|
154
|
+
} else if (tagName === "INPUT") {
|
|
155
|
+
const type = (el as HTMLInputElement).type;
|
|
156
|
+
if (type !== "checkbox" && type !== "radio" && type !== "file") {
|
|
157
|
+
event = "input";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
150
162
|
if (oldProps[key]) {
|
|
151
163
|
el.removeEventListener(event, getEventListener(oldProps[key]));
|
|
152
164
|
}
|
|
@@ -257,12 +257,17 @@ export function useSyncExternalStore<T>(
|
|
|
257
257
|
// snapshots (e.g. zustand selectors returning store actions) as updaters.
|
|
258
258
|
const [snapshot, setSnapshot] = useState<T>(() => getSnapshot());
|
|
259
259
|
|
|
260
|
-
|
|
260
|
+
useLayoutEffect(() => {
|
|
261
261
|
const handleStoreChange = () => {
|
|
262
262
|
setSnapshot(() => getSnapshot());
|
|
263
263
|
};
|
|
264
264
|
const unsubscribe = subscribe(handleStoreChange);
|
|
265
|
-
|
|
265
|
+
|
|
266
|
+
// Check if snapshot changed between render and effect
|
|
267
|
+
if (getSnapshot() !== snapshot) {
|
|
268
|
+
handleStoreChange();
|
|
269
|
+
}
|
|
270
|
+
|
|
266
271
|
return () => {
|
|
267
272
|
unsubscribe();
|
|
268
273
|
};
|
|
@@ -14,8 +14,8 @@ describe("createElement", () => {
|
|
|
14
14
|
it("creates an element with string children as text nodes", () => {
|
|
15
15
|
const vnode = createElement("span", null, "hello");
|
|
16
16
|
expect(vnode.props.children).toHaveLength(1);
|
|
17
|
-
expect(vnode.props.children
|
|
18
|
-
expect(vnode.props.children
|
|
17
|
+
expect((vnode.props.children as unknown as any[])[0].type).toBe("TEXT");
|
|
18
|
+
expect((vnode.props.children as unknown as any[])[0].props.nodeValue).toBe("hello");
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
it("creates nested elements", () => {
|
|
@@ -27,8 +27,8 @@ describe("createElement", () => {
|
|
|
27
27
|
);
|
|
28
28
|
expect(vnode.type).toBe("div");
|
|
29
29
|
expect(vnode.props.children).toHaveLength(2);
|
|
30
|
-
expect(vnode.props.children
|
|
31
|
-
expect(vnode.props.children
|
|
30
|
+
expect((vnode.props.children as unknown as any[])[0].type).toBe("img");
|
|
31
|
+
expect((vnode.props.children as unknown as any[])[1].type).toBe("span");
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
it("flattens array children", () => {
|
|
@@ -44,7 +44,7 @@ describe("createElement", () => {
|
|
|
44
44
|
it("filters out null, undefined, and boolean children", () => {
|
|
45
45
|
const vnode = createElement("div", null, null, undefined, false, true, "text");
|
|
46
46
|
expect(vnode.props.children).toHaveLength(1);
|
|
47
|
-
expect(vnode.props.children
|
|
47
|
+
expect((vnode.props.children as unknown as any[])[0].type).toBe("TEXT");
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
it("retains function type for components (deferred)", () => {
|
|
@@ -60,7 +60,7 @@ describe("createElement", () => {
|
|
|
60
60
|
it("converts numbers to text nodes", () => {
|
|
61
61
|
const vnode = createElement("span", null, 42);
|
|
62
62
|
expect(vnode.props.children).toHaveLength(1);
|
|
63
|
-
expect(vnode.props.children
|
|
63
|
+
expect((vnode.props.children as unknown as any[])[0].props.nodeValue).toBe("42");
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
it("extracts key from props", () => {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
|
|
2
|
+
import { describe, test, expect, vi } from "vitest";
|
|
3
|
+
import { render } from "../src/refract/render.js";
|
|
4
|
+
import { createElement } from "../src/refract/createElement.js";
|
|
5
|
+
import { useSyncExternalStore, useState } from "../src/refract/compat/react.js";
|
|
6
|
+
import { setReactCompatEventMode } from "../src/refract/dom.js";
|
|
7
|
+
|
|
8
|
+
// Enable compat mode
|
|
9
|
+
setReactCompatEventMode(true);
|
|
10
|
+
|
|
11
|
+
describe("POC Compatibility", () => {
|
|
12
|
+
test("useSyncExternalStore basic subscription", async () => {
|
|
13
|
+
let listeners: (() => void)[] = [];
|
|
14
|
+
const store = {
|
|
15
|
+
value: 0,
|
|
16
|
+
subscribe(l: () => void) {
|
|
17
|
+
listeners.push(l);
|
|
18
|
+
return () => {
|
|
19
|
+
listeners = listeners.filter(x => x !== l);
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
getSnapshot() {
|
|
23
|
+
return store.value;
|
|
24
|
+
},
|
|
25
|
+
increment() {
|
|
26
|
+
store.value++;
|
|
27
|
+
listeners.forEach(l => l());
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function StoreComponent() {
|
|
32
|
+
const val = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
33
|
+
return createElement("div", {}, `Value: ${val}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const container = document.createElement("div");
|
|
37
|
+
render(createElement(StoreComponent, null), container);
|
|
38
|
+
|
|
39
|
+
expect(container.textContent).toBe("Value: 0");
|
|
40
|
+
|
|
41
|
+
store.increment();
|
|
42
|
+
// Wait for microtasks/updates
|
|
43
|
+
await new Promise(r => setTimeout(r, 0));
|
|
44
|
+
|
|
45
|
+
expect(container.textContent).toBe("Value: 1");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("onChange maps to input for text inputs", async () => {
|
|
49
|
+
const handleChange = vi.fn();
|
|
50
|
+
|
|
51
|
+
function InputComponent() {
|
|
52
|
+
return createElement("input", { type: "text", onChange: handleChange });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const container = document.createElement("div");
|
|
56
|
+
render(createElement(InputComponent, null), container);
|
|
57
|
+
|
|
58
|
+
const input = container.querySelector("input");
|
|
59
|
+
expect(input).not.toBeNull();
|
|
60
|
+
|
|
61
|
+
// Simulate input event (which React treats as change)
|
|
62
|
+
const event = new Event("input", { bubbles: true });
|
|
63
|
+
input?.dispatchEvent(event);
|
|
64
|
+
|
|
65
|
+
// In standard DOM, onChange doesn't fire on input, but React compat should handle this?
|
|
66
|
+
// If not implemented, this will fail, confirming the need for the fix.
|
|
67
|
+
expect(handleChange).toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -38,7 +38,7 @@ describe("react-router-dom compatibility smoke", () => {
|
|
|
38
38
|
),
|
|
39
39
|
);
|
|
40
40
|
expect(tree.type).toBe(MemoryRouter);
|
|
41
|
-
expect((tree.props.children
|
|
41
|
+
expect(React.Children.count(tree.props.children)).toBe(1);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
it("supports hook dispatcher bridging for externally-resolved React", async () => {
|