Skip to content

Commit 1a90262

Browse files
authored
Unwrap sync resolved thenables without suspending (#25615)
If a thenable resolves synchronously, `use` should unwrap its result without suspending or interrupting the component's execution.
1 parent 4ea063b commit 1a90262

File tree

9 files changed

+171
-64
lines changed

9 files changed

+171
-64
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

+27
Original file line numberDiff line numberDiff line change
@@ -5300,6 +5300,33 @@ describe('ReactDOMFizzServer', () => {
53005300
expect(Scheduler).toFlushAndYield([]);
53015301
expect(getVisibleChildren(container)).toEqual('Hi');
53025302
});
5303+
5304+
// @gate enableUseHook
5305+
it('unwraps thenable that fulfills synchronously without suspending', async () => {
5306+
function App() {
5307+
const thenable = {
5308+
then(resolve) {
5309+
// This thenable immediately resolves, synchronously, without waiting
5310+
// a microtask.
5311+
resolve('Hi');
5312+
},
5313+
};
5314+
try {
5315+
return <Text text={use(thenable)} />;
5316+
} catch {
5317+
throw new Error(
5318+
'`use` should not suspend because the thenable resolved synchronously.',
5319+
);
5320+
}
5321+
}
5322+
// Because the thenable resolves synchronously, we should be able to finish
5323+
// rendering synchronously, with no fallback.
5324+
await act(async () => {
5325+
const {pipe} = renderToPipeableStream(<App />);
5326+
pipe(writable);
5327+
});
5328+
expect(getVisibleChildren(container)).toEqual('Hi');
5329+
});
53035330
});
53045331

53055332
describe('useEvent', () => {

packages/react-reconciler/src/ReactFiberThenable.new.js

+15-3
Original file line numberDiff line numberDiff line change
@@ -110,24 +110,36 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number): T {
110110
// it's defined, but an unknown value, assume it's been instrumented by
111111
// some custom userspace implementation. We treat it as "pending".
112112
} else {
113-
const pendingThenable: PendingThenable<mixed> = (thenable: any);
113+
const pendingThenable: PendingThenable<T> = (thenable: any);
114114
pendingThenable.status = 'pending';
115115
pendingThenable.then(
116116
fulfilledValue => {
117117
if (thenable.status === 'pending') {
118-
const fulfilledThenable: FulfilledThenable<mixed> = (thenable: any);
118+
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
119119
fulfilledThenable.status = 'fulfilled';
120120
fulfilledThenable.value = fulfilledValue;
121121
}
122122
},
123123
(error: mixed) => {
124124
if (thenable.status === 'pending') {
125-
const rejectedThenable: RejectedThenable<mixed> = (thenable: any);
125+
const rejectedThenable: RejectedThenable<T> = (thenable: any);
126126
rejectedThenable.status = 'rejected';
127127
rejectedThenable.reason = error;
128128
}
129129
},
130130
);
131+
132+
// Check one more time in case the thenable resolved synchronously
133+
switch (thenable.status) {
134+
case 'fulfilled': {
135+
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
136+
return fulfilledThenable.value;
137+
}
138+
case 'rejected': {
139+
const rejectedThenable: RejectedThenable<T> = (thenable: any);
140+
throw rejectedThenable.reason;
141+
}
142+
}
131143
}
132144

133145
// Suspend.

packages/react-reconciler/src/ReactFiberThenable.old.js

+15-3
Original file line numberDiff line numberDiff line change
@@ -110,24 +110,36 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number): T {
110110
// it's defined, but an unknown value, assume it's been instrumented by
111111
// some custom userspace implementation. We treat it as "pending".
112112
} else {
113-
const pendingThenable: PendingThenable<mixed> = (thenable: any);
113+
const pendingThenable: PendingThenable<T> = (thenable: any);
114114
pendingThenable.status = 'pending';
115115
pendingThenable.then(
116116
fulfilledValue => {
117117
if (thenable.status === 'pending') {
118-
const fulfilledThenable: FulfilledThenable<mixed> = (thenable: any);
118+
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
119119
fulfilledThenable.status = 'fulfilled';
120120
fulfilledThenable.value = fulfilledValue;
121121
}
122122
},
123123
(error: mixed) => {
124124
if (thenable.status === 'pending') {
125-
const rejectedThenable: RejectedThenable<mixed> = (thenable: any);
125+
const rejectedThenable: RejectedThenable<T> = (thenable: any);
126126
rejectedThenable.status = 'rejected';
127127
rejectedThenable.reason = error;
128128
}
129129
},
130130
);
131+
132+
// Check one more time in case the thenable resolved synchronously
133+
switch (thenable.status) {
134+
case 'fulfilled': {
135+
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
136+
return fulfilledThenable.value;
137+
}
138+
case 'rejected': {
139+
const rejectedThenable: RejectedThenable<T> = (thenable: any);
140+
throw rejectedThenable.reason;
141+
}
142+
}
131143
}
132144

133145
// Suspend.

packages/react-reconciler/src/ReactFiberWorkLoop.new.js

+10-26
Original file line numberDiff line numberDiff line change
@@ -2016,37 +2016,21 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
20162016
workInProgressSuspendedReason !== NotSuspended &&
20172017
workInProgress !== null
20182018
) {
2019-
// The work loop is suspended. We need to either unwind the stack or
2020-
// replay the suspended component.
2019+
// The work loop is suspended. During a synchronous render, we don't
2020+
// yield to the main thread. Immediately unwind the stack. This will
2021+
// trigger either a fallback or an error boundary.
2022+
// TODO: For discrete and "default" updates (anything that's not
2023+
// flushSync), we want to wait for the microtasks the flush before
2024+
// unwinding. Will probably implement this using renderRootConcurrent,
2025+
// or merge renderRootSync and renderRootConcurrent into the same
2026+
// function and fork the behavior some other way.
20212027
const unitOfWork = workInProgress;
20222028
const thrownValue = workInProgressThrownValue;
20232029
workInProgressSuspendedReason = NotSuspended;
20242030
workInProgressThrownValue = null;
2031+
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
20252032

2026-
// TODO: This check is only here to account for thenables that
2027-
// synchronously resolve. Otherwise we would always unwind when
2028-
// rendering with renderRootSync. (In the future, discrete updates will
2029-
// use renderRootConcurrent instead.) We should account for
2030-
// synchronously resolved thenables before hitting this path.
2031-
switch (workInProgressSuspendedReason) {
2032-
case SuspendedOnError: {
2033-
// Unwind then continue with the normal work loop.
2034-
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2035-
break;
2036-
}
2037-
default: {
2038-
const wasPinged =
2039-
workInProgressSuspendedThenableState !== null &&
2040-
isThenableStateResolved(workInProgressSuspendedThenableState);
2041-
if (wasPinged) {
2042-
replaySuspendedUnitOfWork(unitOfWork, thrownValue);
2043-
} else {
2044-
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2045-
}
2046-
// Continue with the normal work loop.
2047-
break;
2048-
}
2049-
}
2033+
// Continue with the normal work loop.
20502034
}
20512035
workLoopSync();
20522036
break;

packages/react-reconciler/src/ReactFiberWorkLoop.old.js

+10-26
Original file line numberDiff line numberDiff line change
@@ -2016,37 +2016,21 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
20162016
workInProgressSuspendedReason !== NotSuspended &&
20172017
workInProgress !== null
20182018
) {
2019-
// The work loop is suspended. We need to either unwind the stack or
2020-
// replay the suspended component.
2019+
// The work loop is suspended. During a synchronous render, we don't
2020+
// yield to the main thread. Immediately unwind the stack. This will
2021+
// trigger either a fallback or an error boundary.
2022+
// TODO: For discrete and "default" updates (anything that's not
2023+
// flushSync), we want to wait for the microtasks the flush before
2024+
// unwinding. Will probably implement this using renderRootConcurrent,
2025+
// or merge renderRootSync and renderRootConcurrent into the same
2026+
// function and fork the behavior some other way.
20212027
const unitOfWork = workInProgress;
20222028
const thrownValue = workInProgressThrownValue;
20232029
workInProgressSuspendedReason = NotSuspended;
20242030
workInProgressThrownValue = null;
2031+
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
20252032

2026-
// TODO: This check is only here to account for thenables that
2027-
// synchronously resolve. Otherwise we would always unwind when
2028-
// rendering with renderRootSync. (In the future, discrete updates will
2029-
// use renderRootConcurrent instead.) We should account for
2030-
// synchronously resolved thenables before hitting this path.
2031-
switch (workInProgressSuspendedReason) {
2032-
case SuspendedOnError: {
2033-
// Unwind then continue with the normal work loop.
2034-
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2035-
break;
2036-
}
2037-
default: {
2038-
const wasPinged =
2039-
workInProgressSuspendedThenableState !== null &&
2040-
isThenableStateResolved(workInProgressSuspendedThenableState);
2041-
if (wasPinged) {
2042-
replaySuspendedUnitOfWork(unitOfWork, thrownValue);
2043-
} else {
2044-
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2045-
}
2046-
// Continue with the normal work loop.
2047-
break;
2048-
}
2049-
}
2033+
// Continue with the normal work loop.
20502034
}
20512035
workLoopSync();
20522036
break;

packages/react-reconciler/src/__tests__/ReactThenable-test.js

+28
Original file line numberDiff line numberDiff line change
@@ -640,4 +640,32 @@ describe('ReactThenable', () => {
640640
});
641641
expect(Scheduler).toHaveYielded(['Something different']);
642642
});
643+
644+
// @gate enableUseHook
645+
test('unwraps thenable that fulfills synchronously without suspending', async () => {
646+
function App() {
647+
const thenable = {
648+
then(resolve) {
649+
// This thenable immediately resolves, synchronously, without waiting
650+
// a microtask.
651+
resolve('Hi');
652+
},
653+
};
654+
try {
655+
return <Text text={use(thenable)} />;
656+
} catch {
657+
throw new Error(
658+
'`use` should not suspend because the thenable resolved synchronously.',
659+
);
660+
}
661+
}
662+
// Because the thenable resolves synchronously, we should be able to finish
663+
// rendering synchronously, with no fallback.
664+
const root = ReactNoop.createRoot();
665+
ReactNoop.flushSync(() => {
666+
root.render(<App />);
667+
});
668+
expect(Scheduler).toHaveYielded(['Hi']);
669+
expect(root).toMatchRenderedOutput('Hi');
670+
});
643671
});

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js

+36
Original file line numberDiff line numberDiff line change
@@ -778,4 +778,40 @@ describe('ReactFlightDOMBrowser', () => {
778778
});
779779
expect(container.innerHTML).toBe('Hi');
780780
});
781+
782+
// @gate enableUseHook
783+
it('unwraps thenable that fulfills synchronously without suspending', async () => {
784+
function Server() {
785+
const thenable = {
786+
then(resolve) {
787+
// This thenable immediately resolves, synchronously, without waiting
788+
// a microtask.
789+
resolve('Hi');
790+
},
791+
};
792+
try {
793+
return use(thenable);
794+
} catch {
795+
throw new Error(
796+
'`use` should not suspend because the thenable resolved synchronously.',
797+
);
798+
}
799+
}
800+
801+
// Because the thenable resolves synchronously, we should be able to finish
802+
// rendering synchronously, with no fallback.
803+
const stream = ReactServerDOMWriter.renderToReadableStream(<Server />);
804+
const response = ReactServerDOMReader.createFromReadableStream(stream);
805+
806+
function Client() {
807+
return use(response);
808+
}
809+
810+
const container = document.createElement('div');
811+
const root = ReactDOMClient.createRoot(container);
812+
await act(async () => {
813+
root.render(<Client />);
814+
});
815+
expect(container.innerHTML).toBe('Hi');
816+
});
781817
});

packages/react-server/src/ReactFizzThenable.js

+15-3
Original file line numberDiff line numberDiff line change
@@ -83,24 +83,36 @@ export function trackUsedThenable<T>(
8383
// it's defined, but an unknown value, assume it's been instrumented by
8484
// some custom userspace implementation. We treat it as "pending".
8585
} else {
86-
const pendingThenable: PendingThenable<mixed> = (thenable: any);
86+
const pendingThenable: PendingThenable<T> = (thenable: any);
8787
pendingThenable.status = 'pending';
8888
pendingThenable.then(
8989
fulfilledValue => {
9090
if (thenable.status === 'pending') {
91-
const fulfilledThenable: FulfilledThenable<mixed> = (thenable: any);
91+
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
9292
fulfilledThenable.status = 'fulfilled';
9393
fulfilledThenable.value = fulfilledValue;
9494
}
9595
},
9696
(error: mixed) => {
9797
if (thenable.status === 'pending') {
98-
const rejectedThenable: RejectedThenable<mixed> = (thenable: any);
98+
const rejectedThenable: RejectedThenable<T> = (thenable: any);
9999
rejectedThenable.status = 'rejected';
100100
rejectedThenable.reason = error;
101101
}
102102
},
103103
);
104+
105+
// Check one more time in case the thenable resolved synchronously
106+
switch (thenable.status) {
107+
case 'fulfilled': {
108+
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
109+
return fulfilledThenable.value;
110+
}
111+
case 'rejected': {
112+
const rejectedThenable: RejectedThenable<T> = (thenable: any);
113+
throw rejectedThenable.reason;
114+
}
115+
}
104116
}
105117

106118
// Suspend.

packages/react-server/src/ReactFlightThenable.js

+15-3
Original file line numberDiff line numberDiff line change
@@ -83,24 +83,36 @@ export function trackUsedThenable<T>(
8383
// it's defined, but an unknown value, assume it's been instrumented by
8484
// some custom userspace implementation. We treat it as "pending".
8585
} else {
86-
const pendingThenable: PendingThenable<mixed> = (thenable: any);
86+
const pendingThenable: PendingThenable<T> = (thenable: any);
8787
pendingThenable.status = 'pending';
8888
pendingThenable.then(
8989
fulfilledValue => {
9090
if (thenable.status === 'pending') {
91-
const fulfilledThenable: FulfilledThenable<mixed> = (thenable: any);
91+
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
9292
fulfilledThenable.status = 'fulfilled';
9393
fulfilledThenable.value = fulfilledValue;
9494
}
9595
},
9696
(error: mixed) => {
9797
if (thenable.status === 'pending') {
98-
const rejectedThenable: RejectedThenable<mixed> = (thenable: any);
98+
const rejectedThenable: RejectedThenable<T> = (thenable: any);
9999
rejectedThenable.status = 'rejected';
100100
rejectedThenable.reason = error;
101101
}
102102
},
103103
);
104+
105+
// Check one more time in case the thenable resolved synchronously
106+
switch (thenable.status) {
107+
case 'fulfilled': {
108+
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
109+
return fulfilledThenable.value;
110+
}
111+
case 'rejected': {
112+
const rejectedThenable: RejectedThenable<T> = (thenable: any);
113+
throw rejectedThenable.reason;
114+
}
115+
}
104116
}
105117

106118
// Suspend.

0 commit comments

Comments
 (0)