Skip to content

Commit

Permalink
Use double render to detect render phase mutation
Browse files Browse the repository at this point in the history
PR facebook#20665 added a mechanism to detect when a useMutableSource source is
mutated during the render phase. It relies on the fact that we double
render components during development in Strict Mode. If the version
in the double render doesn't match the first, that indicates there must
have been a mutation during render.

However, I realized during review that is true of all errors that occur
during the double render. A pure component will never throw during the
double render, because if it were pure, it would have also thrown during
the first render... in which case it wouldn't have double rendered!

So instead of tracking and comparing the source's version, we can
instead check if we're inside a double render when the error is thrown.

We probably shouldn't be throwing during the double render at all, since
we know it won't happen in production. (It's still a tearing bug, but
that doesn't mean the component will actually throw.)

I considered suppressing the error entirely, but that requires a larger
conversation about how to handle errors that we know are only possible
in development. I think we should probably be suppressing *all* errors
(with a warning) that occur during a double render.
  • Loading branch information
acdlite committed Jan 30, 2021
1 parent eab1bee commit 657d8a4
Show file tree
Hide file tree
Showing 4 changed files with 83 additions and 67 deletions.
47 changes: 31 additions & 16 deletions packages/react-reconciler/src/ReactFiberHooks.new.js
Expand Up @@ -904,18 +904,6 @@ function readFromUnsubcribedMutableSource<Source, Snapshot>(
const getVersion = source._getVersion;
const version = getVersion(source._source);

let mutableSourceSideEffectDetected = false;
if (__DEV__) {
// Detect side effects that update a mutable source during render.
// See https://github.com/facebook/react/issues/19948
if (source._currentlyRenderingFiber !== currentlyRenderingFiber) {
source._currentlyRenderingFiber = currentlyRenderingFiber;
source._initialVersionAsOfFirstRender = version;
} else if (source._initialVersionAsOfFirstRender !== version) {
mutableSourceSideEffectDetected = true;
}
}

// Is it safe for this component to read from this source during the current render?
let isSafeToReadFromSource = false;

Expand Down Expand Up @@ -978,17 +966,44 @@ function readFromUnsubcribedMutableSource<Source, Snapshot>(
// but there's nothing we can do about that (short of throwing here and refusing to continue the render).
markSourceAsDirty(source);

// Intentioally throw an error to force React to retry synchronously. During
// the synchronous retry, it will block interleaved mutations, so we should
// get a consistent read. Therefore, the following error should never be
// visible to the user.
//
// If it were to become visible to the user, it suggests one of two things:
// a bug in React, or (more likely), a mutation during the render phase that
// caused the second re-render attempt to be different from the first.
//
// We know it's the second case if the logs are currently disabled. So in
// dev, we can present a more accurate error message.
if (__DEV__) {
if (mutableSourceSideEffectDetected) {
// eslint-disable-next-line react-internal/no-production-logging
if (console.log.__reactDisabledLog) {
// If the logs are disabled, this is the dev-only double render. This is
// only reachable if there was a mutation during render. Show a helpful
// error message.
//
// Something interesting to note: because we only double render in
// development, this error will never happen during production. This is
// actually true of all errors that occur during a double render,
// because if the first render had thrown, we would have exited the
// begin phase without double rendering. We should consider suppressing
// any error from a double render (with a warning) to more closely match
// the production behavior.
const componentName = getComponentName(currentlyRenderingFiber.type);
console.warn(
'A mutable source was mutated while the %s component was rendering. This is not supported. ' +
'Move any mutations into event handlers or effects.',
invariant(
false,
'A mutable source was mutated while the %s component was rendering. ' +
'This is not supported. Move any mutations into event handlers ' +
'or effects.',
componentName,
);
}
}

// We expect this error not to be thrown during the synchronous retry,
// because we blocked interleaved mutations.
invariant(
false,
'Cannot read from mutable source during the current render without tearing. This may be a bug in React. Please file an issue.',
Expand Down
47 changes: 31 additions & 16 deletions packages/react-reconciler/src/ReactFiberHooks.old.js
Expand Up @@ -885,18 +885,6 @@ function readFromUnsubcribedMutableSource<Source, Snapshot>(
const getVersion = source._getVersion;
const version = getVersion(source._source);

let mutableSourceSideEffectDetected = false;
if (__DEV__) {
// Detect side effects that update a mutable source during render.
// See https://github.com/facebook/react/issues/19948
if (source._currentlyRenderingFiber !== currentlyRenderingFiber) {
source._currentlyRenderingFiber = currentlyRenderingFiber;
source._initialVersionAsOfFirstRender = version;
} else if (source._initialVersionAsOfFirstRender !== version) {
mutableSourceSideEffectDetected = true;
}
}

// Is it safe for this component to read from this source during the current render?
let isSafeToReadFromSource = false;

Expand Down Expand Up @@ -959,17 +947,44 @@ function readFromUnsubcribedMutableSource<Source, Snapshot>(
// but there's nothing we can do about that (short of throwing here and refusing to continue the render).
markSourceAsDirty(source);

// Intentioally throw an error to force React to retry synchronously. During
// the synchronous retry, it will block interleaved mutations, so we should
// get a consistent read. Therefore, the following error should never be
// visible to the user.
//
// If it were to become visible to the user, it suggests one of two things:
// a bug in React, or (more likely), a mutation during the render phase that
// caused the second re-render attempt to be different from the first.
//
// We know it's the second case if the logs are currently disabled. So in
// dev, we can present a more accurate error message.
if (__DEV__) {
if (mutableSourceSideEffectDetected) {
// eslint-disable-next-line react-internal/no-production-logging
if (console.log.__reactDisabledLog) {
// If the logs are disabled, this is the dev-only double render. This is
// only reachable if there was a mutation during render. Show a helpful
// error message.
//
// Something interesting to note: because we only double render in
// development, this error will never happen during production. This is
// actually true of all errors that occur during a double render,
// because if the first render had thrown, we would have exited the
// begin phase without double rendering. We should consider suppressing
// any error from a double render (with a warning) to more closely match
// the production behavior.
const componentName = getComponentName(currentlyRenderingFiber.type);
console.warn(
'A mutable source was mutated while the %s component was rendering. This is not supported. ' +
'Move any mutations into event handlers or effects.',
invariant(
false,
'A mutable source was mutated while the %s component was rendering. ' +
'This is not supported. Move any mutations into event handlers ' +
'or effects.',
componentName,
);
}
}

// We expect this error not to be thrown during the synchronous retry,
// because we blocked interleaved mutations.
invariant(
false,
'Cannot read from mutable source during the current render without tearing. This may be a bug in React. Please file an issue.',
Expand Down
Expand Up @@ -1744,24 +1744,14 @@ describe('useMutableSource', () => {
}

expect(() => {
expect(() => {
act(() => {
ReactNoop.render(<MutateDuringRead />);
});
}).toThrow(
'Cannot read from mutable source during the current render without tearing. This may be a bug in React. Please file an issue.',
);
}).toWarnDev([
// Warns twice because of the retry-on-error render pass. Should
// consider only warning during the first attempt, not during the
// retry. Or maybe vice versa.
'A mutable source was mutated while the MutateDuringRead component was rendering. This is not supported. ' +
'Move any mutations into event handlers or effects.\n' +
' in MutateDuringRead (at **)',
'A mutable source was mutated while the MutateDuringRead component was rendering. This is not supported. ' +
'Move any mutations into event handlers or effects.\n' +
' in MutateDuringRead (at **)',
]);
act(() => {
ReactNoop.render(<MutateDuringRead />);
});
}).toThrow(
'A mutable source was mutated while the MutateDuringRead component ' +
'was rendering. This is not supported. Move any mutations into ' +
'event handlers or effects.',
);

expect(Scheduler).toHaveYielded([
'MutateDuringRead:initial',
Expand Down Expand Up @@ -1792,21 +1782,17 @@ describe('useMutableSource', () => {
}

expect(() => {
expect(() => {
act(() => {
ReactNoop.renderLegacySyncRoot(
<React.StrictMode>
<MutateDuringRead />
</React.StrictMode>,
);
});
}).toThrow(
'Cannot read from mutable source during the current render without tearing. This may be a bug in React. Please file an issue.',
);
}).toWarnDev(
'A mutable source was mutated while the MutateDuringRead component was rendering. This is not supported. ' +
'Move any mutations into event handlers or effects.\n' +
' in MutateDuringRead (at **)',
act(() => {
ReactNoop.renderLegacySyncRoot(
<React.StrictMode>
<MutateDuringRead />
</React.StrictMode>,
);
});
}).toThrow(
'A mutable source was mutated while the MutateDuringRead component ' +
'was rendering. This is not supported. Move any mutations into ' +
'event handlers or effects.',
);

expect(Scheduler).toHaveYielded(['MutateDuringRead:initial']);
Expand Down
4 changes: 2 additions & 2 deletions scripts/error-codes/codes.json
Expand Up @@ -339,7 +339,7 @@
"345": "Root did not complete. This is a bug in React.",
"348": "ensureListeningTo(): received a container that was not an element node. This is likely a bug in React.",
"349": "Expected a work-in-progress root. This is a bug in React. Please file an issue.",
"350": "Cannot read from mutable source during the current render without tearing. This is a bug in React. Please file an issue.",
"350": "Cannot read from mutable source during the current render without tearing. This may be a bug in React. Please file an issue.",
"351": "Unsupported server component type: %s",
"352": "React Lazy Components are not yet supported on the server.",
"353": "A server block should never encode any other slots. This is a bug in React.",
Expand Down Expand Up @@ -373,5 +373,5 @@
"382": "This query has received more parameters than the last time the same query was used. Always pass the exact number of parameters that the query needs.",
"383": "This query has received fewer parameters than the last time the same query was used. Always pass the exact number of parameters that the query needs.",
"384": "Refreshing the cache is not supported in Server Components.",
"385": "Cannot read from mutable source during the current render without tearing. This may be a bug in React. Please file an issue."
"385": "A mutable source was mutated while the %s component was rendering. This is not supported. Move any mutations into event handlers or effects."
}

0 comments on commit 657d8a4

Please sign in to comment.