Skip to content

Commit

Permalink
Fix infinite update loop that happens when an unmemoized value is pas…
Browse files Browse the repository at this point in the history
…sed to useDeferredValue (#24247)

* Fix infinite loop if unmemoized val passed to uDV

The current implementation of useDeferredValue will spawn a new
render any time the input value is different from the previous one. So
if you pass an unmemoized value (like an inline object), it will never
stop spawning new renders.

The fix is to only defer during an urgent render. If we're already
inside a transition, retry, offscreen, or other non-urgen render, then
we can use the latest value.

* Temporarily disable "long nested update" warning

DevTools' timeline profiler warns if an update inside a layout effect
results in an expensive re-render. However, it misattributes renders
that are spawned from a sync render at lower priority. This affects the
new implementation of useDeferredValue but it would also apply to things
like Offscreen.

It's not obvious to me how to fix this given how DevTools models the
idea of a "nested update" so I'm disabling the warning for now to
unblock the bugfix for useDeferredValue.
  • Loading branch information
acdlite authored and rickhanlonii committed Apr 13, 2022
1 parent b45dd8a commit 1f5f497
Show file tree
Hide file tree
Showing 5 changed files with 361 additions and 68 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1463,7 +1463,9 @@ describe('Timeline profiler', () => {
expect(event.warning).toBe(null);
});

it('should warn about long nested (state) updates during layout effects', async () => {
// This is temporarily disabled because the warning doesn't work
// with useDeferredValue
it.skip('should warn about long nested (state) updates during layout effects', async () => {
function Component() {
const [didMount, setDidMount] = React.useState(false);
Scheduler.unstable_yieldValue(
Expand Down Expand Up @@ -1523,7 +1525,9 @@ describe('Timeline profiler', () => {
);
});

it('should warn about long nested (forced) updates during layout effects', async () => {
// This is temporarily disabled because the warning doesn't work
// with useDeferredValue
it.skip('should warn about long nested (forced) updates during layout effects', async () => {
class Component extends React.Component {
_didMount: boolean = false;
componentDidMount() {
Expand Down Expand Up @@ -1654,7 +1658,9 @@ describe('Timeline profiler', () => {
});
});

it('should not warn about deferred value updates scheduled during commit phase', async () => {
// This is temporarily disabled because the warning doesn't work
// with useDeferredValue
it.skip('should not warn about deferred value updates scheduled during commit phase', async () => {
function Component() {
const [value, setValue] = React.useState(0);
const deferredValue = React.useDeferredValue(value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1124,7 +1124,10 @@ export default async function preprocessData(
lane => profilerData.laneToLabelMap.get(lane) === 'Transition',
)
) {
schedulingEvent.warning = WARNING_STRINGS.NESTED_UPDATE;
// FIXME: This warning doesn't account for "nested updates" that are
// spawned by useDeferredValue. Disabling temporarily until we figure
// out the right way to handle this.
// schedulingEvent.warning = WARNING_STRINGS.NESTED_UPDATE;
}
}
});
Expand Down
94 changes: 62 additions & 32 deletions packages/react-reconciler/src/ReactFiberHooks.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import {
NoLanes,
isSubsetOfLanes,
includesBlockingLane,
includesOnlyNonUrgentLanes,
claimNextTransitionLane,
mergeLanes,
removeLanes,
intersectLanes,
Expand Down Expand Up @@ -1929,45 +1931,73 @@ function updateMemo<T>(
}

function mountDeferredValue<T>(value: T): T {
const [prevValue, setValue] = mountState(value);
mountEffect(() => {
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = {};
try {
setValue(value);
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
}
}, [value]);
return prevValue;
const hook = mountWorkInProgressHook();
hook.memoizedState = value;
return value;
}

function updateDeferredValue<T>(value: T): T {
const [prevValue, setValue] = updateState(value);
updateEffect(() => {
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = {};
try {
setValue(value);
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
}
}, [value]);
return prevValue;
const hook = updateWorkInProgressHook();
const resolvedCurrentHook: Hook = (currentHook: any);
const prevValue: T = resolvedCurrentHook.memoizedState;
return updateDeferredValueImpl(hook, prevValue, value);
}

function rerenderDeferredValue<T>(value: T): T {
const [prevValue, setValue] = rerenderState(value);
updateEffect(() => {
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = {};
try {
setValue(value);
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
const hook = updateWorkInProgressHook();
if (currentHook === null) {
// This is a rerender during a mount.
hook.memoizedState = value;
return value;
} else {
// This is a rerender during an update.
const prevValue: T = currentHook.memoizedState;
return updateDeferredValueImpl(hook, prevValue, value);
}
}

function updateDeferredValueImpl<T>(hook: Hook, prevValue: T, value: T): T {
const shouldDeferValue = !includesOnlyNonUrgentLanes(renderLanes);
if (shouldDeferValue) {
// This is an urgent update. If the value has changed, keep using the
// previous value and spawn a deferred render to update it later.

if (!is(value, prevValue)) {
// Schedule a deferred render
const deferredLane = claimNextTransitionLane();
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
deferredLane,
);
markSkippedUpdateLanes(deferredLane);

// Set this to true to indicate that the rendered value is inconsistent
// from the latest value. The name "baseState" doesn't really match how we
// use it because we're reusing a state hook field instead of creating a
// new one.
hook.baseState = true;
}
}, [value]);
return prevValue;

// Reuse the previous value
return prevValue;
} else {
// This is not an urgent update, so we can use the latest value regardless
// of what it is. No need to defer it.

// However, if we're currently inside a spawned render, then we need to mark
// this as an update to prevent the fiber from bailing out.
//
// `baseState` is true when the current value is different from the rendered
// value. The name doesn't really match how we use it because we're reusing
// a state hook field instead of creating a new one.
if (hook.baseState) {
// Flip this back to false.
hook.baseState = false;
markWorkInProgressReceivedUpdate();
}

return value;
}
}

function startTransition(setPending, callback, options) {
Expand Down
94 changes: 62 additions & 32 deletions packages/react-reconciler/src/ReactFiberHooks.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import {
NoLanes,
isSubsetOfLanes,
includesBlockingLane,
includesOnlyNonUrgentLanes,
claimNextTransitionLane,
mergeLanes,
removeLanes,
intersectLanes,
Expand Down Expand Up @@ -1929,45 +1931,73 @@ function updateMemo<T>(
}

function mountDeferredValue<T>(value: T): T {
const [prevValue, setValue] = mountState(value);
mountEffect(() => {
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = {};
try {
setValue(value);
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
}
}, [value]);
return prevValue;
const hook = mountWorkInProgressHook();
hook.memoizedState = value;
return value;
}

function updateDeferredValue<T>(value: T): T {
const [prevValue, setValue] = updateState(value);
updateEffect(() => {
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = {};
try {
setValue(value);
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
}
}, [value]);
return prevValue;
const hook = updateWorkInProgressHook();
const resolvedCurrentHook: Hook = (currentHook: any);
const prevValue: T = resolvedCurrentHook.memoizedState;
return updateDeferredValueImpl(hook, prevValue, value);
}

function rerenderDeferredValue<T>(value: T): T {
const [prevValue, setValue] = rerenderState(value);
updateEffect(() => {
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = {};
try {
setValue(value);
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
const hook = updateWorkInProgressHook();
if (currentHook === null) {
// This is a rerender during a mount.
hook.memoizedState = value;
return value;
} else {
// This is a rerender during an update.
const prevValue: T = currentHook.memoizedState;
return updateDeferredValueImpl(hook, prevValue, value);
}
}

function updateDeferredValueImpl<T>(hook: Hook, prevValue: T, value: T): T {
const shouldDeferValue = !includesOnlyNonUrgentLanes(renderLanes);
if (shouldDeferValue) {
// This is an urgent update. If the value has changed, keep using the
// previous value and spawn a deferred render to update it later.

if (!is(value, prevValue)) {
// Schedule a deferred render
const deferredLane = claimNextTransitionLane();
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
deferredLane,
);
markSkippedUpdateLanes(deferredLane);

// Set this to true to indicate that the rendered value is inconsistent
// from the latest value. The name "baseState" doesn't really match how we
// use it because we're reusing a state hook field instead of creating a
// new one.
hook.baseState = true;
}
}, [value]);
return prevValue;

// Reuse the previous value
return prevValue;
} else {
// This is not an urgent update, so we can use the latest value regardless
// of what it is. No need to defer it.

// However, if we're currently inside a spawned render, then we need to mark
// this as an update to prevent the fiber from bailing out.
//
// `baseState` is true when the current value is different from the rendered
// value. The name doesn't really match how we use it because we're reusing
// a state hook field instead of creating a new one.
if (hook.baseState) {
// Flip this back to false.
hook.baseState = false;
markWorkInProgressReceivedUpdate();
}

return value;
}
}

function startTransition(setPending, callback, options) {
Expand Down
Loading

0 comments on commit 1f5f497

Please sign in to comment.