@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 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 15, 2026.
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.36 kB | 3.16 kB |
223
- | Refract (`core+hooks`) | 9.76 kB | 3.65 kB |
224
- | Refract (`core+context`) | 8.85 kB | 3.38 kB |
225
- | Refract (`core+memo`) | 9.03 kB | 3.39 kB |
226
- | Refract (`core+security`) | 9.27 kB | 3.46 kB |
227
- | Refract (`refract`) | 14.64 kB | 5.34 kB |
228
- | React | 189.74 kB | 59.52 kB |
229
- | Preact | 14.46 kB | 5.95 kB |
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.8x smaller than React,
235
- and the full `refract` entrypoint is about 11.1x smaller.
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` | 5068.40 | baseline | 4144.37 | baseline |
246
- | `memo` | 5883.23 | +16.1% | 5154.56 | +24.4% |
247
- | `context` | 3521.54 | -30.5% | 5063.92 | +22.2% |
248
- | `fragment` | 4880.23 | -3.7% | 4079.08 | -1.6% |
249
- | `keyed` | 5763.70 | +13.7% | 4844.23 | +16.9% |
250
- | `memo+context` | 6173.01 | +21.8% | 5144.98 | +24.1% |
251
- | `memo+context+keyed` | 5606.73 | +10.6% | 4732.23 | +14.2% |
252
-
253
- In this run, `memo+context` was the fastest mount profile, while
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 | No | Yes | Yes |
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 | No | Yes | Yes |
310
- | useTransition / useDeferredValue | No | Yes | No |
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 | No | Yes | Yes |
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 | No | Yes | Yes |
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 | N/A | N/A | Yes |
337
- | **Bundle Size (gzip, JS)** | ~2.9-5.0 kB⁴ | ~59.5 kB | ~6.0 kB |
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@ipxjs/refract",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -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(type, props);
42
+ vnode = createElement(effectiveType as VNodeType, props);
31
43
  } else if (Array.isArray(children)) {
32
- vnode = createElement(type, props, ...(children as JsxChild[]));
44
+ vnode = createElement(effectiveType as VNodeType, props, ...(children as JsxChild[]));
33
45
  } else {
34
- vnode = createElement(type, props, children as JsxChild);
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
- ): Component {
35
- const ForwardRefComponent: Component = (props: Props) => {
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
- export { createElement, Fragment, createContext, memo };
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;
@@ -146,7 +146,19 @@ export function applyProps(
146
146
  break;
147
147
  default:
148
148
  if (key.startsWith("on")) {
149
- const event = key.slice(2).toLowerCase();
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
- useEffect(() => {
260
+ useLayoutEffect(() => {
261
261
  const handleStoreChange = () => {
262
262
  setSnapshot(() => getSnapshot());
263
263
  };
264
264
  const unsubscribe = subscribe(handleStoreChange);
265
- handleStoreChange();
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![0].type).toBe("TEXT");
18
- expect(vnode.props.children![0].props.nodeValue).toBe("hello");
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![0].type).toBe("img");
31
- expect(vnode.props.children![1].type).toBe("span");
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![0].type).toBe("TEXT");
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![0].props.nodeValue).toBe("42");
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 as unknown[]).length).toBe(1);
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 () => {