@ipxjs/refract 0.11.0 → 0.12.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 +71 -5
- package/src/refract/coreRenderer.ts +1 -1
- package/src/refract/hooksRuntime.ts +17 -0
- package/src/refract/types.ts +1 -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
|
|
|
@@ -269,7 +273,54 @@ PureComponent.prototype.isPureReactComponent = true;
|
|
|
269
273
|
// Suspense / Lazy
|
|
270
274
|
// ---------------------------------------------------------------------------
|
|
271
275
|
|
|
276
|
+
// Cache for promises passed to use() — maps a Promise to its settled result
|
|
277
|
+
// so subsequent renders can return the value instead of throwing again.
|
|
278
|
+
type PromiseResult<T> =
|
|
279
|
+
| { status: 'pending' }
|
|
280
|
+
| { status: 'fulfilled'; value: T }
|
|
281
|
+
| { status: 'rejected'; reason: unknown };
|
|
282
|
+
|
|
283
|
+
const promiseCache = new WeakMap<Promise<unknown>, PromiseResult<unknown>>();
|
|
284
|
+
|
|
285
|
+
export function use<T>(value: Promise<T> | unknown): T {
|
|
286
|
+
if (value instanceof Promise) {
|
|
287
|
+
let result = promiseCache.get(value as Promise<unknown>) as PromiseResult<T> | undefined;
|
|
288
|
+
if (!result) {
|
|
289
|
+
result = { status: 'pending' };
|
|
290
|
+
promiseCache.set(value as Promise<unknown>, result as PromiseResult<unknown>);
|
|
291
|
+
(value as Promise<T>).then(
|
|
292
|
+
(v) => { (result as any).status = 'fulfilled'; (result as any).value = v; },
|
|
293
|
+
(e) => { (result as any).status = 'rejected'; (result as any).reason = e; },
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (result.status === 'fulfilled') return (result as { status: 'fulfilled'; value: T }).value;
|
|
297
|
+
if (result.status === 'rejected') throw (result as { status: 'rejected'; reason: unknown }).reason;
|
|
298
|
+
throw value; // pending — triggers Suspense boundary
|
|
299
|
+
}
|
|
300
|
+
// Treat non-Promise values as context objects (React 19 use(Context) pattern).
|
|
301
|
+
return useContext(value as unknown) as T;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Suspense is a real component so it can use useState to toggle between the
|
|
305
|
+
// fallback and live children, and so the renderer can attach a
|
|
306
|
+
// _suspenseHandler to its fiber for thrown-promise recovery.
|
|
272
307
|
export function Suspense(props: { children?: unknown; fallback?: unknown }): unknown {
|
|
308
|
+
const [suspended, setSuspended] = useState(false);
|
|
309
|
+
|
|
310
|
+
// Register the promise handler on this render's fiber so that any
|
|
311
|
+
// descendant that throws a Promise can find and invoke it.
|
|
312
|
+
const fiber = currentFiber;
|
|
313
|
+
if (fiber) {
|
|
314
|
+
fiber._suspenseHandler = (promise: Promise<unknown>) => {
|
|
315
|
+
setSuspended(true);
|
|
316
|
+
promise.then(
|
|
317
|
+
() => setSuspended(false),
|
|
318
|
+
() => setSuspended(false),
|
|
319
|
+
);
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (suspended) return props.fallback ?? null;
|
|
273
324
|
return props.children ?? null;
|
|
274
325
|
}
|
|
275
326
|
|
|
@@ -421,6 +472,8 @@ export function resolveCompatType(type: unknown): unknown {
|
|
|
421
472
|
};
|
|
422
473
|
(wrappedForwardRef as any).displayName = exoticType.displayName
|
|
423
474
|
?? `ForwardRef(${(render as any).name || "anonymous"})`;
|
|
475
|
+
// Preserve $$typeof on the wrapper so react-is.isForwardRef(element) works.
|
|
476
|
+
(wrappedForwardRef as any).$$typeof = REACT_FORWARD_REF_TYPE;
|
|
424
477
|
exoticTypeCache.set(type as object, wrappedForwardRef);
|
|
425
478
|
return wrappedForwardRef;
|
|
426
479
|
}
|
|
@@ -451,7 +504,7 @@ export function resolveCompatType(type: unknown): unknown {
|
|
|
451
504
|
if (Array.isArray(children)) {
|
|
452
505
|
return children.length === 1
|
|
453
506
|
? children[0] as VNode
|
|
454
|
-
: createElementImpl(
|
|
507
|
+
: createElementImpl(InternalFragment, null, ...(children as ElementChild[]));
|
|
455
508
|
}
|
|
456
509
|
return children as VNode;
|
|
457
510
|
};
|
|
@@ -533,6 +586,7 @@ function normalizeChildrenSingle(vnode: any): any {
|
|
|
533
586
|
export function createElement(type: unknown, props?: unknown, ...children: unknown[]): VNode {
|
|
534
587
|
const effectiveType = resolveCompatType(type);
|
|
535
588
|
|
|
589
|
+
let vnode: VNode;
|
|
536
590
|
if (typeof type === 'string' && props && typeof props === 'object') {
|
|
537
591
|
const newProps = { ...props } as Record<string, unknown>;
|
|
538
592
|
let hasChanges = false;
|
|
@@ -543,18 +597,29 @@ export function createElement(type: unknown, props?: unknown, ...children: unkno
|
|
|
543
597
|
}
|
|
544
598
|
}
|
|
545
599
|
if (hasChanges) {
|
|
546
|
-
|
|
600
|
+
vnode = normalizeChildrenSingle(createElementImpl(effectiveType as any, newProps, ...(children as any[])));
|
|
601
|
+
} else {
|
|
602
|
+
vnode = normalizeChildrenSingle(createElementImpl(effectiveType as any, props as any, ...(children as any[])));
|
|
547
603
|
}
|
|
604
|
+
} else {
|
|
605
|
+
vnode = normalizeChildrenSingle(createElementImpl(effectiveType as any, props as any, ...(children as any[])));
|
|
548
606
|
}
|
|
549
607
|
|
|
550
|
-
|
|
608
|
+
// Stamp $$typeof so react-is.isValidElement / isFragment / isForwardRef etc.
|
|
609
|
+
// work correctly on VNodes produced by the compat layer.
|
|
610
|
+
(vnode as any).$$typeof = REACT_ELEMENT_TYPE;
|
|
611
|
+
return vnode;
|
|
551
612
|
}
|
|
552
613
|
|
|
553
614
|
// ---------------------------------------------------------------------------
|
|
554
615
|
// Export
|
|
555
616
|
// ---------------------------------------------------------------------------
|
|
556
617
|
|
|
557
|
-
|
|
618
|
+
// Export the standard React fragment symbol so react-is.isFragment() works
|
|
619
|
+
// correctly. The renderer recognises both this and the internal refract.fragment
|
|
620
|
+
// symbol (see coreRenderer.ts REACT_FRAGMENT_TYPE).
|
|
621
|
+
export const Fragment = Symbol.for("react.fragment");
|
|
622
|
+
export { memo };
|
|
558
623
|
export {
|
|
559
624
|
createRef,
|
|
560
625
|
registerExternalReactModule,
|
|
@@ -571,6 +636,7 @@ const ReactCompat = {
|
|
|
571
636
|
PureComponent,
|
|
572
637
|
Suspense,
|
|
573
638
|
lazy,
|
|
639
|
+
use,
|
|
574
640
|
createContext,
|
|
575
641
|
createElement,
|
|
576
642
|
cloneElement,
|
|
@@ -208,7 +208,7 @@ function flattenRenderedChildren(raw: RenderedChild[]): VNode[] {
|
|
|
208
208
|
continue;
|
|
209
209
|
}
|
|
210
210
|
if (typeof child === "object") {
|
|
211
|
-
const record = child as Record<string, unknown>;
|
|
211
|
+
const record = child as unknown as Record<string, unknown>;
|
|
212
212
|
if ("type" in record && "props" in record) {
|
|
213
213
|
const rawKey = record.key;
|
|
214
214
|
result.push({
|
|
@@ -109,7 +109,24 @@ function handleErrorBoundary(fiber: Fiber, error: unknown): boolean {
|
|
|
109
109
|
return false;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
function handleSuspenseBoundary(fiber: Fiber, error: unknown): boolean {
|
|
113
|
+
if (!(error instanceof Promise)) return false;
|
|
114
|
+
let current: Fiber | null = fiber.parent;
|
|
115
|
+
while (current) {
|
|
116
|
+
if (current._suspenseHandler) {
|
|
117
|
+
current._suspenseHandler(error as Promise<unknown>);
|
|
118
|
+
reconcileChildren(fiber, []);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
current = current.parent;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
112
126
|
registerFiberCleanupHandler(cleanupFiberEffects);
|
|
113
127
|
registerAfterCommitHandler(runPendingEffects);
|
|
114
128
|
registerBeforeRenderBatchHandler(flushPassiveEffects);
|
|
129
|
+
// Suspense must be registered before the error boundary so a thrown Promise
|
|
130
|
+
// is caught by the nearest Suspense boundary before reaching any error boundary.
|
|
131
|
+
registerRenderErrorHandler(handleSuspenseBoundary);
|
|
115
132
|
registerRenderErrorHandler(handleErrorBoundary);
|
package/src/refract/types.ts
CHANGED