@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 CHANGED
@@ -1,6 +1,8 @@
1
1
  ![](assets/lens-syntax-refract.svg "=x200")
2
2
 
3
- # Refract
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 17, 2026):
125
- - `yarn test`: 14 files passed, 85 tests 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`, and react-router tree construction/dispatcher bridging
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 | No | Yes | Yes² |
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 exports `Suspense`/`lazy`, but full suspension/fallback semantics are not implemented.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ipxjs/refract",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",
@@ -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(Fragment, null, ...(children as ElementChild[]));
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
- return normalizeChildrenSingle(createElementImpl(effectiveType as any, newProps, ...(children as any[])));
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
- return normalizeChildrenSingle(createElementImpl(effectiveType as any, props as any, ...(children as any[])));
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
- export { Fragment, memo };
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);
@@ -48,6 +48,7 @@ export interface Fiber {
48
48
  _contexts?: Map<number, unknown>;
49
49
  _objectContexts?: Map<object, unknown>;
50
50
  _errorHandler?: (error: unknown) => void;
51
+ _suspenseHandler?: (promise: Promise<unknown>) => void;
51
52
  alternate: Fiber | null;
52
53
  flags: number;
53
54
  }