@ipxjs/refract 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -215,24 +215,24 @@ image requests blocked.
215
215
 
216
216
  ### Bundle Size Snapshot
217
217
 
218
- The values below are from a local run on February 15, 2026.
218
+ The values below are from a local run on February 16, 2026.
219
219
 
220
220
  | Framework | JS bundle (raw) | JS bundle (gzip) |
221
221
  |---------------------------|----------------:|-----------------:|
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 |
222
+ | Refract (`core`) | 8.62 kB | 3.24 kB |
223
+ | Refract (`core+hooks`) | 10.28 kB | 3.85 kB |
224
+ | Refract (`core+context`) | 9.12 kB | 3.47 kB |
225
+ | Refract (`core+memo`) | 9.29 kB | 3.48 kB |
226
+ | Refract (`core+security`) | 9.53 kB | 3.54 kB |
227
+ | Refract (`refract`) | 15.20 kB | 5.55 kB |
228
228
  | React | 189.74 kB | 59.52 kB |
229
229
  | Preact | 14.46 kB | 5.95 kB |
230
230
 
231
231
  Load-time metrics are machine-dependent, so the benchmark script prints a fresh
232
232
  per-run timing table (median, p95, min/max, sd) for every framework.
233
233
 
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.
234
+ From this snapshot, Refract `core` gzip JS is about 18.4x smaller than React,
235
+ and the full `refract` entrypoint is about 10.7x smaller.
236
236
 
237
237
  ### Component Combination Benchmarks (Vitest)
238
238
 
@@ -242,16 +242,16 @@ Higher `hz` is better.
242
242
 
243
243
  | Component usage profile | Mount (hz) | Mount vs base | Reconcile (hz) | Reconcile vs base |
244
244
  |-------------------------|------------|---------------|----------------|-------------------|
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% |
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
252
 
253
- In this run, `memo+context` was the fastest mount profile, while
254
- `memo` was the fastest reconcile profile.
253
+ In this run, `keyed` was the fastest mount profile, while
254
+ `memo+context` was the fastest reconcile profile.
255
255
 
256
256
  ### Running the Benchmark
257
257
 
@@ -301,24 +301,27 @@ How Refract compares to React and Preact:
301
301
  | **Hooks** | | | |
302
302
  | useState | Yes | Yes | Yes |
303
303
  | useEffect | Yes | Yes | Yes |
304
- | useLayoutEffect | No | Yes | Yes |
304
+ | useLayoutEffect | Yes | Yes | Yes |
305
+ | useInsertionEffect | Yes | Yes | Yes |
305
306
  | useRef | Yes | Yes | Yes |
306
307
  | useMemo / useCallback | Yes | Yes | Yes |
307
308
  | useReducer | Yes | Yes | Yes |
308
309
  | useContext | Yes | Yes | Yes |
309
- | useId | No | Yes | Yes |
310
- | useTransition / useDeferredValue | No | Yes | No |
310
+ | useId | Yes | Yes | Yes |
311
+ | useSyncExternalStore | Yes | Yes | Yes |
312
+ | useImperativeHandle | Yes | Yes | Yes |
313
+ | useTransition / useDeferredValue | Partial⁵ | Yes | Partial⁷ |
311
314
  | **State & Data Flow** | | | |
312
315
  | Built-in state management | Yes | Yes | Yes |
313
316
  | Context API | Yes | Yes | Yes |
314
317
  | Refs (createRef / ref prop) | Yes | Yes | Yes |
315
- | forwardRef | No | Yes | Yes |
318
+ | forwardRef | Yes⁶ | Yes | Yes |
316
319
  | **Rendering** | | | |
317
320
  | Event handling | Yes | Yes | Yes |
318
321
  | Style objects | Yes | Yes | Yes |
319
322
  | className prop | Yes | Yes | Yes¹ |
320
323
  | dangerouslySetInnerHTML | Yes | Yes | Yes |
321
- | Portals | No | Yes | Yes |
324
+ | Portals | Yes | Yes | Yes |
322
325
  | Suspense / lazy | No | Yes | Yes² |
323
326
  | Error boundaries | Yes³ | Yes | Yes |
324
327
  | Server-side rendering | No | Yes | Yes |
@@ -333,13 +336,16 @@ How Refract compares to React and Preact:
333
336
  | memo / PureComponent | Yes | Yes | Yes |
334
337
  | **Ecosystem** | | | |
335
338
  | 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 |
339
+ | React compatibility layer | Yes⁶ | N/A | Yes|
340
+ | **Bundle Size (gzip, JS)** | ~3.2-5.6 kB⁴ | ~59.5 kB | ~6.0 kB |
338
341
 
339
342
  ¹ Preact supports both `class` and `className`.
340
343
  ² Preact has partial Suspense support via `preact/compat`.
341
344
  ³ Refract uses the `useErrorBoundary` hook rather than class-based error boundaries.
342
345
  ⁴ Refract size depends on entrypoint (`refract/core` vs `refract` full).
346
+ ⁵ Refract exposes `useTransition` / `useDeferredValue` but currently runs both synchronously (no concurrent scheduling).
347
+ ⁶ Available via opt-in compat entrypoints (`refract/compat/react*`).
348
+ ⁷ Preact compatibility is provided through `preact/compat`.
343
349
 
344
350
  ## License
345
351
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ipxjs/refract",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "A minimal React-like virtual DOM library with an optional React compat layer",
5
5
  "type": "module",
6
6
  "main": "src/refract/index.ts",
@@ -4,6 +4,19 @@ import type { VNode, VNodeType } from "../types.js";
4
4
  type JsxProps = Record<string, unknown> | null | undefined;
5
5
  type JsxChild = VNode | string | number | boolean | null | undefined | JsxChild[];
6
6
 
7
+ // React compat: normalize props.children on the returned VNode so that
8
+ // a single child is stored directly (not in an array). Libraries like
9
+ // MUI access `props.children.props` expecting a single React element.
10
+ // Refract's core createElement always stores children as an array, but
11
+ // the renderer's normalizeChildrenProp handles both formats, so this is safe.
12
+ function normalizeVNodeChildren(vnode: VNode): VNode {
13
+ const c = vnode.props.children;
14
+ if (Array.isArray(c) && c.length === 1) {
15
+ vnode.props.children = c[0];
16
+ }
17
+ return vnode;
18
+ }
19
+
7
20
  function createJsxElement(type: VNodeType, rawProps: JsxProps, key?: string): ReturnType<typeof createElement> {
8
21
  const props = { ...(rawProps ?? {}) };
9
22
  if (key !== undefined) {
@@ -12,13 +25,15 @@ function createJsxElement(type: VNodeType, rawProps: JsxProps, key?: string): Re
12
25
  const children = props.children as JsxChild | JsxChild[] | undefined;
13
26
  delete props.children;
14
27
 
28
+ let vnode: VNode;
15
29
  if (children === undefined) {
16
- return createElement(type, props);
17
- }
18
- if (Array.isArray(children)) {
19
- return createElement(type, props, ...(children as JsxChild[]));
30
+ vnode = createElement(type, props);
31
+ } else if (Array.isArray(children)) {
32
+ vnode = createElement(type, props, ...(children as JsxChild[]));
33
+ } else {
34
+ vnode = createElement(type, props, children as JsxChild);
20
35
  }
21
- return createElement(type, props, children as JsxChild);
36
+ return normalizeVNodeChildren(vnode);
22
37
  }
23
38
 
24
39
  export function jsx(type: VNodeType, props: JsxProps, key?: string): ReturnType<typeof createElement> {
@@ -36,6 +36,7 @@ export function forwardRef<T, P extends Record<string, unknown> = Record<string,
36
36
  const { ref, ...rest } = props as Props & { ref?: { current: T | null } | ((value: T | null) => void) | null };
37
37
  return render(rest as unknown as P, ref ?? null);
38
38
  };
39
+ (ForwardRefComponent as any).displayName = `ForwardRef(${(render as any).name || 'anonymous'})`;
39
40
  return ForwardRefComponent;
40
41
  }
41
42
 
@@ -69,7 +70,13 @@ export function cloneElement(
69
70
  const nextChildren = normalizeChildren(mergedProps.children);
70
71
  delete mergedProps.children;
71
72
 
72
- return createElement(element.type, mergedProps, ...(nextChildren as ElementChild[]));
73
+ const vnode = createElement(element.type, mergedProps, ...(nextChildren as ElementChild[]));
74
+ // React compat: single child should not be wrapped in array
75
+ const c = vnode.props.children;
76
+ if (Array.isArray(c) && c.length === 1) {
77
+ vnode.props.children = c[0];
78
+ }
79
+ return vnode;
73
80
  }
74
81
 
75
82
  function childrenToArray(children: unknown): unknown[] {
@@ -17,6 +17,9 @@ import {
17
17
  /** Module globals for hook system */
18
18
  export let currentFiber: Fiber | null = null;
19
19
 
20
+ /** True while performWork() is executing (render phase) */
21
+ export let isRendering = false;
22
+
20
23
  /** Store root fiber per container */
21
24
  const roots = new WeakMap<Node, Fiber>();
22
25
  let deletions: Fiber[] = [];
@@ -43,7 +46,9 @@ export function renderFiber(vnode: VNode, container: Node): void {
43
46
  flags: UPDATE,
44
47
  };
45
48
  deletions = [];
49
+ isRendering = true;
46
50
  performWork(rootFiber);
51
+ isRendering = false;
47
52
  const committedDeletions = deletions.slice();
48
53
  commitRoot(rootFiber);
49
54
  runAfterCommitHandlers();
@@ -51,15 +56,16 @@ export function renderFiber(vnode: VNode, container: Node): void {
51
56
  runCommitHandlers(rootFiber, committedDeletions);
52
57
  }
53
58
 
54
- /** Depth-first work loop: call components, diff children */
55
- function performWork(fiber: Fiber): void {
59
+ /** Process a single fiber: call component, diff children.
60
+ * Returns true if bailed out (children should be skipped). */
61
+ function processWorkUnit(fiber: Fiber): boolean {
56
62
  const isComponent = typeof fiber.type === "function";
57
63
  const isFragment = fiber.type === Fragment;
58
64
  const isPortal = fiber.type === Portal;
59
65
 
60
66
  if (isComponent) {
61
67
  if (fiber.alternate && fiber.flags === UPDATE && shouldBailoutComponent(fiber)) {
62
- return advanceWork(fiber);
68
+ return true;
63
69
  }
64
70
 
65
71
  currentFiber = fiber;
@@ -95,13 +101,35 @@ function performWork(fiber: Fiber): void {
95
101
  }
96
102
  }
97
103
 
98
- // Traverse: child first, then sibling, then uncle
99
- if (fiber.child) {
100
- performWork(fiber.child);
101
- return;
102
- }
104
+ return false;
105
+ }
106
+
107
+ /** Iterative depth-first work loop (avoids stack overflow on deep trees) */
108
+ function performWork(rootFiber: Fiber): void {
109
+ let fiber: Fiber | null = rootFiber;
110
+
111
+ while (fiber) {
112
+ const bailedOut = processWorkUnit(fiber);
113
+
114
+ // Descend to child unless bailed out
115
+ if (!bailedOut && fiber.child) {
116
+ fiber = fiber.child;
117
+ continue;
118
+ }
103
119
 
104
- advanceWork(fiber);
120
+ // No child or bailed out — walk up to find next sibling
121
+ if (fiber === rootFiber) break;
122
+ while (fiber) {
123
+ if (fiber.sibling) {
124
+ fiber = fiber.sibling;
125
+ break;
126
+ }
127
+ fiber = fiber.parent;
128
+ if (!fiber || fiber === rootFiber) {
129
+ fiber = null;
130
+ }
131
+ }
132
+ }
105
133
  }
106
134
 
107
135
  type RenderedChild = VNode | string | number | boolean | null | undefined | RenderedChild[];
@@ -139,17 +167,6 @@ function isPortalFiber(fiber: Fiber): boolean {
139
167
  return fiber.type === Portal;
140
168
  }
141
169
 
142
- function advanceWork(fiber: Fiber): void {
143
- let next: Fiber | null = fiber;
144
- while (next) {
145
- if (next.sibling) {
146
- performWork(next.sibling);
147
- return;
148
- }
149
- next = next.parent;
150
- }
151
- }
152
-
153
170
  /** Find the next DOM sibling for insertion (skips siblings being placed/moved) */
154
171
  function getNextDomSibling(fiber: Fiber): Node | null {
155
172
  let sib: Fiber | null = fiber.sibling;
@@ -266,9 +283,15 @@ function commitWork(fiber: Fiber): void {
266
283
  }
267
284
  }
268
285
 
269
- // Handle ref prop
286
+ // Handle ref prop — only on mount or when ref changes (like React)
270
287
  if (fiber.dom && fiber.props.ref) {
271
- setRef(fiber.props.ref, fiber.dom);
288
+ const oldRef = fiber.alternate?.props.ref;
289
+ if (fiber.flags & PLACEMENT || fiber.props.ref !== oldRef) {
290
+ if (oldRef && oldRef !== fiber.props.ref) {
291
+ setRef(oldRef, null);
292
+ }
293
+ setRef(fiber.props.ref, fiber.dom);
294
+ }
272
295
  }
273
296
 
274
297
  fiber.flags = 0;
@@ -331,12 +354,29 @@ export function scheduleRender(fiber: Fiber): void {
331
354
  }
332
355
  }
333
356
 
357
+ const MAX_NESTED_RENDERS = 50;
358
+ const renderCounts = new Map<Node, number>();
359
+
334
360
  function flushRenders(): void {
335
361
  flushScheduled = false;
336
- for (const container of pendingContainers) {
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) {
337
367
  const currentRoot = roots.get(container);
338
368
  if (!currentRoot) continue;
339
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
+ );
377
+ }
378
+ renderCounts.set(container, count);
379
+
340
380
  const newRoot: Fiber = {
341
381
  type: currentRoot.type,
342
382
  props: currentRoot.props,
@@ -351,14 +391,20 @@ function flushRenders(): void {
351
391
  flags: UPDATE,
352
392
  };
353
393
  deletions = [];
394
+ isRendering = true;
354
395
  performWork(newRoot);
396
+ isRendering = false;
355
397
  const committedDeletions = deletions.slice();
356
398
  commitRoot(newRoot);
357
399
  runAfterCommitHandlers();
358
400
  roots.set(container, newRoot);
359
401
  runCommitHandlers(newRoot, committedDeletions);
360
402
  }
361
- pendingContainers.clear();
403
+
404
+ // Reset counters when no more pending renders (loop resolved)
405
+ if (pendingContainers.size === 0) {
406
+ renderCounts.clear();
407
+ }
362
408
  }
363
409
 
364
410
  export function flushPendingRenders(): void {
@@ -120,7 +120,11 @@ export function applyProps(
120
120
  break;
121
121
  }
122
122
  case "className":
123
- el.setAttribute("class", newProps[key] as string);
123
+ if (newProps[key] == null || newProps[key] === false) {
124
+ el.removeAttribute("class");
125
+ } else {
126
+ el.setAttribute("class", String(newProps[key]));
127
+ }
124
128
  break;
125
129
  case "style":
126
130
  if (typeof newProps[key] === "object" && newProps[key] !== null) {
@@ -148,11 +152,18 @@ export function applyProps(
148
152
  }
149
153
  el.addEventListener(event, getEventListener(newProps[key]));
150
154
  } else {
151
- if (unsafeUrlPropChecker(key, newProps[key])) {
155
+ const value = newProps[key];
156
+ if (unsafeUrlPropChecker(key, value)) {
152
157
  el.removeAttribute(key);
153
158
  continue;
154
159
  }
155
- el.setAttribute(key, String(newProps[key]));
160
+ if (value == null || value === false) {
161
+ el.removeAttribute(key);
162
+ } else if (value === true) {
163
+ el.setAttribute(key, "true");
164
+ } else {
165
+ el.setAttribute(key, String(value));
166
+ }
156
167
  }
157
168
  break;
158
169
  }
@@ -20,7 +20,11 @@ export function createContext<T>(defaultValue: T): Context<T> {
20
20
  fiber._contexts.set(id, props.value);
21
21
 
22
22
  const children = props.children ?? [];
23
- return children.length === 1 ? children[0] : createElement(Fragment, null, ...children);
23
+ if (Array.isArray(children)) {
24
+ return children.length === 1 ? children[0] : createElement(Fragment, null, ...children);
25
+ }
26
+ // Single child (React compat: children may be a VNode, not an array)
27
+ return children;
24
28
  };
25
29
 
26
30
  return { Provider, _id: id, _defaultValue: defaultValue };
@@ -33,15 +33,21 @@ export function useState<T>(initial: T | (() => T)): [T, (value: T | ((prev: T)
33
33
  }
34
34
  hook.queue = [];
35
35
 
36
- const setState = (value: T | ((prev: T) => T)) => {
37
- const action = typeof value === "function"
38
- ? value as (prev: T) => T
39
- : () => value;
40
- (hook.queue as ((prev: T) => T)[]).push(action);
41
- scheduleRender(fiber);
42
- };
36
+ // Create a stable setter that is reused across renders (like React)
37
+ if (!hook._setter) {
38
+ hook._setter = (value: T | ((prev: T) => T)) => {
39
+ const action = typeof value === "function"
40
+ ? value as (prev: T) => T
41
+ : () => value;
42
+ (hook.queue as ((prev: T) => T)[]).push(action);
43
+ scheduleRender(hook._fiber!);
44
+ };
45
+ }
46
+ // Update fiber reference each render so the setter always schedules
47
+ // against the current fiber (fibers are recreated on re-render)
48
+ hook._fiber = fiber;
43
49
 
44
- return [hook.state as T, setState];
50
+ return [hook.state as T, hook._setter as (value: T | ((prev: T) => T)) => void];
45
51
  }
46
52
 
47
53
  type EffectCleanup = void | (() => void);
@@ -162,15 +168,33 @@ export function useReducer<S, A, I>(
162
168
  initialArg: S | I,
163
169
  init?: (arg: I) => S,
164
170
  ): [S, (action: A) => void] {
165
- const [state, setState] = useState<S>(() => (
166
- init
167
- ? init(initialArg as I)
168
- : initialArg as S
169
- ));
170
- const dispatch = (action: A) => {
171
- setState((prev) => reducer(prev, action));
172
- };
173
- return [state, dispatch];
171
+ const hook = getHook();
172
+ const fiber = currentFiber!;
173
+
174
+ if (hook.queue === undefined) {
175
+ hook.state = init ? init(initialArg as I) : initialArg as S;
176
+ hook.queue = [];
177
+ }
178
+
179
+ // Process queued actions
180
+ for (const action of hook.queue as A[]) {
181
+ hook.state = reducer(hook.state as S, action);
182
+ }
183
+ hook.queue = [];
184
+
185
+ // Store current reducer ref (may change between renders)
186
+ hook._reducer = reducer;
187
+
188
+ // Create stable dispatch (like React)
189
+ if (!hook._dispatch) {
190
+ hook._dispatch = (action: A) => {
191
+ (hook.queue as A[]).push(action);
192
+ scheduleRender(hook._fiber!);
193
+ };
194
+ }
195
+ hook._fiber = fiber;
196
+
197
+ return [hook.state as S, hook._dispatch as (action: A) => void];
174
198
  }
175
199
 
176
200
  export function createRef<T = unknown>(): { current: T | null } {
@@ -229,11 +253,13 @@ export function useSyncExternalStore<T>(
229
253
  getSnapshot: () => T,
230
254
  _getServerSnapshot?: () => T,
231
255
  ): T {
232
- const [snapshot, setSnapshot] = useState<T>(getSnapshot());
256
+ // Wrap in arrow functions to prevent useState from treating function-valued
257
+ // snapshots (e.g. zustand selectors returning store actions) as updaters.
258
+ const [snapshot, setSnapshot] = useState<T>(() => getSnapshot());
233
259
 
234
260
  useEffect(() => {
235
261
  const handleStoreChange = () => {
236
- setSnapshot(getSnapshot());
262
+ setSnapshot(() => getSnapshot());
237
263
  };
238
264
  const unsubscribe = subscribe(handleStoreChange);
239
265
  handleStoreChange();
@@ -7,7 +7,7 @@ export type VNodeType = string | symbol | Component;
7
7
  /** Props passed to elements and components */
8
8
  export interface Props {
9
9
  [key: string]: unknown;
10
- children?: VNode[];
10
+ children?: VNode[] | VNode;
11
11
  key?: string | number;
12
12
  }
13
13
 
@@ -27,6 +27,10 @@ export const DELETION = 4;
27
27
  export interface Hook {
28
28
  state: unknown;
29
29
  queue?: unknown[];
30
+ _setter?: Function;
31
+ _dispatch?: Function;
32
+ _reducer?: Function;
33
+ _fiber?: Fiber;
30
34
  }
31
35
 
32
36
  /** Internal fiber node — represents a mounted VNode */