Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
Feb 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 39 additions & 31 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -658,10 +658,6 @@ export function replaySuspendedComponentWithHooks<Props, SecondArg>(
// only get reset when the component either completes (finishRenderingHooks)
// or unwinds (resetHooksOnUnwind).
if (__DEV__) {
hookTypesDev =
current !== null
? ((current._debugHookTypes: any): Array<HookType>)
: null;
hookTypesUpdateIndexDev = -1;
// Used for hot reloading:
ignorePreviousDependencies =
Expand Down Expand Up @@ -696,8 +692,13 @@ function renderWithHooksAgain<Props, SecondArg>(
let numberOfReRenders: number = 0;
let children;
do {
didScheduleRenderPhaseUpdateDuringThisPass = false;
if (didScheduleRenderPhaseUpdateDuringThisPass) {
// It's possible that a use() value depended on a state that was updated in
// this rerender, so we need to watch for different thenables this time.
thenableState = null;
}
thenableIndexCounter = 0;
didScheduleRenderPhaseUpdateDuringThisPass = false;

if (numberOfReRenders >= RE_RENDER_LIMIT) {
throw new Error(
Expand Down Expand Up @@ -841,8 +842,7 @@ function updateWorkInProgressHook(): Hook {
// This function is used both for updates and for re-renders triggered by a
// render phase update. It assumes there is either a current hook we can
// clone, or a work-in-progress hook from a previous render pass that we can
// use as a base. When we reach the end of the base list, we must switch to
// the dispatcher used for mounts.
// use as a base.
let nextCurrentHook: null | Hook;
if (currentHook === null) {
const current = currentlyRenderingFiber.alternate;
Expand Down Expand Up @@ -876,16 +876,10 @@ function updateWorkInProgressHook(): Hook {
if (currentFiber === null) {
// This is the initial render. This branch is reached when the component
// suspends, resumes, then renders an additional hook.
const newHook: Hook = {
memoizedState: null,

baseState: null,
baseQueue: null,
queue: null,

next: null,
};
nextCurrentHook = newHook;
// Should never be reached because we should switch to the mount dispatcher first.
throw new Error(
'Update hook called on initial render. This is likely a bug in React. Please file an issue.',
);
} else {
// This is an update. We should always have a current hook.
throw new Error('Rendered more hooks than during the previous render.');
Expand Down Expand Up @@ -951,7 +945,24 @@ function use<T>(usable: Usable<T>): T {
if (thenableState === null) {
thenableState = createThenableState();
}
return trackUsedThenable(thenableState, thenable, index);
const result = trackUsedThenable(thenableState, thenable, index);
if (
currentlyRenderingFiber.alternate === null &&
(workInProgressHook === null
? currentlyRenderingFiber.memoizedState === null
: workInProgressHook.next === null)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could turn nextWorkInProgressHook into a module level variable and read from there. To avoid a tiny bit of duplication. And/or could split use into separate mount and update implementations. But since this is a fairly specialized branch it's fine this way too.

) {
// Initial render, and either this is the first time the component is
// called, or there were no Hooks called after this use() the previous
// time (perhaps because it threw). Subsequent Hook calls should use the
// mount dispatcher.
if (__DEV__) {
ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a little trouble wrapping my head around whether it's necessary to do something with HooksDispatcherOnMountWithHookTypesInDEV here but my DebugValue test passes so ¯\_(ツ)_/¯?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That dispatcher is related to the hooks mismatch warning, not useDebugValue. But I think I recall the last time I looked at that code, it was due to a weird legacy mode only quirk related to React.lazy. We should try to clean it up (separately). Still, probably the worst thing that happens is we sometimes don't fire a warning where we should.

The other weird case is the InvalidNestedHooksDispatcher(s). If we want to match production exactly then we have to account for those too. That's where you call a hook inside of e.g. useMemo. But that's considered undefined behavior so also not super critical.

So I think we can merge as-is. Neither of those are as important as fixing the bug.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah sorry – HooksDispatcherOnMountWithHookTypesInDEV is because non-stateful hooks (like useDebugValue) can trigger the mismatch warning. Some of the tests in ReactHooks-test.internal using useContext or useDebugValue fail without it. But I couldn't get it to happen here with the test I added.

Can you elaborate on what needs to be done with the InvalidNestedHooks ones? From a quick glance I would think we're already OK there and it will warn as expected.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The finally block when you exit a useMemo call sets the dispatcher back to the previous one.

ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV;
try {
return mountMemo(create, deps);
} finally {
ReactCurrentDispatcher.current = prevDispatcher;

So for example, if you were to call use inside useMemo (which triggers a warning in dev), I think what would happen is that the dispatcher would get overridden twice.

Although now that I type that out, maybe it just works because the useMemo computation would have been reused from last time. So the invalid hook call wouldn't happen.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Meh. I agree it's undefined behavior and you get the first warning so I don't really care.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm although now is the dev and prod behavior different…

} else {
ReactCurrentDispatcher.current = HooksDispatcherOnMount;
}
}
return result;
} else if (
usable.$$typeof === REACT_CONTEXT_TYPE ||
usable.$$typeof === REACT_SERVER_CONTEXT_TYPE
Expand Down Expand Up @@ -1998,6 +2009,7 @@ function updateEffectImpl(
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;

// currentHook is null when rerendering after a render phase state update.
if (currentHook !== null) {
const prevEffect = currentHook.memoizedState;
destroy = prevEffect.destroy;
Expand Down Expand Up @@ -2250,12 +2262,10 @@ function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (prevState !== null) {
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
hook.memoizedState = [callback, nextDeps];
Expand Down Expand Up @@ -2283,13 +2293,11 @@ function updateMemo<T>(
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (prevState !== null) {
// Assume these are defined. If they're not, areHookInputsEqual will warn.
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
// Assume these are defined. If they're not, areHookInputsEqual will warn.
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
if (shouldDoubleInvokeUserFnsInHooksDEV) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1076,7 +1076,7 @@ describe('ReactHooks', () => {
expect(() => {
ReactTestRenderer.create(<App />);
}).toThrow(
'Should have a queue. This is likely a bug in React. Please file an issue.',
'Update hook called on initial render. This is likely a bug in React. Please file an issue.',
);
}).toErrorDev([
'Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks',
Expand Down
146 changes: 145 additions & 1 deletion packages/react-reconciler/src/__tests__/ReactThenable-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ let ReactNoop;
let Scheduler;
let act;
let use;
let useDebugValue;
let useState;
let useMemo;
let Suspense;
Expand All @@ -21,6 +22,7 @@ describe('ReactThenable', () => {
Scheduler = require('scheduler');
act = require('jest-react').act;
use = React.use;
useDebugValue = React.useDebugValue;
useState = React.useState;
useMemo = React.useMemo;
Suspense = React.Suspense;
Expand Down Expand Up @@ -794,7 +796,7 @@ describe('ReactThenable', () => {
expect(root).toMatchRenderedOutput('(empty)');
});

test('when replaying a suspended component, reuses the hooks computed during the previous attempt', async () => {
test('when replaying a suspended component, reuses the hooks computed during the previous attempt (Memo)', async () => {
function ExcitingText({text}) {
// This computes the uppercased version of some text. Pretend it's an
// expensive operation that we want to reuse.
Expand Down Expand Up @@ -846,6 +848,118 @@ describe('ReactThenable', () => {
]);
});

test('when replaying a suspended component, reuses the hooks computed during the previous attempt (State)', async () => {
let _setFruit;
let _setVegetable;
function Kitchen() {
const [fruit, setFruit] = useState('apple');
_setFruit = setFruit;
const usedFruit = use(getAsyncText(fruit));
const [vegetable, setVegetable] = useState('carrot');
_setVegetable = setVegetable;
return <Text text={usedFruit + ' ' + vegetable} />;
}

// Initial render.
const root = ReactNoop.createRoot();
await act(async () => {
startTransition(() => {
root.render(<Kitchen />);
});
});
expect(Scheduler).toHaveYielded(['Async text requested [apple]']);
expect(root).toMatchRenderedOutput(null);
await act(async () => {
resolveTextRequests('apple');
});
expect(Scheduler).toHaveYielded([
'Async text requested [apple]',
'apple carrot',
]);
expect(root).toMatchRenderedOutput('apple carrot');

// Update the state variable after the use().
await act(async () => {
startTransition(() => {
_setVegetable('dill');
});
});
expect(Scheduler).toHaveYielded(['Async text requested [apple]']);
expect(root).toMatchRenderedOutput('apple carrot');
await act(async () => {
resolveTextRequests('apple');
});
expect(Scheduler).toHaveYielded([
'Async text requested [apple]',
'apple dill',
]);
expect(root).toMatchRenderedOutput('apple dill');

// Update the state variable before the use(). The second state is maintained.
await act(async () => {
startTransition(() => {
_setFruit('banana');
});
});
expect(Scheduler).toHaveYielded(['Async text requested [banana]']);
expect(root).toMatchRenderedOutput('apple dill');
await act(async () => {
resolveTextRequests('banana');
});
expect(Scheduler).toHaveYielded([
'Async text requested [banana]',
'banana dill',
]);
expect(root).toMatchRenderedOutput('banana dill');
});

test('when replaying a suspended component, reuses the hooks computed during the previous attempt (DebugValue+State)', async () => {
// Make sure we don't get a Hook mismatch warning on updates if there were non-stateful Hooks before the use().
let _setLawyer;
function Lexicon() {
useDebugValue(123);
const avocado = use(getAsyncText('aguacate'));
const [lawyer, setLawyer] = useState('abogado');
_setLawyer = setLawyer;
return <Text text={avocado + ' ' + lawyer} />;
}

// Initial render.
const root = ReactNoop.createRoot();
await act(async () => {
startTransition(() => {
root.render(<Lexicon />);
});
});
expect(Scheduler).toHaveYielded(['Async text requested [aguacate]']);
expect(root).toMatchRenderedOutput(null);
await act(async () => {
resolveTextRequests('aguacate');
});
expect(Scheduler).toHaveYielded([
'Async text requested [aguacate]',
'aguacate abogado',
]);
expect(root).toMatchRenderedOutput('aguacate abogado');

// Now update the state.
await act(async () => {
startTransition(() => {
_setLawyer('avocat');
});
});
expect(Scheduler).toHaveYielded(['Async text requested [aguacate]']);
expect(root).toMatchRenderedOutput('aguacate abogado');
await act(async () => {
resolveTextRequests('aguacate');
});
expect(Scheduler).toHaveYielded([
'Async text requested [aguacate]',
'aguacate avocat',
]);
expect(root).toMatchRenderedOutput('aguacate avocat');
});

// @gate enableUseHook
test(
'wrap an async function with useMemo to skip running the function ' +
Expand Down Expand Up @@ -1021,4 +1135,34 @@ describe('ReactThenable', () => {
expect(Scheduler).toHaveYielded(['Async text requested [C]', 'C']);
expect(root).toMatchRenderedOutput('ABC');
});

// @gate enableUseHook
test('use() combined with render phase updates', async () => {
function Async() {
const a = use(Promise.resolve('A'));
const [count, setCount] = useState(0);
if (count === 0) {
setCount(1);
}
const usedCount = use(Promise.resolve(count));
return <Text text={a + usedCount} />;
}

function App() {
return (
<Suspense fallback={<Text text="Loading..." />}>
<Async />
</Suspense>
);
}

const root = ReactNoop.createRoot();
await act(async () => {
startTransition(() => {
root.render(<App />);
});
});
expect(Scheduler).toHaveYielded(['A1']);
expect(root).toMatchRenderedOutput('A1');
});
});
3 changes: 2 additions & 1 deletion scripts/error-codes/codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -451,5 +451,6 @@
"463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
"464": "ReactDOMServer.renderToStaticNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
"465": "enableFizzExternalRuntime without enableFloat is not supported. This should never appear in production, since it means you are using a misconfigured React bundle.",
"466": "Trying to call a function from \"use server\" but the callServer option was not implemented in your router runtime."
"466": "Trying to call a function from \"use server\" but the callServer option was not implemented in your router runtime.",
"467": "Update hook called on initial render. This is likely a bug in React. Please file an issue."
}