@samitouri / QOS-React / commits / a8f971b7a6

Switch to mount dispatcher after use() when needed (#26232)

When resuming a suspended render, there may be more Hooks to be called that weren't seen the previous time through. Make sure to switch to the mount dispatcher when calling use() if the next Hook call should be treated as a mount. Fixes #25964.

Sophie Alpert committed Feb 24, 2023 at 12:06 UTC a8f971b7a669a9a6321b9f3cea820f68b2e4ac6e
4 files changed +187 -34
packages/react-reconciler/src/ReactFiberHooks.js
+39 -31
@@ -658,10 +658,6 @@ export function replaySuspendedComponentWithHooks<Props, SecondArg>(
658 // only get reset when the component either completes (finishRenderingHooks)
659 // or unwinds (resetHooksOnUnwind).
660 if (__DEV__) {
661 - hookTypesDev =
662 - current !== null
663 - ? ((current._debugHookTypes: any): Array<HookType>)
664 - : null;
661 hookTypesUpdateIndexDev = -1;
662 // Used for hot reloading:
663 ignorePreviousDependencies =
@@ -696,8 +692,13 @@ function renderWithHooksAgain<Props, SecondArg>(
692 let numberOfReRenders: number = 0;
693 let children;
694 do {
699 - didScheduleRenderPhaseUpdateDuringThisPass = false;
695 + if (didScheduleRenderPhaseUpdateDuringThisPass) {
696 + // It's possible that a use() value depended on a state that was updated in
697 + // this rerender, so we need to watch for different thenables this time.
698 + thenableState = null;
699 + }
700 thenableIndexCounter = 0;
701 + didScheduleRenderPhaseUpdateDuringThisPass = false;
702
703 if (numberOfReRenders >= RE_RENDER_LIMIT) {
704 throw new Error(
@@ -841,8 +842,7 @@ function updateWorkInProgressHook(): Hook {
842 // This function is used both for updates and for re-renders triggered by a
843 // render phase update. It assumes there is either a current hook we can
844 // clone, or a work-in-progress hook from a previous render pass that we can
844 - // use as a base. When we reach the end of the base list, we must switch to
845 - // the dispatcher used for mounts.
845 + // use as a base.
846 let nextCurrentHook: null | Hook;
847 if (currentHook === null) {
848 const current = currentlyRenderingFiber.alternate;
@@ -876,16 +876,10 @@ function updateWorkInProgressHook(): Hook {
876 if (currentFiber === null) {
877 // This is the initial render. This branch is reached when the component
878 // suspends, resumes, then renders an additional hook.
879 - const newHook: Hook = {
880 - memoizedState: null,
881 -
882 - baseState: null,
883 - baseQueue: null,
884 - queue: null,
885 -
886 - next: null,
887 - };
888 - nextCurrentHook = newHook;
879 + // Should never be reached because we should switch to the mount dispatcher first.
880 + throw new Error(
881 + 'Update hook called on initial render. This is likely a bug in React. Please file an issue.',
882 + );
883 } else {
884 // This is an update. We should always have a current hook.
885 throw new Error('Rendered more hooks than during the previous render.');
@@ -951,7 +945,24 @@ function use<T>(usable: Usable<T>): T {
945 if (thenableState === null) {
946 thenableState = createThenableState();
947 }
954 - return trackUsedThenable(thenableState, thenable, index);
948 + const result = trackUsedThenable(thenableState, thenable, index);
949 + if (
950 + currentlyRenderingFiber.alternate === null &&
951 + (workInProgressHook === null
952 + ? currentlyRenderingFiber.memoizedState === null
953 + : workInProgressHook.next === null)
954 + ) {
955 + // Initial render, and either this is the first time the component is
956 + // called, or there were no Hooks called after this use() the previous
957 + // time (perhaps because it threw). Subsequent Hook calls should use the
958 + // mount dispatcher.
959 + if (__DEV__) {
960 + ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
961 + } else {
962 + ReactCurrentDispatcher.current = HooksDispatcherOnMount;
963 + }
964 + }
965 + return result;
966 } else if (
967 usable.$$typeof === REACT_CONTEXT_TYPE ||
968 usable.$$typeof === REACT_SERVER_CONTEXT_TYPE
@@ -1998,6 +2009,7 @@ function updateEffectImpl(
2009 const nextDeps = deps === undefined ? null : deps;
2010 let destroy = undefined;
2011
2012 + // currentHook is null when rerendering after a render phase state update.
2013 if (currentHook !== null) {
2014 const prevEffect = currentHook.memoizedState;
2015 destroy = prevEffect.destroy;
@@ -2250,12 +2262,10 @@ function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
2262 const hook = updateWorkInProgressHook();
2263 const nextDeps = deps === undefined ? null : deps;
2264 const prevState = hook.memoizedState;
2253 - if (prevState !== null) {
2254 - if (nextDeps !== null) {
2255 - const prevDeps: Array<mixed> | null = prevState[1];
2256 - if (areHookInputsEqual(nextDeps, prevDeps)) {
2257 - return prevState[0];
2258 - }
2265 + if (nextDeps !== null) {
2266 + const prevDeps: Array<mixed> | null = prevState[1];
2267 + if (areHookInputsEqual(nextDeps, prevDeps)) {
2268 + return prevState[0];
2269 }
2270 }
2271 hook.memoizedState = [callback, nextDeps];
@@ -2283,13 +2293,11 @@ function updateMemo<T>(
2293 const hook = updateWorkInProgressHook();
2294 const nextDeps = deps === undefined ? null : deps;
2295 const prevState = hook.memoizedState;
2286 - if (prevState !== null) {
2287 - // Assume these are defined. If they're not, areHookInputsEqual will warn.
2288 - if (nextDeps !== null) {
2289 - const prevDeps: Array<mixed> | null = prevState[1];
2290 - if (areHookInputsEqual(nextDeps, prevDeps)) {
2291 - return prevState[0];
2292 - }
2296 + // Assume these are defined. If they're not, areHookInputsEqual will warn.
2297 + if (nextDeps !== null) {
2298 + const prevDeps: Array<mixed> | null = prevState[1];
2299 + if (areHookInputsEqual(nextDeps, prevDeps)) {
2300 + return prevState[0];
2301 }
2302 }
2303 if (shouldDoubleInvokeUserFnsInHooksDEV) {
packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
+1 -1
@@ -1076,7 +1076,7 @@ describe('ReactHooks', () => {
1076 expect(() => {
1077 ReactTestRenderer.create(<App />);
1078 }).toThrow(
1079 - 'Should have a queue. This is likely a bug in React. Please file an issue.',
1079 + 'Update hook called on initial render. This is likely a bug in React. Please file an issue.',
1080 );
1081 }).toErrorDev([
1082 'Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks',
packages/react-reconciler/src/__tests__/ReactThenable-test.js
+145 -1
@@ -5,6 +5,7 @@ let ReactNoop;
5 let Scheduler;
6 let act;
7 let use;
8 +let useDebugValue;
9 let useState;
10 let useMemo;
11 let Suspense;
@@ -21,6 +22,7 @@ describe('ReactThenable', () => {
22 Scheduler = require('scheduler');
23 act = require('jest-react').act;
24 use = React.use;
25 + useDebugValue = React.useDebugValue;
26 useState = React.useState;
27 useMemo = React.useMemo;
28 Suspense = React.Suspense;
@@ -794,7 +796,7 @@ describe('ReactThenable', () => {
796 expect(root).toMatchRenderedOutput('(empty)');
797 });
798
797 - test('when replaying a suspended component, reuses the hooks computed during the previous attempt', async () => {
799 + test('when replaying a suspended component, reuses the hooks computed during the previous attempt (Memo)', async () => {
800 function ExcitingText({text}) {
801 // This computes the uppercased version of some text. Pretend it's an
802 // expensive operation that we want to reuse.
@@ -846,6 +848,118 @@ describe('ReactThenable', () => {
848 ]);
849 });
850
851 + test('when replaying a suspended component, reuses the hooks computed during the previous attempt (State)', async () => {
852 + let _setFruit;
853 + let _setVegetable;
854 + function Kitchen() {
855 + const [fruit, setFruit] = useState('apple');
856 + _setFruit = setFruit;
857 + const usedFruit = use(getAsyncText(fruit));
858 + const [vegetable, setVegetable] = useState('carrot');
859 + _setVegetable = setVegetable;
860 + return <Text text={usedFruit + ' ' + vegetable} />;
861 + }
862 +
863 + // Initial render.
864 + const root = ReactNoop.createRoot();
865 + await act(async () => {
866 + startTransition(() => {
867 + root.render(<Kitchen />);
868 + });
869 + });
870 + expect(Scheduler).toHaveYielded(['Async text requested [apple]']);
871 + expect(root).toMatchRenderedOutput(null);
872 + await act(async () => {
873 + resolveTextRequests('apple');
874 + });
875 + expect(Scheduler).toHaveYielded([
876 + 'Async text requested [apple]',
877 + 'apple carrot',
878 + ]);
879 + expect(root).toMatchRenderedOutput('apple carrot');
880 +
881 + // Update the state variable after the use().
882 + await act(async () => {
883 + startTransition(() => {
884 + _setVegetable('dill');
885 + });
886 + });
887 + expect(Scheduler).toHaveYielded(['Async text requested [apple]']);
888 + expect(root).toMatchRenderedOutput('apple carrot');
889 + await act(async () => {
890 + resolveTextRequests('apple');
891 + });
892 + expect(Scheduler).toHaveYielded([
893 + 'Async text requested [apple]',
894 + 'apple dill',
895 + ]);
896 + expect(root).toMatchRenderedOutput('apple dill');
897 +
898 + // Update the state variable before the use(). The second state is maintained.
899 + await act(async () => {
900 + startTransition(() => {
901 + _setFruit('banana');
902 + });
903 + });
904 + expect(Scheduler).toHaveYielded(['Async text requested [banana]']);
905 + expect(root).toMatchRenderedOutput('apple dill');
906 + await act(async () => {
907 + resolveTextRequests('banana');
908 + });
909 + expect(Scheduler).toHaveYielded([
910 + 'Async text requested [banana]',
911 + 'banana dill',
912 + ]);
913 + expect(root).toMatchRenderedOutput('banana dill');
914 + });
915 +
916 + test('when replaying a suspended component, reuses the hooks computed during the previous attempt (DebugValue+State)', async () => {
917 + // Make sure we don't get a Hook mismatch warning on updates if there were non-stateful Hooks before the use().
918 + let _setLawyer;
919 + function Lexicon() {
920 + useDebugValue(123);
921 + const avocado = use(getAsyncText('aguacate'));
922 + const [lawyer, setLawyer] = useState('abogado');
923 + _setLawyer = setLawyer;
924 + return <Text text={avocado + ' ' + lawyer} />;
925 + }
926 +
927 + // Initial render.
928 + const root = ReactNoop.createRoot();
929 + await act(async () => {
930 + startTransition(() => {
931 + root.render(<Lexicon />);
932 + });
933 + });
934 + expect(Scheduler).toHaveYielded(['Async text requested [aguacate]']);
935 + expect(root).toMatchRenderedOutput(null);
936 + await act(async () => {
937 + resolveTextRequests('aguacate');
938 + });
939 + expect(Scheduler).toHaveYielded([
940 + 'Async text requested [aguacate]',
941 + 'aguacate abogado',
942 + ]);
943 + expect(root).toMatchRenderedOutput('aguacate abogado');
944 +
945 + // Now update the state.
946 + await act(async () => {
947 + startTransition(() => {
948 + _setLawyer('avocat');
949 + });
950 + });
951 + expect(Scheduler).toHaveYielded(['Async text requested [aguacate]']);
952 + expect(root).toMatchRenderedOutput('aguacate abogado');
953 + await act(async () => {
954 + resolveTextRequests('aguacate');
955 + });
956 + expect(Scheduler).toHaveYielded([
957 + 'Async text requested [aguacate]',
958 + 'aguacate avocat',
959 + ]);
960 + expect(root).toMatchRenderedOutput('aguacate avocat');
961 + });
962 +
963 // @gate enableUseHook
964 test(
965 'wrap an async function with useMemo to skip running the function ' +
@@ -1021,4 +1135,34 @@ describe('ReactThenable', () => {
1135 expect(Scheduler).toHaveYielded(['Async text requested [C]', 'C']);
1136 expect(root).toMatchRenderedOutput('ABC');
1137 });
1138 +
1139 + // @gate enableUseHook
1140 + test('use() combined with render phase updates', async () => {
1141 + function Async() {
1142 + const a = use(Promise.resolve('A'));
1143 + const [count, setCount] = useState(0);
1144 + if (count === 0) {
1145 + setCount(1);
1146 + }
1147 + const usedCount = use(Promise.resolve(count));
1148 + return <Text text={a + usedCount} />;
1149 + }
1150 +
1151 + function App() {
1152 + return (
1153 + <Suspense fallback={<Text text="Loading..." />}>
1154 + <Async />
1155 + </Suspense>
1156 + );
1157 + }
1158 +
1159 + const root = ReactNoop.createRoot();
1160 + await act(async () => {
1161 + startTransition(() => {
1162 + root.render(<App />);
1163 + });
1164 + });
1165 + expect(Scheduler).toHaveYielded(['A1']);
1166 + expect(root).toMatchRenderedOutput('A1');
1167 + });
1168 });
scripts/error-codes/codes.json
+2 -1
@@ -451,5 +451,6 @@
451 "463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
452 "464": "ReactDOMServer.renderToStaticNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
453 "465": "enableFizzExternalRuntime without enableFloat is not supported. This should never appear in production, since it means you are using a misconfigured React bundle.",
454 - "466": "Trying to call a function from \"use server\" but the callServer option was not implemented in your router runtime."
454 + "466": "Trying to call a function from \"use server\" but the callServer option was not implemented in your router runtime.",
455 + "467": "Update hook called on initial render. This is likely a bug in React. Please file an issue."
456 }
\ No newline at end of file