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.
Andrew Clark committed
Nov 2, 2022 at 10:53 UTC
1a902623a84ab004f9f042162bdcbce20f08b13f
9 files changed
+171
-64
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+27
@@ -5300,6 +5300,33 @@ describe('ReactDOMFizzServer', () => {
5300
expect(Scheduler).toFlushAndYield([]);
5301
expect(getVisibleChildren(container)).toEqual('Hi');
5302
});
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
+ });
5330
});
5331
5332
describe('useEvent', () => {
packages/react-reconciler/src/ReactFiberThenable.new.js
+15
-3
@@ -110,24 +110,36 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number): T {
110
// it's defined, but an unknown value, assume it's been instrumented by
111
// some custom userspace implementation. We treat it as "pending".
112
} else {
113
- const pendingThenable: PendingThenable<mixed> = (thenable: any);
113
+ const pendingThenable: PendingThenable<T> = (thenable: any);
114
pendingThenable.status = 'pending';
115
pendingThenable.then(
116
fulfilledValue => {
117
if (thenable.status === 'pending') {
118
- const fulfilledThenable: FulfilledThenable<mixed> = (thenable: any);
118
+ const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
119
fulfilledThenable.status = 'fulfilled';
120
fulfilledThenable.value = fulfilledValue;
121
}
122
},
123
(error: mixed) => {
124
if (thenable.status === 'pending') {
125
- const rejectedThenable: RejectedThenable<mixed> = (thenable: any);
125
+ const rejectedThenable: RejectedThenable<T> = (thenable: any);
126
rejectedThenable.status = 'rejected';
127
rejectedThenable.reason = error;
128
}
129
},
130
);
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
+ }
143
}
144
145
// Suspend.
packages/react-reconciler/src/ReactFiberThenable.old.js
+15
-3
@@ -110,24 +110,36 @@ export function trackUsedThenable<T>(thenable: Thenable<T>, index: number): T {
110
// it's defined, but an unknown value, assume it's been instrumented by
111
// some custom userspace implementation. We treat it as "pending".
112
} else {
113
- const pendingThenable: PendingThenable<mixed> = (thenable: any);
113
+ const pendingThenable: PendingThenable<T> = (thenable: any);
114
pendingThenable.status = 'pending';
115
pendingThenable.then(
116
fulfilledValue => {
117
if (thenable.status === 'pending') {
118
- const fulfilledThenable: FulfilledThenable<mixed> = (thenable: any);
118
+ const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
119
fulfilledThenable.status = 'fulfilled';
120
fulfilledThenable.value = fulfilledValue;
121
}
122
},
123
(error: mixed) => {
124
if (thenable.status === 'pending') {
125
- const rejectedThenable: RejectedThenable<mixed> = (thenable: any);
125
+ const rejectedThenable: RejectedThenable<T> = (thenable: any);
126
rejectedThenable.status = 'rejected';
127
rejectedThenable.reason = error;
128
}
129
},
130
);
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
+ }
143
}
144
145
// Suspend.
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+10
-26
@@ -2016,37 +2016,21 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
2016
workInProgressSuspendedReason !== NotSuspended &&
2017
workInProgress !== null
2018
) {
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.
2027
const unitOfWork = workInProgress;
2028
const thrownValue = workInProgressThrownValue;
2029
workInProgressSuspendedReason = NotSuspended;
2030
workInProgressThrownValue = null;
2031
+ unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2032
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.
2034
}
2035
workLoopSync();
2036
break;
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+10
-26
@@ -2016,37 +2016,21 @@ function renderRootSync(root: FiberRoot, lanes: Lanes) {
2016
workInProgressSuspendedReason !== NotSuspended &&
2017
workInProgress !== null
2018
) {
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.
2027
const unitOfWork = workInProgress;
2028
const thrownValue = workInProgressThrownValue;
2029
workInProgressSuspendedReason = NotSuspended;
2030
workInProgressThrownValue = null;
2031
+ unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
2032
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.
2034
}
2035
workLoopSync();
2036
break;
packages/react-reconciler/src/__tests__/ReactThenable-test.js
+28
@@ -640,4 +640,32 @@ describe('ReactThenable', () => {
640
});
641
expect(Scheduler).toHaveYielded(['Something different']);
642
});
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
+ });
671
});
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+36
@@ -778,4 +778,40 @@ describe('ReactFlightDOMBrowser', () => {
778
});
779
expect(container.innerHTML).toBe('Hi');
780
});
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
+ });
817
});
packages/react-server/src/ReactFizzThenable.js
+15
-3
@@ -83,24 +83,36 @@ export function trackUsedThenable<T>(
83
// it's defined, but an unknown value, assume it's been instrumented by
84
// some custom userspace implementation. We treat it as "pending".
85
} else {
86
- const pendingThenable: PendingThenable<mixed> = (thenable: any);
86
+ const pendingThenable: PendingThenable<T> = (thenable: any);
87
pendingThenable.status = 'pending';
88
pendingThenable.then(
89
fulfilledValue => {
90
if (thenable.status === 'pending') {
91
- const fulfilledThenable: FulfilledThenable<mixed> = (thenable: any);
91
+ const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
92
fulfilledThenable.status = 'fulfilled';
93
fulfilledThenable.value = fulfilledValue;
94
}
95
},
96
(error: mixed) => {
97
if (thenable.status === 'pending') {
98
- const rejectedThenable: RejectedThenable<mixed> = (thenable: any);
98
+ const rejectedThenable: RejectedThenable<T> = (thenable: any);
99
rejectedThenable.status = 'rejected';
100
rejectedThenable.reason = error;
101
}
102
},
103
);
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
+ }
116
}
117
118
// Suspend.
packages/react-server/src/ReactFlightThenable.js
+15
-3
@@ -83,24 +83,36 @@ export function trackUsedThenable<T>(
83
// it's defined, but an unknown value, assume it's been instrumented by
84
// some custom userspace implementation. We treat it as "pending".
85
} else {
86
- const pendingThenable: PendingThenable<mixed> = (thenable: any);
86
+ const pendingThenable: PendingThenable<T> = (thenable: any);
87
pendingThenable.status = 'pending';
88
pendingThenable.then(
89
fulfilledValue => {
90
if (thenable.status === 'pending') {
91
- const fulfilledThenable: FulfilledThenable<mixed> = (thenable: any);
91
+ const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
92
fulfilledThenable.status = 'fulfilled';
93
fulfilledThenable.value = fulfilledValue;
94
}
95
},
96
(error: mixed) => {
97
if (thenable.status === 'pending') {
98
- const rejectedThenable: RejectedThenable<mixed> = (thenable: any);
98
+ const rejectedThenable: RejectedThenable<T> = (thenable: any);
99
rejectedThenable.status = 'rejected';
100
rejectedThenable.reason = error;
101
}
102
},
103
);
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
+ }
116
}
117
118
// Suspend.