@samitouri / QOS-React / commits / 9be531cd37

[Fiber] Treat unwrapping React.lazy more like a use() (#34031)

While we want to get rid of React.lazy's special wrapper type and just use a Promise for the type, we still have the wrapper. However, this is still conceptually the same as a Usable in that it should be have the same if you `use(promise)` or render a Promise as a child or type position. This PR makes it behave like a `use()` when we unwrap them. We could move to a model where it actually reaches the internal of the Lazy's Promise when it unwraps but for now I leave the lazy API signature intact by just catching the Promise and then "use()" that. This lets us align on the semantics with `use()` such as the suspense yield optimization. It also lets us warn or fork based on legacy throw-a-Promise behavior where as `React.lazy` is not deprecated.

Sebastian Markbåge committed Jul 29, 2025 at 11:50 UTC 9be531cd37f5558c72f7de360eb921b0074e8544
4 files changed +37 -77
packages/react-reconciler/src/ReactChildFiber.js
+6 -50
@@ -68,9 +68,9 @@ import {
68 SuspenseActionException,
69 createThenableState,
70 trackUsedThenable,
71 + resolveLazy,
72 } from './ReactFiberThenable';
73 import {readContextDuringReconciliation} from './ReactFiberNewContext';
73 -import {callLazyInitInDEV} from './ReactFiberCallUserSpace';
74
75 import {runWithFiberInDEV} from './ReactCurrentFiber';
76
@@ -364,15 +364,6 @@ function warnOnSymbolType(returnFiber: Fiber, invalidChild: symbol) {
364 }
365 }
366
367 -function resolveLazy(lazyType: any) {
368 - if (__DEV__) {
369 - return callLazyInitInDEV(lazyType);
370 - }
371 - const payload = lazyType._payload;
372 - const init = lazyType._init;
373 - return init(payload);
374 -}
375 -
367 type ChildReconciler = (
368 returnFiber: Fiber,
369 currentFirstChild: Fiber | null,
@@ -698,14 +689,7 @@ function createChildReconciler(
689 }
690 case REACT_LAZY_TYPE: {
691 const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
701 - let resolvedChild;
702 - if (__DEV__) {
703 - resolvedChild = callLazyInitInDEV(newChild);
704 - } else {
705 - const payload = newChild._payload;
706 - const init = newChild._init;
707 - resolvedChild = init(payload);
708 - }
692 + const resolvedChild = resolveLazy((newChild: any));
693 const created = createChild(returnFiber, resolvedChild, lanes);
694 currentDebugInfo = prevDebugInfo;
695 return created;
@@ -830,14 +814,7 @@ function createChildReconciler(
814 }
815 case REACT_LAZY_TYPE: {
816 const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
833 - let resolvedChild;
834 - if (__DEV__) {
835 - resolvedChild = callLazyInitInDEV(newChild);
836 - } else {
837 - const payload = newChild._payload;
838 - const init = newChild._init;
839 - resolvedChild = init(payload);
840 - }
817 + const resolvedChild = resolveLazy((newChild: any));
818 const updated = updateSlot(
819 returnFiber,
820 oldFiber,
@@ -962,14 +939,7 @@ function createChildReconciler(
939 }
940 case REACT_LAZY_TYPE: {
941 const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
965 - let resolvedChild;
966 - if (__DEV__) {
967 - resolvedChild = callLazyInitInDEV(newChild);
968 - } else {
969 - const payload = newChild._payload;
970 - const init = newChild._init;
971 - resolvedChild = init(payload);
972 - }
942 + const resolvedChild = resolveLazy((newChild: any));
943 const updated = updateFromMap(
944 existingChildren,
945 returnFiber,
@@ -1086,14 +1056,7 @@ function createChildReconciler(
1056 });
1057 break;
1058 case REACT_LAZY_TYPE: {
1089 - let resolvedChild;
1090 - if (__DEV__) {
1091 - resolvedChild = callLazyInitInDEV((child: any));
1092 - } else {
1093 - const payload = child._payload;
1094 - const init = (child._init: any);
1095 - resolvedChild = init(payload);
1096 - }
1059 + const resolvedChild = resolveLazy((child: any));
1060 warnOnInvalidKey(
1061 returnFiber,
1062 workInProgress,
@@ -1809,14 +1772,7 @@ function createChildReconciler(
1772 );
1773 case REACT_LAZY_TYPE: {
1774 const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
1812 - let result;
1813 - if (__DEV__) {
1814 - result = callLazyInitInDEV(newChild);
1815 - } else {
1816 - const payload = newChild._payload;
1817 - const init = newChild._init;
1818 - result = init(payload);
1819 - }
1775 + const result = resolveLazy((newChild: any));
1776 const firstChild = reconcileChildFibersImpl(
1777 returnFiber,
1778 currentFirstChild,
packages/react-reconciler/src/ReactFiberBeginWork.js
+3 -13
@@ -302,11 +302,8 @@ import {
302 pushRootMarkerInstance,
303 TransitionTracingMarker,
304 } from './ReactFiberTracingMarkerComponent';
305 -import {
306 - callLazyInitInDEV,
307 - callComponentInDEV,
308 - callRenderInDEV,
309 -} from './ReactFiberCallUserSpace';
305 +import {callComponentInDEV, callRenderInDEV} from './ReactFiberCallUserSpace';
306 +import {resolveLazy} from './ReactFiberThenable';
307
308 // A special exception that's used to unwind the stack when an update flows
309 // into a dehydrated boundary.
@@ -2020,14 +2017,7 @@ function mountLazyComponent(
2017
2018 const props = workInProgress.pendingProps;
2019 const lazyComponent: LazyComponentType<any, any> = elementType;
2023 - let Component;
2024 - if (__DEV__) {
2025 - Component = callLazyInitInDEV(lazyComponent);
2026 - } else {
2027 - const payload = lazyComponent._payload;
2028 - const init = lazyComponent._init;
2029 - Component = init(payload);
2030 - }
2020 + let Component = resolveLazy(lazyComponent);
2021 // Store the unwrapped component in the type.
2022 workInProgress.type = Component;
2023
packages/react-reconciler/src/ReactFiberThenable.js
+25
@@ -14,6 +14,10 @@ import type {
14 RejectedThenable,
15 } from 'shared/ReactTypes';
16
17 +import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
18 +
19 +import {callLazyInitInDEV} from './ReactFiberCallUserSpace';
20 +
21 import {getWorkInProgressRoot} from './ReactFiberWorkLoop';
22
23 import ReactSharedInternals from 'shared/ReactSharedInternals';
@@ -260,6 +264,27 @@ export function suspendCommit(): void {
264 throw SuspenseyCommitException;
265 }
266
267 +export function resolveLazy<T>(lazyType: LazyComponentType<T, any>): T {
268 + try {
269 + if (__DEV__) {
270 + return callLazyInitInDEV(lazyType);
271 + }
272 + const payload = lazyType._payload;
273 + const init = lazyType._init;
274 + return init(payload);
275 + } catch (x) {
276 + if (x !== null && typeof x === 'object' && typeof x.then === 'function') {
277 + // This lazy Suspended. Treat this as if we called use() to unwrap it.
278 + suspendedThenable = x;
279 + if (__DEV__) {
280 + needsToResetSuspendedThenableDEV = true;
281 + }
282 + throw SuspenseException;
283 + }
284 + throw x;
285 + }
286 +}
287 +
288 // This is used to track the actual thenable that suspended so it can be
289 // passed to the rest of the Suspense implementation — which, for historical
290 // reasons, expects to receive a thenable.
packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js
+3 -14
@@ -198,10 +198,7 @@ describe('ReactLazy', () => {
198
199 await resolveFakeImport(Foo);
200
201 - await waitForAll([
202 - 'Foo',
203 - ...(gate('alwaysThrottleRetries') ? [] : ['Foo']),
204 - ]);
201 + await waitForAll(['Foo']);
202 expect(root).not.toMatchRenderedOutput('FooBar');
203
204 await act(() => resolveFakeImport(Bar));
@@ -1329,11 +1326,7 @@ describe('ReactLazy', () => {
1326 expect(ref.current).toBe(null);
1327
1328 await act(() => resolveFakeImport(Foo));
1332 - assertLog([
1333 - 'Foo',
1334 - // pre-warming
1335 - 'Foo',
1336 - ]);
1329 + assertLog(['Foo']);
1330
1331 await act(() => resolveFakeImport(ForwardRefBar));
1332 assertLog(['Foo', 'forwardRef', 'Bar']);
@@ -1493,11 +1486,7 @@ describe('ReactLazy', () => {
1486 expect(root).not.toMatchRenderedOutput('AB');
1487
1488 await act(() => resolveFakeImport(ChildA));
1496 - assertLog([
1497 - 'A',
1498 - // pre-warming
1499 - 'A',
1500 - ]);
1489 + assertLog(['A']);
1490
1491 await act(() => resolveFakeImport(ChildB));
1492 assertLog(['A', 'B', 'Did mount: A', 'Did mount: B']);