From 3c51c4b779db3b41a0c34cd8db59503299116a07 Mon Sep 17 00:00:00 2001 From: leeguooooo Date: Fri, 16 Jan 2026 16:45:48 +0900 Subject: [PATCH] ReactLazy: handle status-thenable transitions --- .../src/__tests__/ReactLazy-test.internal.js | 81 ++++++++++++- packages/react/src/ReactLazy.js | 113 ++++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) diff --git a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js index 3afb0008ab5..a208d87c1fc 100644 --- a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js @@ -123,18 +123,25 @@ describe('ReactLazy', () => { }, })); + const LazyTextWithStatus = lazy(() => ({ + status: 'fulfilled', + value: {default: Text}, + then() {}, + })); + let root; await act(() => { root = ReactTestRenderer.create( }> + , {unstable_isConcurrent: true}, ); }); - assertLog(['Hi']); - expect(root).toMatchRenderedOutput('Hi'); + assertLog(['Hi', 'Bye']); + expect(root).toMatchRenderedOutput('HiBye'); }); it('can reject synchronously without suspending', async () => { @@ -171,6 +178,76 @@ describe('ReactLazy', () => { expect(root).toMatchRenderedOutput('Error: oh no'); }); + it('can reject synchronously without suspending with status thenable', async () => { + const error = new Error('oh no'); + const LazyText = lazy(() => ({ + status: 'rejected', + reason: error, + then() {}, + })); + + class ErrorBoundary extends React.Component { + state = {}; + static getDerivedStateFromError(error) { + return {message: error.message}; + } + render() { + return this.state.message + ? `Error: ${this.state.message}` + : this.props.children; + } + } + + let root; + await act(() => { + root = ReactTestRenderer.create( + + }> + + + , + {unstable_isConcurrent: true}, + ); + }); + assertLog([]); + expect(root).toMatchRenderedOutput('Error: oh no'); + }); + + it('can resolve after pending status thenable flips', async () => { + let statusThenable; + const LazyText = lazy(() => { + statusThenable = { + status: 'pending', + value: {default: Text}, + then() {}, + }; + return statusThenable; + }); + + const root = ReactTestRenderer.create( + }> + + , + { + unstable_isConcurrent: true, + }, + ); + + await waitForAll(['Loading...']); + expect(root).not.toMatchRenderedOutput('Hi'); + + statusThenable.status = 'fulfilled'; + statusThenable.value = {default: Text}; + + root.update( + }> + + , + ); + await waitForAll(['Hi again']); + expect(root).toMatchRenderedOutput('Hi again'); + }); + it('multiple lazy components', async () => { function Foo() { return ; diff --git a/packages/react/src/ReactLazy.js b/packages/react/src/ReactLazy.js index 47f37afbae2..f341f379d39 100644 --- a/packages/react/src/ReactLazy.js +++ b/packages/react/src/ReactLazy.js @@ -31,24 +31,32 @@ type UninitializedPayload = { _status: -1, _result: () => Thenable<{default: T, ...}>, _ioInfo?: ReactIOInfo, // DEV-only + _debugValueResolve?: null | ((value: mixed) => void), + _debugValueReject?: null | ((error: mixed) => void), }; type PendingPayload = { _status: 0, _result: Wakeable, _ioInfo?: ReactIOInfo, // DEV-only + _debugValueResolve?: null | ((value: mixed) => void), + _debugValueReject?: null | ((error: mixed) => void), }; type ResolvedPayload = { _status: 1, _result: {default: T, ...}, _ioInfo?: ReactIOInfo, // DEV-only + _debugValueResolve?: null | ((value: mixed) => void), + _debugValueReject?: null | ((error: mixed) => void), }; type RejectedPayload = { _status: 2, _result: mixed, _ioInfo?: ReactIOInfo, // DEV-only + _debugValueResolve?: null | ((value: mixed) => void), + _debugValueReject?: null | ((error: mixed) => void), }; type Payload = @@ -68,6 +76,62 @@ export type LazyComponent = { }; function lazyInitializer(payload: Payload): T { + if (payload._status === Pending) { + const thenable: Thenable<{default: T, ...}> = (payload._result: any); + switch (thenable.status) { + case 'fulfilled': { + const fulfilledThenable: FulfilledThenable<{default: T, ...}> = + (thenable: any); + const resolved: ResolvedPayload = (payload: any); + resolved._status = Resolved; + resolved._result = fulfilledThenable.value; + if (__DEV__ && enableAsyncDebugInfo) { + const ioInfo = payload._ioInfo; + if (ioInfo != null) { + const ioInfoMutable: any = ioInfo; + ioInfoMutable.end = performance.now(); + const debugValue = + fulfilledThenable.value == null + ? undefined + : fulfilledThenable.value.default; + const ioInfoValue: any = ioInfoMutable.value; + ioInfoValue.status = 'fulfilled'; + ioInfoValue.value = debugValue; + const debugValueResolve = payload._debugValueResolve; + if (debugValueResolve != null) { + payload._debugValueResolve = null; + debugValueResolve(debugValue); + } + } + } + break; + } + case 'rejected': { + const rejectedThenable: RejectedThenable<{default: T, ...}> = + (thenable: any); + const rejected: RejectedPayload = (payload: any); + rejected._status = Rejected; + rejected._result = rejectedThenable.reason; + if (__DEV__ && enableAsyncDebugInfo) { + const ioInfo = payload._ioInfo; + if (ioInfo != null) { + const ioInfoMutable: any = ioInfo; + ioInfoMutable.end = performance.now(); + const ioInfoValue: any = ioInfoMutable.value; + ioInfoValue.then(noop, noop); + ioInfoValue.status = 'rejected'; + ioInfoValue.reason = rejectedThenable.reason; + const debugValueReject = payload._debugValueReject; + if (debugValueReject != null) { + payload._debugValueReject = null; + debugValueReject(rejectedThenable.reason); + } + } + } + break; + } + } + } if (payload._status === Uninitialized) { let resolveDebugValue: (void | T) => void = (null: any); let rejectDebugValue: mixed => void = (null: any); @@ -83,6 +147,8 @@ function lazyInitializer(payload: Payload): T { resolveDebugValue = resolve; rejectDebugValue = reject; }); + payload._debugValueResolve = resolveDebugValue; + payload._debugValueReject = rejectDebugValue; } } const ctor = payload._result; @@ -172,6 +238,53 @@ function lazyInitializer(payload: Payload): T { } } } + if (payload._status === Uninitialized || payload._status === Pending) { + switch ((thenable: Thenable<{default: T, ...}>).status) { + case 'fulfilled': { + const fulfilledThenable: FulfilledThenable<{default: T, ...}> = + (thenable: any); + const resolved: ResolvedPayload = (payload: any); + resolved._status = Resolved; + resolved._result = fulfilledThenable.value; + if (__DEV__ && enableAsyncDebugInfo) { + const ioInfo = payload._ioInfo; + if (ioInfo != null) { + const ioInfoMutable: any = ioInfo; + ioInfoMutable.end = performance.now(); + const debugValue = + fulfilledThenable.value == null + ? undefined + : fulfilledThenable.value.default; + resolveDebugValue(debugValue); + const ioInfoValue: any = ioInfoMutable.value; + ioInfoValue.status = 'fulfilled'; + ioInfoValue.value = debugValue; + } + } + break; + } + case 'rejected': { + const rejectedThenable: RejectedThenable<{default: T, ...}> = + (thenable: any); + const rejected: RejectedPayload = (payload: any); + rejected._status = Rejected; + rejected._result = rejectedThenable.reason; + if (__DEV__ && enableAsyncDebugInfo) { + const ioInfo = payload._ioInfo; + if (ioInfo != null) { + const ioInfoMutable: any = ioInfo; + ioInfoMutable.end = performance.now(); + const ioInfoValue: any = ioInfoMutable.value; + ioInfoValue.then(noop, noop); + rejectDebugValue(rejectedThenable.reason); + ioInfoValue.status = 'rejected'; + ioInfoValue.reason = rejectedThenable.reason; + } + } + break; + } + } + } if (payload._status === Uninitialized) { // In case, we're still uninitialized, then we're waiting for the thenable // to resolve. Set it as pending in the meantime.