@ipxjs/refract 0.11.0 → 0.13.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 +13 -6
- package/package.json +1 -1
- package/src/refract/compat/react-dom.ts +9 -0
- package/src/refract/compat/react-jsx-runtime.ts +3 -0
- package/src/refract/compat/react.ts +76 -8
- package/src/refract/compat/sharedInternals.ts +14 -2
- package/src/refract/coreRenderer.ts +152 -104
- package/src/refract/features/hooks.ts +4 -0
- package/src/refract/hooksRuntime.ts +19 -0
- package/src/refract/types.ts +1 -0
- package/tests/hooks.test.ts +83 -0
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|

|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[Info](https://refract.ipx.dev) | [Docs](https://refract.ipx.dev/docs)
|
|
4
|
+
|
|
5
|
+
# Refract
|
|
4
6
|
|
|
5
7
|
A minimal React-like virtual DOM library, written in TypeScript with split entrypoints
|
|
6
8
|
so you can keep bundles small and targeted.
|
|
@@ -94,9 +96,13 @@ Supported compat APIs include:
|
|
|
94
96
|
- `forwardRef`, `cloneElement`, `Children`, `isValidElement`
|
|
95
97
|
- `useLayoutEffect`, `useInsertionEffect`, `useId`
|
|
96
98
|
- `useSyncExternalStore`, `useImperativeHandle`
|
|
99
|
+
- `use` — suspends on Promise (with a `promiseCache` WeakMap) or reads a context object
|
|
100
|
+
- `Suspense` — renders a fallback subtree while a child suspends
|
|
97
101
|
- `createPortal`
|
|
98
102
|
- `createRoot`, `flushSync`, `unstable_batchedUpdates`
|
|
103
|
+
- `findDOMNode` stub (returns `null`; prevents crashes from libraries like `react-transition-group` v4)
|
|
99
104
|
- `jsx/jsxs/jsxDEV` runtime entrypoints
|
|
105
|
+
- `$$typeof` stamped on every VNode (`Symbol.for("react.element")`) so `react-is` helpers work
|
|
100
106
|
- React hook dispatcher bridge internals (`__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE`) with optional `registerExternalReactModule(...)` for mixed-runtime environments (tests/Node)
|
|
101
107
|
|
|
102
108
|
Example Vite aliases:
|
|
@@ -121,10 +127,10 @@ export default defineConfig({
|
|
|
121
127
|
The compat layer is intentionally separate from core so users who do not need
|
|
122
128
|
React ecosystem compatibility keep the smallest and fastest Refract bundles.
|
|
123
129
|
|
|
124
|
-
Compatibility status (last verified February
|
|
125
|
-
- `yarn test`: 14 files passed,
|
|
130
|
+
Compatibility status (last verified February 22, 2026):
|
|
131
|
+
- `yarn test`: 14 files passed, 91 tests passed
|
|
126
132
|
- 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`,
|
|
133
|
+
- Verified behaviors include `forwardRef`, portals, `createRoot`, JSX runtimes, `useSyncExternalStore`, `flushSync`, react-router tree construction/dispatcher bridging, `React.use` (Promise + context), and Suspense boundary fallback rendering
|
|
128
134
|
|
|
129
135
|
## API
|
|
130
136
|
|
|
@@ -318,6 +324,7 @@ How Refract compares to React and Preact:
|
|
|
318
324
|
| useId | Yes | Yes | Yes |
|
|
319
325
|
| useSyncExternalStore | Yes | Yes | Yes |
|
|
320
326
|
| useImperativeHandle | Yes | Yes | Yes |
|
|
327
|
+
| use | Yes⁶ | Yes | No |
|
|
321
328
|
| useTransition / useDeferredValue | Partial⁵ | Yes | Partial⁷ |
|
|
322
329
|
| **State & Data Flow** | | | |
|
|
323
330
|
| Built-in state management | Yes | Yes | Yes |
|
|
@@ -330,7 +337,7 @@ How Refract compares to React and Preact:
|
|
|
330
337
|
| className prop | Yes | Yes | Yes¹ |
|
|
331
338
|
| dangerouslySetInnerHTML | Yes | Yes | Yes |
|
|
332
339
|
| Portals | Yes | Yes | Yes |
|
|
333
|
-
| Suspense / lazy |
|
|
340
|
+
| Suspense / lazy | Partial⁸ | Yes | Yes² |
|
|
334
341
|
| Error boundaries | Yes³ | Yes | Yes |
|
|
335
342
|
| Server-side rendering | No | Yes | Yes |
|
|
336
343
|
| Hydration | No⁹ | Yes | Yes |
|
|
@@ -354,7 +361,7 @@ How Refract compares to React and Preact:
|
|
|
354
361
|
⁵ Refract exposes `useTransition` / `useDeferredValue` but currently runs both synchronously (no concurrent scheduling).
|
|
355
362
|
⁶ Available via opt-in compat entrypoints (`refract/compat/react*`) with partial React API parity.
|
|
356
363
|
⁷ Preact compatibility is provided through `preact/compat`.
|
|
357
|
-
⁸ Compat
|
|
364
|
+
⁸ Compat `Suspense` renders a fallback while a child suspends (via `React.use` + `useState` boundary). `lazy` is exported but code-splitting semantics are limited.
|
|
358
365
|
⁹ `hydrateRoot` is exposed in compat, but currently performs client render rather than true SSR hydration.
|
|
359
366
|
¹⁰ `memo` is supported; `PureComponent` is compat-oriented and does not guarantee full React shallow-compare behavior.
|
|
360
367
|
|
package/package.json
CHANGED
|
@@ -16,6 +16,14 @@ export function unstable_batchedUpdates<T>(callback: () => T): T {
|
|
|
16
16
|
return callback();
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// findDOMNode was removed in React 19. react-transition-group v4 calls it
|
|
20
|
+
// when no nodeRef prop is provided. Returning null avoids a hard crash; any
|
|
21
|
+
// guarded usage (if node) degrades gracefully while unguarded usage (e.g.
|
|
22
|
+
// CSSTransition without nodeRef) will silently skip the animation.
|
|
23
|
+
export function findDOMNode(_instance: unknown): Element | null {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
export function flushSync<T>(callback: () => T): T {
|
|
20
28
|
const result = callback();
|
|
21
29
|
flushPendingRenders();
|
|
@@ -34,6 +42,7 @@ export function unmountComponentAtNode(container: HTMLElement): boolean {
|
|
|
34
42
|
|
|
35
43
|
const ReactDomCompat = {
|
|
36
44
|
createPortal,
|
|
45
|
+
findDOMNode,
|
|
37
46
|
flushSync,
|
|
38
47
|
render: renderCompat,
|
|
39
48
|
unstable_batchedUpdates,
|
|
@@ -2,6 +2,8 @@ import { createElement, Fragment } from "../createElement.js";
|
|
|
2
2
|
import type { VNode, VNodeType } from "../types.js";
|
|
3
3
|
import { resolveCompatType, getWrappedHandler } from "./react.js";
|
|
4
4
|
|
|
5
|
+
const REACT_ELEMENT_TYPE = Symbol.for("react.element");
|
|
6
|
+
|
|
5
7
|
type JsxProps = Record<string, unknown> | null | undefined;
|
|
6
8
|
type JsxChild = VNode | string | number | boolean | null | undefined | JsxChild[];
|
|
7
9
|
|
|
@@ -45,6 +47,7 @@ function createJsxElement(type: VNodeType, rawProps: JsxProps, key?: string): Re
|
|
|
45
47
|
} else {
|
|
46
48
|
vnode = createElement(effectiveType as VNodeType, props, children as JsxChild);
|
|
47
49
|
}
|
|
50
|
+
(vnode as any).$$typeof = REACT_ELEMENT_TYPE;
|
|
48
51
|
return normalizeVNodeChildren(vnode);
|
|
49
52
|
}
|
|
50
53
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { createElement as createElementImpl, Fragment } from "../createElement.js";
|
|
1
|
+
import { createElement as createElementImpl, Fragment as InternalFragment } from "../createElement.js";
|
|
2
2
|
import { memo } from "../memo.js";
|
|
3
3
|
import { createContext as createContextImpl, setContextValue } from "../features/context.js";
|
|
4
4
|
import {
|
|
5
5
|
createRef,
|
|
6
6
|
useErrorBoundary,
|
|
7
7
|
} from "../features/hooks.js";
|
|
8
|
+
import { currentFiber } from "../coreRenderer.js";
|
|
8
9
|
import type { Component as RefractComponent, Props, VNode } from "../types.js";
|
|
9
10
|
import {
|
|
10
11
|
__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,
|
|
@@ -41,6 +42,9 @@ export function forwardRef<T, P extends Record<string, unknown> = Record<string,
|
|
|
41
42
|
return render(rest as unknown as P, ref ?? null);
|
|
42
43
|
};
|
|
43
44
|
(ForwardRefComponent as any).displayName = `ForwardRef(${(render as any).name || 'anonymous'})`;
|
|
45
|
+
// Stamp $$typeof so react-is.isForwardRef(element) works when this component
|
|
46
|
+
// is used as the VNode type.
|
|
47
|
+
(ForwardRefComponent as any).$$typeof = REACT_FORWARD_REF_TYPE;
|
|
44
48
|
return ForwardRefComponent;
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -87,11 +91,13 @@ function childrenToArray(children: unknown): unknown[] {
|
|
|
87
91
|
if (children === undefined || children === null) return [];
|
|
88
92
|
if (!Array.isArray(children)) return [children];
|
|
89
93
|
const out: unknown[] = [];
|
|
90
|
-
const stack = [
|
|
94
|
+
const stack: unknown[] = [children];
|
|
91
95
|
while (stack.length > 0) {
|
|
92
|
-
const child = stack.
|
|
96
|
+
const child = stack.pop();
|
|
93
97
|
if (Array.isArray(child)) {
|
|
94
|
-
|
|
98
|
+
for (let i = child.length - 1; i >= 0; i--) {
|
|
99
|
+
stack.push(child[i]);
|
|
100
|
+
}
|
|
95
101
|
continue;
|
|
96
102
|
}
|
|
97
103
|
if (child === undefined || child === null || typeof child === "boolean") {
|
|
@@ -269,7 +275,54 @@ PureComponent.prototype.isPureReactComponent = true;
|
|
|
269
275
|
// Suspense / Lazy
|
|
270
276
|
// ---------------------------------------------------------------------------
|
|
271
277
|
|
|
278
|
+
// Cache for promises passed to use() — maps a Promise to its settled result
|
|
279
|
+
// so subsequent renders can return the value instead of throwing again.
|
|
280
|
+
type PromiseResult<T> =
|
|
281
|
+
| { status: 'pending' }
|
|
282
|
+
| { status: 'fulfilled'; value: T }
|
|
283
|
+
| { status: 'rejected'; reason: unknown };
|
|
284
|
+
|
|
285
|
+
const promiseCache = new WeakMap<Promise<unknown>, PromiseResult<unknown>>();
|
|
286
|
+
|
|
287
|
+
export function use<T>(value: Promise<T> | unknown): T {
|
|
288
|
+
if (value instanceof Promise) {
|
|
289
|
+
let result = promiseCache.get(value as Promise<unknown>) as PromiseResult<T> | undefined;
|
|
290
|
+
if (!result) {
|
|
291
|
+
result = { status: 'pending' };
|
|
292
|
+
promiseCache.set(value as Promise<unknown>, result as PromiseResult<unknown>);
|
|
293
|
+
(value as Promise<T>).then(
|
|
294
|
+
(v) => { (result as any).status = 'fulfilled'; (result as any).value = v; },
|
|
295
|
+
(e) => { (result as any).status = 'rejected'; (result as any).reason = e; },
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (result.status === 'fulfilled') return (result as { status: 'fulfilled'; value: T }).value;
|
|
299
|
+
if (result.status === 'rejected') throw (result as { status: 'rejected'; reason: unknown }).reason;
|
|
300
|
+
throw value; // pending — triggers Suspense boundary
|
|
301
|
+
}
|
|
302
|
+
// Treat non-Promise values as context objects (React 19 use(Context) pattern).
|
|
303
|
+
return useContext(value as unknown) as T;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Suspense is a real component so it can use useState to toggle between the
|
|
307
|
+
// fallback and live children, and so the renderer can attach a
|
|
308
|
+
// _suspenseHandler to its fiber for thrown-promise recovery.
|
|
272
309
|
export function Suspense(props: { children?: unknown; fallback?: unknown }): unknown {
|
|
310
|
+
const [suspended, setSuspended] = useState(false);
|
|
311
|
+
|
|
312
|
+
// Register the promise handler on this render's fiber so that any
|
|
313
|
+
// descendant that throws a Promise can find and invoke it.
|
|
314
|
+
const fiber = currentFiber;
|
|
315
|
+
if (fiber) {
|
|
316
|
+
fiber._suspenseHandler = (promise: Promise<unknown>) => {
|
|
317
|
+
setSuspended(true);
|
|
318
|
+
promise.then(
|
|
319
|
+
() => setSuspended(false),
|
|
320
|
+
() => setSuspended(false),
|
|
321
|
+
);
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (suspended) return props.fallback ?? null;
|
|
273
326
|
return props.children ?? null;
|
|
274
327
|
}
|
|
275
328
|
|
|
@@ -421,6 +474,8 @@ export function resolveCompatType(type: unknown): unknown {
|
|
|
421
474
|
};
|
|
422
475
|
(wrappedForwardRef as any).displayName = exoticType.displayName
|
|
423
476
|
?? `ForwardRef(${(render as any).name || "anonymous"})`;
|
|
477
|
+
// Preserve $$typeof on the wrapper so react-is.isForwardRef(element) works.
|
|
478
|
+
(wrappedForwardRef as any).$$typeof = REACT_FORWARD_REF_TYPE;
|
|
424
479
|
exoticTypeCache.set(type as object, wrappedForwardRef);
|
|
425
480
|
return wrappedForwardRef;
|
|
426
481
|
}
|
|
@@ -451,7 +506,7 @@ export function resolveCompatType(type: unknown): unknown {
|
|
|
451
506
|
if (Array.isArray(children)) {
|
|
452
507
|
return children.length === 1
|
|
453
508
|
? children[0] as VNode
|
|
454
|
-
: createElementImpl(
|
|
509
|
+
: createElementImpl(InternalFragment, null, ...(children as ElementChild[]));
|
|
455
510
|
}
|
|
456
511
|
return children as VNode;
|
|
457
512
|
};
|
|
@@ -533,6 +588,7 @@ function normalizeChildrenSingle(vnode: any): any {
|
|
|
533
588
|
export function createElement(type: unknown, props?: unknown, ...children: unknown[]): VNode {
|
|
534
589
|
const effectiveType = resolveCompatType(type);
|
|
535
590
|
|
|
591
|
+
let vnode: VNode;
|
|
536
592
|
if (typeof type === 'string' && props && typeof props === 'object') {
|
|
537
593
|
const newProps = { ...props } as Record<string, unknown>;
|
|
538
594
|
let hasChanges = false;
|
|
@@ -543,18 +599,29 @@ export function createElement(type: unknown, props?: unknown, ...children: unkno
|
|
|
543
599
|
}
|
|
544
600
|
}
|
|
545
601
|
if (hasChanges) {
|
|
546
|
-
|
|
602
|
+
vnode = normalizeChildrenSingle(createElementImpl(effectiveType as any, newProps, ...(children as any[])));
|
|
603
|
+
} else {
|
|
604
|
+
vnode = normalizeChildrenSingle(createElementImpl(effectiveType as any, props as any, ...(children as any[])));
|
|
547
605
|
}
|
|
606
|
+
} else {
|
|
607
|
+
vnode = normalizeChildrenSingle(createElementImpl(effectiveType as any, props as any, ...(children as any[])));
|
|
548
608
|
}
|
|
549
609
|
|
|
550
|
-
|
|
610
|
+
// Stamp $$typeof so react-is.isValidElement / isFragment / isForwardRef etc.
|
|
611
|
+
// work correctly on VNodes produced by the compat layer.
|
|
612
|
+
(vnode as any).$$typeof = REACT_ELEMENT_TYPE;
|
|
613
|
+
return vnode;
|
|
551
614
|
}
|
|
552
615
|
|
|
553
616
|
// ---------------------------------------------------------------------------
|
|
554
617
|
// Export
|
|
555
618
|
// ---------------------------------------------------------------------------
|
|
556
619
|
|
|
557
|
-
|
|
620
|
+
// Export the standard React fragment symbol so react-is.isFragment() works
|
|
621
|
+
// correctly. The renderer recognises both this and the internal refract.fragment
|
|
622
|
+
// symbol (see coreRenderer.ts REACT_FRAGMENT_TYPE).
|
|
623
|
+
export const Fragment = Symbol.for("react.fragment");
|
|
624
|
+
export { memo };
|
|
558
625
|
export {
|
|
559
626
|
createRef,
|
|
560
627
|
registerExternalReactModule,
|
|
@@ -571,6 +638,7 @@ const ReactCompat = {
|
|
|
571
638
|
PureComponent,
|
|
572
639
|
Suspense,
|
|
573
640
|
lazy,
|
|
641
|
+
use,
|
|
574
642
|
createContext,
|
|
575
643
|
createElement,
|
|
576
644
|
cloneElement,
|
|
@@ -107,6 +107,7 @@ const secretInternals: ReactSecretInternalsCompat = {
|
|
|
107
107
|
const externalClientInternals = new Set<ReactClientInternalsCompat>();
|
|
108
108
|
const externalSecretInternals = new Set<ReactSecretInternalsCompat>();
|
|
109
109
|
const dispatcherStack: (RefractHookDispatcher | null)[] = [];
|
|
110
|
+
const MAX_EXTERNAL_INTERNALS = 8;
|
|
110
111
|
|
|
111
112
|
let runtimeInitialized = false;
|
|
112
113
|
|
|
@@ -135,7 +136,7 @@ export function registerExternalReactModule(moduleValue: unknown): void {
|
|
|
135
136
|
|
|
136
137
|
const candidateClient = moduleRecord.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
|
137
138
|
if (candidateClient && typeof candidateClient === "object" && "H" in (candidateClient as Record<string, unknown>)) {
|
|
138
|
-
externalClientInternals
|
|
139
|
+
addBoundedInternal(externalClientInternals, candidateClient as ReactClientInternalsCompat);
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
const candidateSecret = moduleRecord.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
|
|
@@ -147,12 +148,23 @@ export function registerExternalReactModule(moduleValue: unknown): void {
|
|
|
147
148
|
&& typeof dispatcherHolder.ReactCurrentDispatcher === "object"
|
|
148
149
|
&& "current" in (dispatcherHolder.ReactCurrentDispatcher as Record<string, unknown>)
|
|
149
150
|
) {
|
|
150
|
-
externalSecretInternals
|
|
151
|
+
addBoundedInternal(externalSecretInternals, candidateSecret as ReactSecretInternalsCompat);
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
syncDispatcherToExternal();
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
function addBoundedInternal<T>(set: Set<T>, value: T): void {
|
|
158
|
+
if (set.has(value)) return;
|
|
159
|
+
if (set.size >= MAX_EXTERNAL_INTERNALS) {
|
|
160
|
+
const oldest = set.values().next().value as T | undefined;
|
|
161
|
+
if (oldest !== undefined) {
|
|
162
|
+
set.delete(oldest);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
set.add(value);
|
|
166
|
+
}
|
|
167
|
+
|
|
156
168
|
function beforeComponentRender(): void {
|
|
157
169
|
dispatcherStack.push(clientInternals.H);
|
|
158
170
|
setDispatcher(dispatcher);
|
|
@@ -54,8 +54,12 @@ export function renderFiber(vnode: VNode, container: Node): void {
|
|
|
54
54
|
};
|
|
55
55
|
deletions = [];
|
|
56
56
|
isRendering = true;
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
try {
|
|
58
|
+
performWork(rootFiber);
|
|
59
|
+
} finally {
|
|
60
|
+
isRendering = false;
|
|
61
|
+
currentFiber = null;
|
|
62
|
+
}
|
|
59
63
|
const committedDeletions = deletions.slice();
|
|
60
64
|
commitRoot(rootFiber);
|
|
61
65
|
clearAlternates(rootFiber);
|
|
@@ -90,6 +94,7 @@ function processWorkUnit(fiber: Fiber): boolean {
|
|
|
90
94
|
if (!tryHandleRenderError(fiber, error)) throw error;
|
|
91
95
|
} finally {
|
|
92
96
|
runAfterComponentRenderHandlers(fiber);
|
|
97
|
+
currentFiber = null;
|
|
93
98
|
}
|
|
94
99
|
} else if (isFragment) {
|
|
95
100
|
reconcileChildren(fiber, normalizeChildrenProp(fiber.props.children));
|
|
@@ -208,7 +213,7 @@ function flattenRenderedChildren(raw: RenderedChild[]): VNode[] {
|
|
|
208
213
|
continue;
|
|
209
214
|
}
|
|
210
215
|
if (typeof child === "object") {
|
|
211
|
-
const record = child as Record<string, unknown>;
|
|
216
|
+
const record = child as unknown as Record<string, unknown>;
|
|
212
217
|
if ("type" in record && "props" in record) {
|
|
213
218
|
const rawKey = record.key;
|
|
214
219
|
result.push({
|
|
@@ -254,33 +259,57 @@ function getNextDomSibling(fiber: Fiber): Node | null {
|
|
|
254
259
|
/** Collect all DOM nodes from a component/fragment fiber's subtree */
|
|
255
260
|
function collectChildDomNodes(fiber: Fiber): Node[] {
|
|
256
261
|
const nodes: Node[] = [];
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
262
|
+
const stack: Fiber[] = [];
|
|
263
|
+
const rootChildren: Fiber[] = [];
|
|
264
|
+
let child = fiber.child;
|
|
265
|
+
while (child) {
|
|
266
|
+
rootChildren.push(child);
|
|
267
|
+
child = child.sibling;
|
|
268
|
+
}
|
|
269
|
+
for (let i = rootChildren.length - 1; i >= 0; i--) {
|
|
270
|
+
stack.push(rootChildren[i]);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
while (stack.length > 0) {
|
|
274
|
+
const current = stack.pop()!;
|
|
275
|
+
if (isPortalFiber(current)) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (current.dom) {
|
|
279
|
+
nodes.push(current.dom);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const children: Fiber[] = [];
|
|
283
|
+
let next = current.child;
|
|
284
|
+
while (next) {
|
|
285
|
+
children.push(next);
|
|
286
|
+
next = next.sibling;
|
|
287
|
+
}
|
|
288
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
289
|
+
stack.push(children[i]);
|
|
269
290
|
}
|
|
270
291
|
}
|
|
271
|
-
walk(fiber.child);
|
|
272
292
|
return nodes;
|
|
273
293
|
}
|
|
274
294
|
|
|
275
295
|
/** Get the first committed DOM node in a fiber subtree */
|
|
276
296
|
function getFirstCommittedDom(fiber: Fiber): Node | null {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
297
|
+
const stack: Fiber[] = [fiber];
|
|
298
|
+
while (stack.length > 0) {
|
|
299
|
+
const current = stack.pop()!;
|
|
300
|
+
if (isPortalFiber(current)) continue;
|
|
301
|
+
if (current.dom && !(current.flags & PLACEMENT)) {
|
|
302
|
+
return current.dom;
|
|
303
|
+
}
|
|
304
|
+
const children: Fiber[] = [];
|
|
305
|
+
let child = current.child;
|
|
306
|
+
while (child) {
|
|
307
|
+
children.push(child);
|
|
308
|
+
child = child.sibling;
|
|
309
|
+
}
|
|
310
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
311
|
+
stack.push(children[i]);
|
|
312
|
+
}
|
|
284
313
|
}
|
|
285
314
|
return null;
|
|
286
315
|
}
|
|
@@ -296,69 +325,74 @@ function commitRoot(rootFiber: Fiber): void {
|
|
|
296
325
|
}
|
|
297
326
|
|
|
298
327
|
function commitWork(fiber: Fiber): void {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
|
|
328
|
+
const stack: Fiber[] = [fiber];
|
|
329
|
+
while (stack.length > 0) {
|
|
330
|
+
const current = stack.pop()!;
|
|
331
|
+
if (isPortalFiber(current)) {
|
|
332
|
+
current.flags = 0;
|
|
333
|
+
if (current.sibling) stack.push(current.sibling);
|
|
334
|
+
if (current.child) stack.push(current.child);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
305
337
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
// Component/fragment: move all child DOM nodes
|
|
322
|
-
const domNodes = collectChildDomNodes(fiber);
|
|
323
|
-
const before = getNextDomSibling(fiber);
|
|
324
|
-
for (const dom of domNodes) {
|
|
325
|
-
if (before) {
|
|
326
|
-
parentDom.insertBefore(dom, before);
|
|
338
|
+
let parentFiber = current.parent;
|
|
339
|
+
while (parentFiber && !parentFiber.dom) {
|
|
340
|
+
parentFiber = parentFiber.parent;
|
|
341
|
+
}
|
|
342
|
+
const parentDom = parentFiber?.dom;
|
|
343
|
+
|
|
344
|
+
if (parentDom) {
|
|
345
|
+
if (current.flags & PLACEMENT) {
|
|
346
|
+
if (current.dom) {
|
|
347
|
+
const before = getNextDomSibling(current);
|
|
348
|
+
if (before) {
|
|
349
|
+
parentDom.insertBefore(current.dom, before);
|
|
350
|
+
} else {
|
|
351
|
+
parentDom.appendChild(current.dom);
|
|
352
|
+
}
|
|
327
353
|
} else {
|
|
328
|
-
|
|
354
|
+
// Component/fragment: move all child DOM nodes
|
|
355
|
+
const domNodes = collectChildDomNodes(current);
|
|
356
|
+
const before = getNextDomSibling(current);
|
|
357
|
+
for (const dom of domNodes) {
|
|
358
|
+
if (before) {
|
|
359
|
+
parentDom.insertBefore(dom, before);
|
|
360
|
+
} else {
|
|
361
|
+
parentDom.appendChild(dom);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
} else if (current.flags & UPDATE && current.dom) {
|
|
366
|
+
if (current.type === "TEXT") {
|
|
367
|
+
const oldValue = current.alternate?.props.nodeValue;
|
|
368
|
+
if (oldValue !== current.props.nodeValue) {
|
|
369
|
+
current.dom.textContent = current.props.nodeValue as string;
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
applyProps(
|
|
373
|
+
current.dom as HTMLElement,
|
|
374
|
+
current.alternate?.props ?? {},
|
|
375
|
+
current.props,
|
|
376
|
+
);
|
|
329
377
|
}
|
|
330
378
|
}
|
|
331
379
|
}
|
|
332
|
-
} else if (fiber.flags & UPDATE && fiber.dom) {
|
|
333
|
-
if (fiber.type === "TEXT") {
|
|
334
|
-
const oldValue = fiber.alternate?.props.nodeValue;
|
|
335
|
-
if (oldValue !== fiber.props.nodeValue) {
|
|
336
|
-
fiber.dom.textContent = fiber.props.nodeValue as string;
|
|
337
|
-
}
|
|
338
|
-
} else {
|
|
339
|
-
applyProps(
|
|
340
|
-
fiber.dom as HTMLElement,
|
|
341
|
-
fiber.alternate?.props ?? {},
|
|
342
|
-
fiber.props,
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
380
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
381
|
+
// Handle ref prop — only on mount or when ref changes (like React)
|
|
382
|
+
if (current.dom && current.props.ref) {
|
|
383
|
+
const oldRef = current.alternate?.props.ref;
|
|
384
|
+
if (current.flags & PLACEMENT || current.props.ref !== oldRef) {
|
|
385
|
+
if (oldRef && oldRef !== current.props.ref) {
|
|
386
|
+
setRef(oldRef, null);
|
|
387
|
+
}
|
|
388
|
+
setRef(current.props.ref, current.dom);
|
|
353
389
|
}
|
|
354
|
-
setRef(fiber.props.ref, fiber.dom);
|
|
355
390
|
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
fiber.flags = 0;
|
|
359
391
|
|
|
360
|
-
|
|
361
|
-
|
|
392
|
+
current.flags = 0;
|
|
393
|
+
if (current.sibling) stack.push(current.sibling);
|
|
394
|
+
if (current.child) stack.push(current.child);
|
|
395
|
+
}
|
|
362
396
|
}
|
|
363
397
|
|
|
364
398
|
function setRef(ref: unknown, value: Node | null): void {
|
|
@@ -373,41 +407,51 @@ function setRef(ref: unknown, value: Node | null): void {
|
|
|
373
407
|
* Alternates are only needed during reconciliation; retaining them
|
|
374
408
|
* creates an ever-growing chain of old fiber trees. */
|
|
375
409
|
function clearAlternates(fiber: Fiber | null): void {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
410
|
+
if (!fiber) return;
|
|
411
|
+
const stack: Fiber[] = [fiber];
|
|
412
|
+
while (stack.length > 0) {
|
|
413
|
+
const current = stack.pop()!;
|
|
414
|
+
current.alternate = null;
|
|
415
|
+
if (current.sibling) stack.push(current.sibling);
|
|
416
|
+
if (current.child) stack.push(current.child);
|
|
380
417
|
}
|
|
381
418
|
}
|
|
382
419
|
|
|
383
420
|
function commitDeletion(fiber: Fiber): void {
|
|
384
421
|
runCleanups(fiber);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
if (isPortalFiber(fiber)) {
|
|
390
|
-
let child: Fiber | null = fiber.child;
|
|
391
|
-
while (child) {
|
|
392
|
-
commitDeletion(child);
|
|
393
|
-
child = child.sibling;
|
|
422
|
+
walkSubtree(fiber, (node) => {
|
|
423
|
+
if (node.dom && node.props.ref) {
|
|
424
|
+
setRef(node.props.ref, null);
|
|
394
425
|
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
let child: Fiber | null = fiber.child;
|
|
400
|
-
while (child) {
|
|
401
|
-
commitDeletion(child);
|
|
402
|
-
child = child.sibling;
|
|
426
|
+
});
|
|
427
|
+
walkSubtree(fiber, (node) => {
|
|
428
|
+
if (!isPortalFiber(node) && node.dom) {
|
|
429
|
+
node.dom.parentNode?.removeChild(node.dom);
|
|
403
430
|
}
|
|
404
|
-
}
|
|
431
|
+
});
|
|
405
432
|
}
|
|
406
433
|
|
|
407
434
|
function runCleanups(fiber: Fiber): void {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
435
|
+
walkSubtree(fiber, (node) => {
|
|
436
|
+
runFiberCleanupHandlers(node);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function walkSubtree(root: Fiber, visit: (fiber: Fiber) => void): void {
|
|
441
|
+
const stack: Fiber[] = [root];
|
|
442
|
+
while (stack.length > 0) {
|
|
443
|
+
const current = stack.pop()!;
|
|
444
|
+
visit(current);
|
|
445
|
+
const children: Fiber[] = [];
|
|
446
|
+
let child = current.child;
|
|
447
|
+
while (child) {
|
|
448
|
+
children.push(child);
|
|
449
|
+
child = child.sibling;
|
|
450
|
+
}
|
|
451
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
452
|
+
stack.push(children[i]);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
411
455
|
}
|
|
412
456
|
|
|
413
457
|
const pendingContainers = new Set<Node>();
|
|
@@ -473,8 +517,12 @@ function flushRenders(): void {
|
|
|
473
517
|
};
|
|
474
518
|
deletions = [];
|
|
475
519
|
isRendering = true;
|
|
476
|
-
|
|
477
|
-
|
|
520
|
+
try {
|
|
521
|
+
performWork(newRoot);
|
|
522
|
+
} finally {
|
|
523
|
+
isRendering = false;
|
|
524
|
+
currentFiber = null;
|
|
525
|
+
}
|
|
478
526
|
const committedDeletions = deletions.slice();
|
|
479
527
|
commitRoot(newRoot);
|
|
480
528
|
clearAlternates(newRoot);
|
|
@@ -36,9 +36,11 @@ export function useState<T>(initial: T | (() => T)): [T, (value: T | ((prev: T)
|
|
|
36
36
|
// Create a stable setter that is reused across renders (like React)
|
|
37
37
|
if (!hook._setter) {
|
|
38
38
|
hook._setter = (value: T | ((prev: T) => T)) => {
|
|
39
|
+
if (!hook._fiber) return;
|
|
39
40
|
const action = typeof value === "function"
|
|
40
41
|
? value as (prev: T) => T
|
|
41
42
|
: () => value;
|
|
43
|
+
if (!hook.queue) hook.queue = [];
|
|
42
44
|
(hook.queue as ((prev: T) => T)[]).push(action);
|
|
43
45
|
scheduleRender(hook._fiber!);
|
|
44
46
|
};
|
|
@@ -191,6 +193,8 @@ export function useReducer<S, A, I>(
|
|
|
191
193
|
// Create stable dispatch (like React)
|
|
192
194
|
if (!hook._dispatch) {
|
|
193
195
|
hook._dispatch = (action: A) => {
|
|
196
|
+
if (!hook._fiber) return;
|
|
197
|
+
if (!hook.queue) hook.queue = [];
|
|
194
198
|
(hook.queue as A[]).push(action);
|
|
195
199
|
scheduleRender(hook._fiber!);
|
|
196
200
|
};
|
|
@@ -33,6 +33,8 @@ function cleanupFiberEffects(fiber: Fiber): void {
|
|
|
33
33
|
if (!fiber.hooks) return;
|
|
34
34
|
|
|
35
35
|
for (const hook of fiber.hooks) {
|
|
36
|
+
hook.queue = undefined;
|
|
37
|
+
hook._fiber = undefined;
|
|
36
38
|
const state = hook.state;
|
|
37
39
|
if (!state || typeof state !== "object") continue;
|
|
38
40
|
const effectState = state as { cleanup?: () => void; pending?: boolean };
|
|
@@ -109,7 +111,24 @@ function handleErrorBoundary(fiber: Fiber, error: unknown): boolean {
|
|
|
109
111
|
return false;
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
function handleSuspenseBoundary(fiber: Fiber, error: unknown): boolean {
|
|
115
|
+
if (!(error instanceof Promise)) return false;
|
|
116
|
+
let current: Fiber | null = fiber.parent;
|
|
117
|
+
while (current) {
|
|
118
|
+
if (current._suspenseHandler) {
|
|
119
|
+
current._suspenseHandler(error as Promise<unknown>);
|
|
120
|
+
reconcileChildren(fiber, []);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
current = current.parent;
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
112
128
|
registerFiberCleanupHandler(cleanupFiberEffects);
|
|
113
129
|
registerAfterCommitHandler(runPendingEffects);
|
|
114
130
|
registerBeforeRenderBatchHandler(flushPassiveEffects);
|
|
131
|
+
// Suspense must be registered before the error boundary so a thrown Promise
|
|
132
|
+
// is caught by the nearest Suspense boundary before reaching any error boundary.
|
|
133
|
+
registerRenderErrorHandler(handleSuspenseBoundary);
|
|
115
134
|
registerRenderErrorHandler(handleErrorBoundary);
|
package/src/refract/types.ts
CHANGED
package/tests/hooks.test.ts
CHANGED
|
@@ -71,6 +71,25 @@ describe("hooks", () => {
|
|
|
71
71
|
expect(renderCount).toBe(2);
|
|
72
72
|
expect(container.querySelector("span")!.textContent).toBe("3");
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
it("ignores setState after unmount", async () => {
|
|
76
|
+
let setCount!: (v: number | ((p: number) => number)) => void;
|
|
77
|
+
let renders = 0;
|
|
78
|
+
function Counter() {
|
|
79
|
+
const [count, sc] = useState(0);
|
|
80
|
+
setCount = sc;
|
|
81
|
+
renders++;
|
|
82
|
+
return createElement("span", null, String(count));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
render(createElement(Counter, null), container);
|
|
86
|
+
render(createElement("div", null, "gone"), container);
|
|
87
|
+
|
|
88
|
+
expect(() => setCount(1)).not.toThrow();
|
|
89
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
90
|
+
expect(container.textContent).toBe("gone");
|
|
91
|
+
expect(renders).toBe(1);
|
|
92
|
+
});
|
|
74
93
|
});
|
|
75
94
|
|
|
76
95
|
describe("useEffect", () => {
|
|
@@ -269,5 +288,69 @@ describe("hooks", () => {
|
|
|
269
288
|
await new Promise((r) => setTimeout(r, 10));
|
|
270
289
|
expect(container.querySelector("span")!.textContent).toBe("2");
|
|
271
290
|
});
|
|
291
|
+
|
|
292
|
+
it("ignores dispatch after unmount", async () => {
|
|
293
|
+
type Action = { type: "inc" };
|
|
294
|
+
let dispatch!: (action: Action) => void;
|
|
295
|
+
let renders = 0;
|
|
296
|
+
function Counter() {
|
|
297
|
+
const [count, d] = useReducer((state: number, action: Action) => {
|
|
298
|
+
if (action.type === "inc") return state + 1;
|
|
299
|
+
return state;
|
|
300
|
+
}, 0);
|
|
301
|
+
dispatch = d;
|
|
302
|
+
renders++;
|
|
303
|
+
return createElement("span", null, String(count));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
render(createElement(Counter, null), container);
|
|
307
|
+
render(createElement("div", null, "gone"), container);
|
|
308
|
+
|
|
309
|
+
expect(() => dispatch({ type: "inc" })).not.toThrow();
|
|
310
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
311
|
+
expect(container.textContent).toBe("gone");
|
|
312
|
+
expect(renders).toBe(1);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("cleanup scoping", () => {
|
|
317
|
+
it("only cleans up deleted subtree effects", async () => {
|
|
318
|
+
const leftCleanup = vi.fn();
|
|
319
|
+
const rightCleanup = vi.fn();
|
|
320
|
+
let setShowLeft!: (v: boolean) => void;
|
|
321
|
+
|
|
322
|
+
function Left() {
|
|
323
|
+
useEffect(() => leftCleanup, []);
|
|
324
|
+
return createElement("span", null, "left");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function Right() {
|
|
328
|
+
useEffect(() => rightCleanup, []);
|
|
329
|
+
return createElement("span", null, "right");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function App() {
|
|
333
|
+
const [showLeft, ss] = useState(true);
|
|
334
|
+
setShowLeft = ss;
|
|
335
|
+
return createElement(
|
|
336
|
+
"div",
|
|
337
|
+
null,
|
|
338
|
+
showLeft ? createElement(Left, { key: "left" }) : null,
|
|
339
|
+
createElement(Right, { key: "right" }),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
render(createElement(App, null), container);
|
|
344
|
+
flushPassiveEffects();
|
|
345
|
+
expect(leftCleanup).toHaveBeenCalledTimes(0);
|
|
346
|
+
expect(rightCleanup).toHaveBeenCalledTimes(0);
|
|
347
|
+
|
|
348
|
+
setShowLeft(false);
|
|
349
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
350
|
+
flushPassiveEffects();
|
|
351
|
+
|
|
352
|
+
expect(leftCleanup).toHaveBeenCalledTimes(1);
|
|
353
|
+
expect(rightCleanup).toHaveBeenCalledTimes(0);
|
|
354
|
+
});
|
|
272
355
|
});
|
|
273
356
|
});
|