@ipxjs/refract 0.4.1 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ipxjs/refract",
3
- "version": "0.4.1",
3
+ "version": "0.5.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",
@@ -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 */