@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 +31 -25
- 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/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
|
|
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.
|
|
223
|
-
| Refract (`core+hooks`) |
|
|
224
|
-
| Refract (`core+context`) |
|
|
225
|
-
| Refract (`core+memo`) | 9.
|
|
226
|
-
| Refract (`core+security`) | 9.
|
|
227
|
-
| Refract (`refract`) |
|
|
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.
|
|
235
|
-
and the full `refract` entrypoint is about
|
|
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` |
|
|
246
|
-
| `memo` |
|
|
247
|
-
| `context` |
|
|
248
|
-
| `fragment` |
|
|
249
|
-
| `keyed` |
|
|
250
|
-
| `memo+context` |
|
|
251
|
-
| `memo+context+keyed` |
|
|
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, `
|
|
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 |
|
|
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 |
|
|
310
|
-
|
|
|
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 |
|
|
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 |
|
|
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 |
|
|
337
|
-
| **Bundle Size (gzip, JS)** | ~2
|
|
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
|
@@ -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 */
|