@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 +1 -1
- package/src/refract/compat/react-jsx-runtime.ts +20 -5
- package/src/refract/compat/react.ts +8 -1
- package/src/refract/coreRenderer.ts +70 -24
- package/src/refract/dom.ts +14 -3
- package/src/refract/features/context.ts +5 -1
- package/src/refract/features/hooks.ts +45 -19
- package/src/refract/types.ts +5 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/refract/dom.ts
CHANGED
|
@@ -120,7 +120,11 @@ export function applyProps(
|
|
|
120
120
|
break;
|
|
121
121
|
}
|
|
122
122
|
case "className":
|
|
123
|
-
|
|
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
|
-
|
|
155
|
+
const value = newProps[key];
|
|
156
|
+
if (unsafeUrlPropChecker(key, value)) {
|
|
152
157
|
el.removeAttribute(key);
|
|
153
158
|
continue;
|
|
154
159
|
}
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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,
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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();
|
package/src/refract/types.ts
CHANGED
|
@@ -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 */
|