@samitouri / QOS-React / commits / 1a902623a8

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.