Skip to content

Commit 1f5f497

Browse files
acdliterickhanlonii
authored andcommitted
Fix infinite update loop that happens when an unmemoized value is passed 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.
1 parent b45dd8a commit 1f5f497

File tree

5 files changed

+361
-68
lines changed

5 files changed

+361
-68
lines changed

packages/react-devtools-shared/src/__tests__/preprocessData-test.js

+9-3
Original file line numberDiff line numberDiff line change
@@ -1463,7 +1463,9 @@ describe('Timeline profiler', () => {
14631463
expect(event.warning).toBe(null);
14641464
});
14651465

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

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

1657-
it('should not warn about deferred value updates scheduled during commit phase', async () => {
1661+
// This is temporarily disabled because the warning doesn't work
1662+
// with useDeferredValue
1663+
it.skip('should not warn about deferred value updates scheduled during commit phase', async () => {
16581664
function Component() {
16591665
const [value, setValue] = React.useState(0);
16601666
const deferredValue = React.useDeferredValue(value);

packages/react-devtools-timeline/src/import-worker/preprocessData.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -1124,7 +1124,10 @@ export default async function preprocessData(
11241124
lane => profilerData.laneToLabelMap.get(lane) === 'Transition',
11251125
)
11261126
) {
1127-
schedulingEvent.warning = WARNING_STRINGS.NESTED_UPDATE;
1127+
// FIXME: This warning doesn't account for "nested updates" that are
1128+
// spawned by useDeferredValue. Disabling temporarily until we figure
1129+
// out the right way to handle this.
1130+
// schedulingEvent.warning = WARNING_STRINGS.NESTED_UPDATE;
11281131
}
11291132
}
11301133
});

packages/react-reconciler/src/ReactFiberHooks.new.js

+62-32
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ import {
4747
NoLanes,
4848
isSubsetOfLanes,
4949
includesBlockingLane,
50+
includesOnlyNonUrgentLanes,
51+
claimNextTransitionLane,
5052
mergeLanes,
5153
removeLanes,
5254
intersectLanes,
@@ -1929,45 +1931,73 @@ function updateMemo<T>(
19291931
}
19301932

19311933
function mountDeferredValue<T>(value: T): T {
1932-
const [prevValue, setValue] = mountState(value);
1933-
mountEffect(() => {
1934-
const prevTransition = ReactCurrentBatchConfig.transition;
1935-
ReactCurrentBatchConfig.transition = {};
1936-
try {
1937-
setValue(value);
1938-
} finally {
1939-
ReactCurrentBatchConfig.transition = prevTransition;
1940-
}
1941-
}, [value]);
1942-
return prevValue;
1934+
const hook = mountWorkInProgressHook();
1935+
hook.memoizedState = value;
1936+
return value;
19431937
}
19441938

19451939
function updateDeferredValue<T>(value: T): T {
1946-
const [prevValue, setValue] = updateState(value);
1947-
updateEffect(() => {
1948-
const prevTransition = ReactCurrentBatchConfig.transition;
1949-
ReactCurrentBatchConfig.transition = {};
1950-
try {
1951-
setValue(value);
1952-
} finally {
1953-
ReactCurrentBatchConfig.transition = prevTransition;
1954-
}
1955-
}, [value]);
1956-
return prevValue;
1940+
const hook = updateWorkInProgressHook();
1941+
const resolvedCurrentHook: Hook = (currentHook: any);
1942+
const prevValue: T = resolvedCurrentHook.memoizedState;
1943+
return updateDeferredValueImpl(hook, prevValue, value);
19571944
}
19581945

19591946
function rerenderDeferredValue<T>(value: T): T {
1960-
const [prevValue, setValue] = rerenderState(value);
1961-
updateEffect(() => {
1962-
const prevTransition = ReactCurrentBatchConfig.transition;
1963-
ReactCurrentBatchConfig.transition = {};
1964-
try {
1965-
setValue(value);
1966-
} finally {
1967-
ReactCurrentBatchConfig.transition = prevTransition;
1947+
const hook = updateWorkInProgressHook();
1948+
if (currentHook === null) {
1949+
// This is a rerender during a mount.
1950+
hook.memoizedState = value;
1951+
return value;
1952+
} else {
1953+
// This is a rerender during an update.
1954+
const prevValue: T = currentHook.memoizedState;
1955+
return updateDeferredValueImpl(hook, prevValue, value);
1956+
}
1957+
}
1958+
1959+
function updateDeferredValueImpl<T>(hook: Hook, prevValue: T, value: T): T {
1960+
const shouldDeferValue = !includesOnlyNonUrgentLanes(renderLanes);
1961+
if (shouldDeferValue) {
1962+
// This is an urgent update. If the value has changed, keep using the
1963+
// previous value and spawn a deferred render to update it later.
1964+
1965+
if (!is(value, prevValue)) {
1966+
// Schedule a deferred render
1967+
const deferredLane = claimNextTransitionLane();
1968+
currentlyRenderingFiber.lanes = mergeLanes(
1969+
currentlyRenderingFiber.lanes,
1970+
deferredLane,
1971+
);
1972+
markSkippedUpdateLanes(deferredLane);
1973+
1974+
// Set this to true to indicate that the rendered value is inconsistent
1975+
// from the latest value. The name "baseState" doesn't really match how we
1976+
// use it because we're reusing a state hook field instead of creating a
1977+
// new one.
1978+
hook.baseState = true;
19681979
}
1969-
}, [value]);
1970-
return prevValue;
1980+
1981+
// Reuse the previous value
1982+
return prevValue;
1983+
} else {
1984+
// This is not an urgent update, so we can use the latest value regardless
1985+
// of what it is. No need to defer it.
1986+
1987+
// However, if we're currently inside a spawned render, then we need to mark
1988+
// this as an update to prevent the fiber from bailing out.
1989+
//
1990+
// `baseState` is true when the current value is different from the rendered
1991+
// value. The name doesn't really match how we use it because we're reusing
1992+
// a state hook field instead of creating a new one.
1993+
if (hook.baseState) {
1994+
// Flip this back to false.
1995+
hook.baseState = false;
1996+
markWorkInProgressReceivedUpdate();
1997+
}
1998+
1999+
return value;
2000+
}
19712001
}
19722002

19732003
function startTransition(setPending, callback, options) {

packages/react-reconciler/src/ReactFiberHooks.old.js

+62-32
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ import {
4747
NoLanes,
4848
isSubsetOfLanes,
4949
includesBlockingLane,
50+
includesOnlyNonUrgentLanes,
51+
claimNextTransitionLane,
5052
mergeLanes,
5153
removeLanes,
5254
intersectLanes,
@@ -1929,45 +1931,73 @@ function updateMemo<T>(
19291931
}
19301932

19311933
function mountDeferredValue<T>(value: T): T {
1932-
const [prevValue, setValue] = mountState(value);
1933-
mountEffect(() => {
1934-
const prevTransition = ReactCurrentBatchConfig.transition;
1935-
ReactCurrentBatchConfig.transition = {};
1936-
try {
1937-
setValue(value);
1938-
} finally {
1939-
ReactCurrentBatchConfig.transition = prevTransition;
1940-
}
1941-
}, [value]);
1942-
return prevValue;
1934+
const hook = mountWorkInProgressHook();
1935+
hook.memoizedState = value;
1936+
return value;
19431937
}
19441938

19451939
function updateDeferredValue<T>(value: T): T {
1946-
const [prevValue, setValue] = updateState(value);
1947-
updateEffect(() => {
1948-
const prevTransition = ReactCurrentBatchConfig.transition;
1949-
ReactCurrentBatchConfig.transition = {};
1950-
try {
1951-
setValue(value);
1952-
} finally {
1953-
ReactCurrentBatchConfig.transition = prevTransition;
1954-
}
1955-
}, [value]);
1956-
return prevValue;
1940+
const hook = updateWorkInProgressHook();
1941+
const resolvedCurrentHook: Hook = (currentHook: any);
1942+
const prevValue: T = resolvedCurrentHook.memoizedState;
1943+
return updateDeferredValueImpl(hook, prevValue, value);
19571944
}
19581945

19591946
function rerenderDeferredValue<T>(value: T): T {
1960-
const [prevValue, setValue] = rerenderState(value);
1961-
updateEffect(() => {
1962-
const prevTransition = ReactCurrentBatchConfig.transition;
1963-
ReactCurrentBatchConfig.transition = {};
1964-
try {
1965-
setValue(value);
1966-
} finally {
1967-
ReactCurrentBatchConfig.transition = prevTransition;
1947+
const hook = updateWorkInProgressHook();
1948+
if (currentHook === null) {
1949+
// This is a rerender during a mount.
1950+
hook.memoizedState = value;
1951+
return value;
1952+
} else {
1953+
// This is a rerender during an update.
1954+
const prevValue: T = currentHook.memoizedState;
1955+
return updateDeferredValueImpl(hook, prevValue, value);
1956+
}
1957+
}
1958+
1959+
function updateDeferredValueImpl<T>(hook: Hook, prevValue: T, value: T): T {
1960+
const shouldDeferValue = !includesOnlyNonUrgentLanes(renderLanes);
1961+
if (shouldDeferValue) {
1962+
// This is an urgent update. If the value has changed, keep using the
1963+
// previous value and spawn a deferred render to update it later.
1964+
1965+
if (!is(value, prevValue)) {
1966+
// Schedule a deferred render
1967+
const deferredLane = claimNextTransitionLane();
1968+
currentlyRenderingFiber.lanes = mergeLanes(
1969+
currentlyRenderingFiber.lanes,
1970+
deferredLane,
1971+
);
1972+
markSkippedUpdateLanes(deferredLane);
1973+
1974+
// Set this to true to indicate that the rendered value is inconsistent
1975+
// from the latest value. The name "baseState" doesn't really match how we
1976+
// use it because we're reusing a state hook field instead of creating a
1977+
// new one.
1978+
hook.baseState = true;
19681979
}
1969-
}, [value]);
1970-
return prevValue;
1980+
1981+
// Reuse the previous value
1982+
return prevValue;
1983+
} else {
1984+
// This is not an urgent update, so we can use the latest value regardless
1985+
// of what it is. No need to defer it.
1986+
1987+
// However, if we're currently inside a spawned render, then we need to mark
1988+
// this as an update to prevent the fiber from bailing out.
1989+
//
1990+
// `baseState` is true when the current value is different from the rendered
1991+
// value. The name doesn't really match how we use it because we're reusing
1992+
// a state hook field instead of creating a new one.
1993+
if (hook.baseState) {
1994+
// Flip this back to false.
1995+
hook.baseState = false;
1996+
markWorkInProgressReceivedUpdate();
1997+
}
1998+
1999+
return value;
2000+
}
19712001
}
19722002

19732003
function startTransition(setPending, callback, options) {

0 commit comments

Comments
 (0)