Skip to content

Commit

Permalink
Make root.unmount() synchronous (#22444)
Browse files Browse the repository at this point in the history
* Move flushSync warning to React DOM

When you call in `flushSync` from an effect, React fires a warning. I've
moved the implementation of this warning out of the reconciler and into
React DOM.

`flushSync` is a renderer API, not an isomorphic API, because it has
behavior that was designed specifically for the constraints of React
DOM. The equivalent API in a different renderer may not be the same.
For example, React Native has a different threading model than the
browser, so it might not make sense to expose a `flushSync` API to the
JavaScript thread.

* Make root.unmount() synchronous

When you unmount a root, the internal state that React stores on the
DOM node is immediately cleared. So, we should also synchronously
delete the React tree. You should be able to create a new root using
the same container.
  • Loading branch information
acdlite committed Sep 27, 2021
1 parent 2cc6d79 commit d3e0869
Show file tree
Hide file tree
Showing 13 changed files with 177 additions and 79 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,9 @@ describe('StoreStressConcurrent', () => {
// 1. Capture the expected render result.
const snapshots = [];
let container = document.createElement('div');
// $FlowFixMe
let root = ReactDOM.createRoot(container);
for (let i = 0; i < steps.length; i++) {
// $FlowFixMe
const root = ReactDOM.createRoot(container);
act(() => root.render(<Root>{steps[i]}</Root>));
// We snapshot each step once so it doesn't regress.
snapshots.push(print(store));
Expand Down Expand Up @@ -320,7 +320,7 @@ describe('StoreStressConcurrent', () => {
for (let j = 0; j < steps.length; j++) {
container = document.createElement('div');
// $FlowFixMe
root = ReactDOM.createRoot(container);
const root = ReactDOM.createRoot(container);
act(() => root.render(<Root>{steps[i]}</Root>));
expect(print(store)).toMatch(snapshots[i]);
act(() => root.render(<Root>{steps[j]}</Root>));
Expand All @@ -337,7 +337,7 @@ describe('StoreStressConcurrent', () => {
for (let j = 0; j < steps.length; j++) {
container = document.createElement('div');
// $FlowFixMe
root = ReactDOM.createRoot(container);
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand Down Expand Up @@ -408,9 +408,9 @@ describe('StoreStressConcurrent', () => {
// This is the only step where we use Jest snapshots.
const snapshots = [];
let container = document.createElement('div');
// $FlowFixMe
let root = ReactDOM.createRoot(container);
for (let i = 0; i < steps.length; i++) {
// $FlowFixMe
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand Down Expand Up @@ -512,6 +512,8 @@ describe('StoreStressConcurrent', () => {

// 2. Verify check Suspense can render same steps as initial fallback content.
for (let i = 0; i < steps.length; i++) {
// $FlowFixMe
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand All @@ -536,7 +538,7 @@ describe('StoreStressConcurrent', () => {
// Always start with a fresh container and steps[i].
container = document.createElement('div');
// $FlowFixMe
root = ReactDOM.createRoot(container);
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand Down Expand Up @@ -582,7 +584,7 @@ describe('StoreStressConcurrent', () => {
// Always start with a fresh container and steps[i].
container = document.createElement('div');
// $FlowFixMe
root = ReactDOM.createRoot(container);
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand Down Expand Up @@ -640,7 +642,7 @@ describe('StoreStressConcurrent', () => {
// Always start with a fresh container and steps[i].
container = document.createElement('div');
// $FlowFixMe
root = ReactDOM.createRoot(container);
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand Down Expand Up @@ -690,7 +692,7 @@ describe('StoreStressConcurrent', () => {
// Always start with a fresh container and steps[i].
container = document.createElement('div');
// $FlowFixMe
root = ReactDOM.createRoot(container);
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand Down Expand Up @@ -744,7 +746,7 @@ describe('StoreStressConcurrent', () => {
// Always start with a fresh container and steps[i].
container = document.createElement('div');
// $FlowFixMe
root = ReactDOM.createRoot(container);
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand Down Expand Up @@ -897,9 +899,9 @@ describe('StoreStressConcurrent', () => {
// This is the only step where we use Jest snapshots.
const snapshots = [];
let container = document.createElement('div');
// $FlowFixMe
let root = ReactDOM.createRoot(container);
for (let i = 0; i < steps.length; i++) {
// $FlowFixMe
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand All @@ -922,6 +924,8 @@ describe('StoreStressConcurrent', () => {
// which is different from the snapshots above. So we take more snapshots.
const fallbackSnapshots = [];
for (let i = 0; i < steps.length; i++) {
// $FlowFixMe
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand Down Expand Up @@ -1055,7 +1059,7 @@ describe('StoreStressConcurrent', () => {
// Always start with a fresh container and steps[i].
container = document.createElement('div');
// $FlowFixMe
root = ReactDOM.createRoot(container);
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand Down Expand Up @@ -1107,7 +1111,7 @@ describe('StoreStressConcurrent', () => {
// Always start with a fresh container and steps[i].
container = document.createElement('div');
// $FlowFixMe
root = ReactDOM.createRoot(container);
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand Down Expand Up @@ -1174,7 +1178,7 @@ describe('StoreStressConcurrent', () => {
// Always start with a fresh container and steps[i].
container = document.createElement('div');
// $FlowFixMe
root = ReactDOM.createRoot(container);
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand Down Expand Up @@ -1226,7 +1230,7 @@ describe('StoreStressConcurrent', () => {
// Always start with a fresh container and steps[i].
container = document.createElement('div');
// $FlowFixMe
root = ReactDOM.createRoot(container);
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand Down Expand Up @@ -1278,7 +1282,7 @@ describe('StoreStressConcurrent', () => {
// Always start with a fresh container and steps[i].
container = document.createElement('div');
// $FlowFixMe
root = ReactDOM.createRoot(container);
const root = ReactDOM.createRoot(container);
act(() =>
root.render(
<Root>
Expand Down
60 changes: 60 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMRoot-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ let ReactDOM = require('react-dom');
let ReactDOMServer = require('react-dom/server');
let Scheduler = require('scheduler');
let act;
let useEffect;

describe('ReactDOMRoot', () => {
let container;
Expand All @@ -26,6 +27,7 @@ describe('ReactDOMRoot', () => {
ReactDOMServer = require('react-dom/server');
Scheduler = require('scheduler');
act = require('jest-react').act;
useEffect = React.useEffect;
});

it('renders children', () => {
Expand Down Expand Up @@ -342,4 +344,62 @@ describe('ReactDOMRoot', () => {
});
expect(container.textContent).toEqual('b');
});

it('unmount is synchronous', async () => {
const root = ReactDOM.createRoot(container);
await act(async () => {
root.render('Hi');
});
expect(container.textContent).toEqual('Hi');

await act(async () => {
root.unmount();
// Should have already unmounted
expect(container.textContent).toEqual('');
});
});

it('throws if an unmounted root is updated', async () => {
const root = ReactDOM.createRoot(container);
await act(async () => {
root.render('Hi');
});
expect(container.textContent).toEqual('Hi');

root.unmount();

expect(() => root.render("I'm back")).toThrow(
'Cannot update an unmounted root.',
);
});

it('warns if root is unmounted inside an effect', async () => {
const container1 = document.createElement('div');
const root1 = ReactDOM.createRoot(container1);
const container2 = document.createElement('div');
const root2 = ReactDOM.createRoot(container2);

function App({step}) {
useEffect(() => {
if (step === 2) {
root2.unmount();
}
}, [step]);
return 'Hi';
}

await act(async () => {
root1.render(<App step={1} />);
});
expect(container1.textContent).toEqual('Hi');

expect(() => {
ReactDOM.flushSync(() => {
root1.render(<App step={2} />);
});
}).toErrorDev(
'Attempted to synchronously unmount a root while React was ' +
'already rendering.',
);
});
});
23 changes: 21 additions & 2 deletions packages/react-dom/src/client/ReactDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import {createEventHandle} from './ReactDOMEventHandle';
import {
batchedUpdates,
discreteUpdates,
flushSync,
flushSyncWithoutWarningIfAlreadyRendering,
flushSync as flushSyncWithoutWarningIfAlreadyRendering,
isAlreadyRendering,
flushControlled,
injectIntoDevTools,
attemptSynchronousHydration,
Expand Down Expand Up @@ -163,6 +163,25 @@ const Internals = {
],
};

// Overload the definition to the two valid signatures.
// Warning, this opts-out of checking the function body.
declare function flushSync<R>(fn: () => R): R;
// eslint-disable-next-line no-redeclare
declare function flushSync(): void;
// eslint-disable-next-line no-redeclare
function flushSync(fn) {
if (__DEV__) {
if (isAlreadyRendering()) {
console.error(
'flushSync was called from inside a lifecycle method. React cannot ' +
'flush when React is already rendering. Consider moving this call to ' +
'a scheduler task or micro task.',
);
}
}
return flushSyncWithoutWarningIfAlreadyRendering(fn);
}

export {
createPortal,
batchedUpdates as unstable_batchedUpdates,
Expand Down
6 changes: 3 additions & 3 deletions packages/react-dom/src/client/ReactDOMLegacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
createContainer,
findHostInstanceWithNoPortals,
updateContainer,
flushSyncWithoutWarningIfAlreadyRendering,
flushSync,
getPublicRootInstance,
findHostInstance,
findHostInstanceWithWarning,
Expand Down Expand Up @@ -174,7 +174,7 @@ function legacyRenderSubtreeIntoContainer(
};
}
// Initial mount should not be batched.
flushSyncWithoutWarningIfAlreadyRendering(() => {
flushSync(() => {
updateContainer(children, fiberRoot, parentComponent, callback);
});
} else {
Expand Down Expand Up @@ -357,7 +357,7 @@ export function unmountComponentAtNode(container: Container) {
}

// Unmount should not be batched.
flushSyncWithoutWarningIfAlreadyRendering(() => {
flushSync(() => {
legacyRenderSubtreeIntoContainer(null, null, container, false, () => {
// $FlowFixMe This should probably use `delete container._reactRootContainer`
container._reactRootContainer = null;
Expand Down
29 changes: 24 additions & 5 deletions packages/react-dom/src/client/ReactDOMRoot.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes';
export type RootType = {
render(children: ReactNodeList): void,
unmount(): void,
_internalRoot: FiberRoot,
_internalRoot: FiberRoot | null,
...
};

Expand Down Expand Up @@ -62,17 +62,23 @@ import {
updateContainer,
findHostInstanceWithNoPortals,
registerMutableSourceForHydration,
flushSync,
isAlreadyRendering,
} from 'react-reconciler/src/ReactFiberReconciler';
import invariant from 'shared/invariant';
import {ConcurrentRoot} from 'react-reconciler/src/ReactRootTags';
import {allowConcurrentByDefault} from 'shared/ReactFeatureFlags';

function ReactDOMRoot(internalRoot) {
function ReactDOMRoot(internalRoot: FiberRoot) {
this._internalRoot = internalRoot;
}

ReactDOMRoot.prototype.render = function(children: ReactNodeList): void {
const root = this._internalRoot;
if (root === null) {
invariant(false, 'Cannot update an unmounted root.');
}

if (__DEV__) {
if (typeof arguments[1] === 'function') {
console.error(
Expand Down Expand Up @@ -109,10 +115,23 @@ ReactDOMRoot.prototype.unmount = function(): void {
}
}
const root = this._internalRoot;
const container = root.containerInfo;
updateContainer(null, root, null, () => {
if (root !== null) {
this._internalRoot = null;
const container = root.containerInfo;
if (__DEV__) {
if (isAlreadyRendering()) {
console.error(
'Attempted to synchronously unmount a root while React was already ' +
'rendering. React cannot finish unmounting the root until the ' +
'current render has completed, which may lead to a race condition.',
);
}
}
flushSync(() => {
updateContainer(null, root, null, null);
});
unmarkContainerAsRoot(container);
});
}
};

export function createRoot(
Expand Down
15 changes: 14 additions & 1 deletion packages/react-noop-renderer/src/createReactNoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,19 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
return children;
}

function flushSync<R>(fn: () => R): R {
if (__DEV__) {
if (NoopRenderer.isAlreadyRendering()) {
console.error(
'flushSync was called from inside a lifecycle method. React cannot ' +
'flush when React is already rendering. Consider moving this call to ' +
'a scheduler task or micro task.',
);
}
}
return NoopRenderer.flushSync(fn);
}

let idCounter = 0;

const ReactNoop = {
Expand Down Expand Up @@ -1136,7 +1149,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
}
},

flushSync: NoopRenderer.flushSync,
flushSync,
flushPassiveEffects: NoopRenderer.flushPassiveEffects,

// Logs the current state of the tree.
Expand Down
Loading

0 comments on commit d3e0869

Please sign in to comment.