@ipxjs/refract 0.5.1 → 0.7.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,21 +220,23 @@ image requests blocked.
215
220
 
216
221
  ### Bundle Size Snapshot
217
222
 
218
- The values below are from a local run on February 16, 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
227
  | Refract (`core`) | 8.62 kB | 3.24 kB |
223
- | Refract (`core+hooks`) | 10.28 kB | 3.85 kB |
228
+ | Refract (`core+hooks`) | 10.27 kB | 3.84 kB |
224
229
  | Refract (`core+context`) | 9.12 kB | 3.47 kB |
225
- | Refract (`core+memo`) | 9.29 kB | 3.48 kB |
230
+ | Refract (`core+memo`) | 9.29 kB | 3.47 kB |
226
231
  | Refract (`core+security`) | 9.53 kB | 3.54 kB |
227
232
  | Refract (`refract`) | 15.20 kB | 5.55 kB |
228
- | React | 189.74 kB | 59.52 kB |
229
- | Preact | 14.46 kB | 5.95 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
241
  From this snapshot, Refract `core` gzip JS is about 18.4x smaller than React,
235
242
  and the full `refract` entrypoint is about 10.7x smaller.
@@ -242,16 +249,16 @@ 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` | 5341.47 | baseline | 3932.98 | baseline |
246
- | `memo` | 5821.44 | +9.0% | 5202.62 | +32.3% |
247
- | `context` | 3960.70 | -25.9% | 5108.06 | +29.9% |
248
- | `fragment` | 4739.90 | -11.3% | 4114.70 | +4.6% |
249
- | `keyed` | 6008.81 | +12.5% | 4816.85 | +22.5% |
250
- | `memo+context` | 5670.29 | +6.2% | 5231.58 | +33.0% |
251
- | `memo+context+keyed` | 5813.60 | +8.8% | 4606.91 | +17.1% |
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% |
252
259
 
253
260
  In this run, `keyed` was the fastest mount profile, while
254
- `memo+context` was the fastest reconcile profile.
261
+ `memo` was the fastest reconcile profile.
255
262
 
256
263
  ### Running the Benchmark
257
264
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ipxjs/refract",
3
- "version": "0.5.1",
3
+ "version": "0.7.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 { flushPendingRenders } from "../coreRenderer.js";
3
+ import { flushPassiveEffects } from "../hooksRuntime.js";
3
4
  import { setReactCompatEventMode } from "../dom.js";
4
5
  import { createPortal as createPortalImpl } from "../portal.js";
5
6
  import type { PortalChild } from "../portal.js";
@@ -18,6 +19,7 @@ export function unstable_batchedUpdates<T>(callback: () => T): T {
18
19
  export function flushSync<T>(callback: () => T): T {
19
20
  const result = callback();
20
21
  flushPendingRenders();
22
+ flushPassiveEffects();
21
23
  return result;
22
24
  }
23
25
 
@@ -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;
@@ -8,6 +8,7 @@ import {
8
8
  runAfterComponentRenderHandlers,
9
9
  runAfterCommitHandlers,
10
10
  runBeforeComponentRenderHandlers,
11
+ runBeforeRenderBatchHandlers,
11
12
  runCommitHandlers,
12
13
  runFiberCleanupHandlers,
13
14
  shouldBailoutComponent,
@@ -358,53 +359,65 @@ const MAX_NESTED_RENDERS = 50;
358
359
  const renderCounts = new Map<Node, number>();
359
360
 
360
361
  function flushRenders(): void {
361
- flushScheduled = false;
362
- // Snapshot and clear pendingContainers BEFORE processing,
363
- // so effects that call scheduleRender during commit add to a fresh set.
364
- const containers = [...pendingContainers];
365
- pendingContainers.clear();
366
- for (const container of containers) {
367
- const currentRoot = roots.get(container);
368
- if (!currentRoot) continue;
369
-
370
- // Re-render guard: detect infinite loops (matches React's limit of 50)
371
- const count = (renderCounts.get(container) ?? 0) + 1;
372
- if (count > MAX_NESTED_RENDERS) {
373
- renderCounts.delete(container);
374
- throw new Error(
375
- "Too many re-renders. Refract limits the number of renders to prevent an infinite loop.",
376
- );
362
+ // Flush any pending passive effects from previous renders before
363
+ // starting new render work. This matches React's behavior: passive
364
+ // effects from render N are guaranteed to run before render N+1.
365
+ runBeforeRenderBatchHandlers();
366
+
367
+ // Keep flushScheduled true during processing to prevent duplicate
368
+ // microtask scheduling. Layout effects that call setState will add to
369
+ // pendingContainers, which we process in the next iteration of the
370
+ // while loop (synchronous cascade, protected by the render counter).
371
+ while (pendingContainers.size > 0) {
372
+ const containers = [...pendingContainers];
373
+ pendingContainers.clear();
374
+
375
+ for (const container of containers) {
376
+ const currentRoot = roots.get(container);
377
+ if (!currentRoot) continue;
378
+
379
+ // Re-render guard: detect infinite loops (matches React's limit of 50)
380
+ const count = (renderCounts.get(container) ?? 0) + 1;
381
+ if (count > MAX_NESTED_RENDERS) {
382
+ renderCounts.clear();
383
+ flushScheduled = false;
384
+ throw new Error(
385
+ "Too many re-renders. Refract limits the number of renders to prevent an infinite loop.",
386
+ );
387
+ }
388
+ renderCounts.set(container, count);
389
+
390
+ const newRoot: Fiber = {
391
+ type: currentRoot.type,
392
+ props: currentRoot.props,
393
+ key: currentRoot.key,
394
+ dom: currentRoot.dom,
395
+ parentDom: currentRoot.parentDom,
396
+ parent: null,
397
+ child: null,
398
+ sibling: null,
399
+ hooks: null,
400
+ alternate: currentRoot,
401
+ flags: UPDATE,
402
+ };
403
+ deletions = [];
404
+ isRendering = true;
405
+ performWork(newRoot);
406
+ isRendering = false;
407
+ const committedDeletions = deletions.slice();
408
+ commitRoot(newRoot);
409
+ runAfterCommitHandlers();
410
+ roots.set(container, newRoot);
411
+ runCommitHandlers(newRoot, committedDeletions);
377
412
  }
378
- renderCounts.set(container, count);
379
-
380
- const newRoot: Fiber = {
381
- type: currentRoot.type,
382
- props: currentRoot.props,
383
- key: currentRoot.key,
384
- dom: currentRoot.dom,
385
- parentDom: currentRoot.parentDom,
386
- parent: null,
387
- child: null,
388
- sibling: null,
389
- hooks: null,
390
- alternate: currentRoot,
391
- flags: UPDATE,
392
- };
393
- deletions = [];
394
- isRendering = true;
395
- performWork(newRoot);
396
- isRendering = false;
397
- const committedDeletions = deletions.slice();
398
- commitRoot(newRoot);
399
- runAfterCommitHandlers();
400
- roots.set(container, newRoot);
401
- runCommitHandlers(newRoot, committedDeletions);
402
413
  }
403
414
 
404
- // Reset counters when no more pending renders (loop resolved)
405
- if (pendingContainers.size === 0) {
406
- renderCounts.clear();
407
- }
415
+ // All synchronous work complete (including layout effect cascades).
416
+ // Reset counter and allow new microtask scheduling.
417
+ // Deferred passive effects will trigger fresh flushRenders calls
418
+ // with their own counter scope.
419
+ renderCounts.clear();
420
+ flushScheduled = false;
408
421
  }
409
422
 
410
423
  export function flushPendingRenders(): void {
@@ -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
  }
@@ -58,6 +58,7 @@ interface EffectHook extends Hook {
58
58
  deps: unknown[] | undefined;
59
59
  cleanup?: EffectCleanup;
60
60
  pending: boolean;
61
+ effectType: "insertion" | "layout" | "passive";
61
62
  };
62
63
  }
63
64
 
@@ -66,7 +67,7 @@ export function useEffect(effect: () => EffectCleanup, deps?: unknown[]): void {
66
67
  const fiber = currentFiber!;
67
68
 
68
69
  if (hook.state === undefined) {
69
- hook.state = { effect, deps, cleanup: undefined, pending: true };
70
+ hook.state = { effect, deps, cleanup: undefined, pending: true, effectType: "passive" };
70
71
  markPendingEffects(fiber);
71
72
  } else {
72
73
  if (depsChanged(hook.state.deps, deps)) {
@@ -85,7 +86,7 @@ export function useLayoutEffect(effect: () => EffectCleanup, deps?: unknown[]):
85
86
  const fiber = currentFiber!;
86
87
 
87
88
  if (hook.state === undefined) {
88
- hook.state = { effect, deps, cleanup: undefined, pending: true };
89
+ hook.state = { effect, deps, cleanup: undefined, pending: true, effectType: "layout" };
89
90
  markPendingLayoutEffects(fiber);
90
91
  } else {
91
92
  if (depsChanged(hook.state.deps, deps)) {
@@ -104,7 +105,7 @@ export function useInsertionEffect(effect: () => EffectCleanup, deps?: unknown[]
104
105
  const fiber = currentFiber!;
105
106
 
106
107
  if (hook.state === undefined) {
107
- hook.state = { effect, deps, cleanup: undefined, pending: true };
108
+ hook.state = { effect, deps, cleanup: undefined, pending: true, effectType: "insertion" };
108
109
  markPendingInsertionEffects(fiber);
109
110
  } else {
110
111
  if (depsChanged(hook.state.deps, deps)) {
@@ -257,12 +258,17 @@ export function useSyncExternalStore<T>(
257
258
  // snapshots (e.g. zustand selectors returning store actions) as updaters.
258
259
  const [snapshot, setSnapshot] = useState<T>(() => getSnapshot());
259
260
 
260
- useEffect(() => {
261
+ useLayoutEffect(() => {
261
262
  const handleStoreChange = () => {
262
263
  setSnapshot(() => getSnapshot());
263
264
  };
264
265
  const unsubscribe = subscribe(handleStoreChange);
265
- handleStoreChange();
266
+
267
+ // Check if snapshot changed between render and effect
268
+ if (getSnapshot() !== snapshot) {
269
+ handleStoreChange();
270
+ }
271
+
266
272
  return () => {
267
273
  unsubscribe();
268
274
  };
@@ -3,6 +3,7 @@ export { render } from "./render.js";
3
3
  export { memo } from "./memo.js";
4
4
  export { setHtmlSanitizer } from "./features/security.js";
5
5
  export { setDevtoolsHook, DEVTOOLS_GLOBAL_HOOK } from "./devtools.js";
6
+ export { flushPassiveEffects } from "./hooksRuntime.js";
6
7
  export {
7
8
  useState,
8
9
  useEffect,
@@ -2,6 +2,7 @@ import type { Fiber } from "./types.js";
2
2
  import { reconcileChildren } from "./reconcile.js";
3
3
  import {
4
4
  registerAfterCommitHandler,
5
+ registerBeforeRenderBatchHandler,
5
6
  registerFiberCleanupHandler,
6
7
  registerRenderErrorHandler,
7
8
  } from "./runtimeExtensions.js";
@@ -9,6 +10,8 @@ import {
9
10
  const fibersWithPendingEffects = new Set<Fiber>();
10
11
  const fibersWithPendingLayoutEffects = new Set<Fiber>();
11
12
  const fibersWithPendingInsertionEffects = new Set<Fiber>();
13
+ const deferredPassiveEffectFibers = new Set<Fiber>();
14
+ let passiveFlushScheduled = false;
12
15
 
13
16
  export function markPendingEffects(fiber: Fiber): void {
14
17
  fibersWithPendingEffects.add(fiber);
@@ -26,18 +29,23 @@ function cleanupFiberEffects(fiber: Fiber): void {
26
29
  fibersWithPendingEffects.delete(fiber);
27
30
  fibersWithPendingLayoutEffects.delete(fiber);
28
31
  fibersWithPendingInsertionEffects.delete(fiber);
32
+ deferredPassiveEffectFibers.delete(fiber);
29
33
  if (!fiber.hooks) return;
30
34
 
31
35
  for (const hook of fiber.hooks) {
32
- const state = hook.state as { cleanup?: () => void } | undefined;
36
+ const state = hook.state as { cleanup?: () => void; pending?: boolean } | undefined;
33
37
  if (state?.cleanup) {
34
38
  state.cleanup();
35
39
  state.cleanup = undefined;
36
40
  }
41
+ // Prevent deferred passive effects from running on unmounted fibers
42
+ if (state && "pending" in state) {
43
+ state.pending = false;
44
+ }
37
45
  }
38
46
  }
39
47
 
40
- function runPendingEffectsFor(fibers: Set<Fiber>): void {
48
+ function runPendingEffectsFor(fibers: Set<Fiber>, effectType: string): void {
41
49
  for (const fiber of fibers) {
42
50
  if (!fiber.hooks) continue;
43
51
 
@@ -46,7 +54,9 @@ function runPendingEffectsFor(fibers: Set<Fiber>): void {
46
54
  effect?: () => void | (() => void);
47
55
  pending?: boolean;
48
56
  cleanup?: () => void;
57
+ effectType?: string;
49
58
  } | undefined;
59
+ if (state?.effectType !== effectType) continue;
50
60
  if (state?.pending && state.effect) {
51
61
  if (state.cleanup) state.cleanup();
52
62
  state.cleanup = state.effect() || undefined;
@@ -58,10 +68,30 @@ function runPendingEffectsFor(fibers: Set<Fiber>): void {
58
68
  }
59
69
 
60
70
  function runPendingEffects(): void {
61
- // run in insertion -> layout -> passive order
62
- runPendingEffectsFor(fibersWithPendingInsertionEffects);
63
- runPendingEffectsFor(fibersWithPendingLayoutEffects);
64
- runPendingEffectsFor(fibersWithPendingEffects);
71
+ // Insertion and layout effects run synchronously (matching React)
72
+ runPendingEffectsFor(fibersWithPendingInsertionEffects, "insertion");
73
+ runPendingEffectsFor(fibersWithPendingLayoutEffects, "layout");
74
+
75
+ // Passive effects (useEffect) are deferred until after the current
76
+ // synchronous work completes, matching React's behavior.
77
+ if (fibersWithPendingEffects.size > 0) {
78
+ for (const fiber of fibersWithPendingEffects) {
79
+ deferredPassiveEffectFibers.add(fiber);
80
+ }
81
+ fibersWithPendingEffects.clear();
82
+ if (!passiveFlushScheduled) {
83
+ passiveFlushScheduled = true;
84
+ setTimeout(flushPassiveEffects, 0);
85
+ }
86
+ }
87
+ }
88
+
89
+ export function flushPassiveEffects(): void {
90
+ passiveFlushScheduled = false;
91
+ if (deferredPassiveEffectFibers.size === 0) return;
92
+ const fibers = new Set(deferredPassiveEffectFibers);
93
+ deferredPassiveEffectFibers.clear();
94
+ runPendingEffectsFor(fibers, "passive");
65
95
  }
66
96
 
67
97
  function handleErrorBoundary(fiber: Fiber, error: unknown): boolean {
@@ -79,4 +109,5 @@ function handleErrorBoundary(fiber: Fiber, error: unknown): boolean {
79
109
 
80
110
  registerFiberCleanupHandler(cleanupFiberEffects);
81
111
  registerAfterCommitHandler(runPendingEffects);
112
+ registerBeforeRenderBatchHandler(flushPassiveEffects);
82
113
  registerRenderErrorHandler(handleErrorBoundary);
@@ -2,6 +2,7 @@ import type { Fiber } from "./types.js";
2
2
 
3
3
  type FiberCleanupHandler = (fiber: Fiber) => void;
4
4
  type AfterCommitHandler = () => void;
5
+ type BeforeRenderBatchHandler = () => void;
5
6
  type RenderErrorHandler = (fiber: Fiber, error: unknown) => boolean;
6
7
  type CommitHandler = (rootFiber: Fiber, deletions: Fiber[]) => void;
7
8
  type ComponentBailoutHandler = (fiber: Fiber) => boolean;
@@ -10,6 +11,7 @@ type AfterComponentRenderHandler = (fiber: Fiber) => void;
10
11
 
11
12
  const fiberCleanupHandlers = new Set<FiberCleanupHandler>();
12
13
  const afterCommitHandlers = new Set<AfterCommitHandler>();
14
+ const beforeRenderBatchHandlers = new Set<BeforeRenderBatchHandler>();
13
15
  const renderErrorHandlers = new Set<RenderErrorHandler>();
14
16
  const commitHandlers = new Set<CommitHandler>();
15
17
  const componentBailoutHandlers = new Set<ComponentBailoutHandler>();
@@ -32,6 +34,11 @@ export function registerAfterCommitHandler(handler: AfterCommitHandler): () => v
32
34
  return makeUnregister(afterCommitHandlers, handler);
33
35
  }
34
36
 
37
+ export function registerBeforeRenderBatchHandler(handler: BeforeRenderBatchHandler): () => void {
38
+ beforeRenderBatchHandlers.add(handler);
39
+ return makeUnregister(beforeRenderBatchHandlers, handler);
40
+ }
41
+
35
42
  export function registerRenderErrorHandler(handler: RenderErrorHandler): () => void {
36
43
  renderErrorHandlers.add(handler);
37
44
  return makeUnregister(renderErrorHandlers, handler);
@@ -69,6 +76,12 @@ export function runAfterCommitHandlers(): void {
69
76
  }
70
77
  }
71
78
 
79
+ export function runBeforeRenderBatchHandlers(): void {
80
+ for (const handler of beforeRenderBatchHandlers) {
81
+ handler();
82
+ }
83
+ }
84
+
72
85
  export function tryHandleRenderError(fiber: Fiber, error: unknown): boolean {
73
86
  for (const handler of renderErrorHandlers) {
74
87
  if (handler(fiber, error)) {
@@ -54,7 +54,7 @@ describe("react compat", () => {
54
54
  expect(container.querySelector("span")!.id).toBe(ids[1]);
55
55
  });
56
56
 
57
- it("runs insertion/layout/passive effects in commit order", () => {
57
+ it("runs insertion/layout effects synchronously, passive effects deferred", async () => {
58
58
  const root = createRoot(container);
59
59
  const calls: string[] = [];
60
60
 
@@ -72,6 +72,9 @@ describe("react compat", () => {
72
72
  }
73
73
 
74
74
  root.render(ReactCompat.createElement(App, null));
75
+ // Insertion and layout are synchronous; passive is deferred (like React)
76
+ expect(calls).toEqual(["insertion", "layout"]);
77
+ await waitForRender();
75
78
  expect(calls).toEqual(["insertion", "layout", "effect"]);
76
79
  });
77
80
 
@@ -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", () => {
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
2
2
  import { createElement } from "../src/refract/createElement.js";
3
3
  import { render } from "../src/refract/render.js";
4
4
  import { useState, useEffect, useRef, useMemo, useCallback, useReducer } from "../src/refract/hooks.js";
5
+ import { flushPassiveEffects } from "../src/refract/hooksRuntime.js";
5
6
 
6
7
  describe("hooks", () => {
7
8
  let container: HTMLDivElement;
@@ -73,13 +74,28 @@ describe("hooks", () => {
73
74
  });
74
75
 
75
76
  describe("useEffect", () => {
76
- it("runs effect after render", () => {
77
+ it("runs effect after render (deferred)", async () => {
77
78
  const effectFn = vi.fn();
78
79
  function App() {
79
80
  useEffect(effectFn);
80
81
  return createElement("div", null);
81
82
  }
82
83
  render(createElement(App, null), container);
84
+ // Passive effects are deferred (like React)
85
+ expect(effectFn).not.toHaveBeenCalled();
86
+ await new Promise((r) => setTimeout(r, 10));
87
+ expect(effectFn).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ it("can be flushed synchronously with flushPassiveEffects", () => {
91
+ const effectFn = vi.fn();
92
+ function App() {
93
+ useEffect(effectFn);
94
+ return createElement("div", null);
95
+ }
96
+ render(createElement(App, null), container);
97
+ expect(effectFn).not.toHaveBeenCalled();
98
+ flushPassiveEffects();
83
99
  expect(effectFn).toHaveBeenCalledTimes(1);
84
100
  });
85
101
 
@@ -112,6 +128,8 @@ describe("hooks", () => {
112
128
  return createElement("span", null, String(value));
113
129
  }
114
130
  render(createElement(App, null), container);
131
+ // Flush initial passive effects
132
+ flushPassiveEffects();
115
133
  expect(effectFn).toHaveBeenCalledTimes(1);
116
134
 
117
135
  setValue(1);
@@ -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 () => {