/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
let React;
let ReactNoop;
let Scheduler;
let act;
let Suspense;
let getCacheForType;
let caches;
let seededCache;
let ErrorBoundary;
let waitForAll;
let waitFor;
let assertLog;
// TODO: These tests don't pass in persistent mode yet. Need to implement.
describe('ReactSuspenseEffectsSemantics', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
act = require('internal-test-utils').act;
Suspense = React.Suspense;
getCacheForType = React.unstable_getCacheForType;
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
waitFor = InternalTestUtils.waitFor;
assertLog = InternalTestUtils.assertLog;
caches = [];
seededCache = null;
ErrorBoundary = class extends React.Component {
state = {error: null};
componentDidCatch(error) {
this.setState({error});
}
render() {
if (this.state.error) {
Scheduler.log('ErrorBoundary render: catch');
return this.props.fallback;
}
Scheduler.log('ErrorBoundary render: try');
return this.props.children;
}
};
});
function createTextCache() {
if (seededCache !== null) {
// Trick to seed a cache before it exists.
// TODO: Need a built-in API to seed data before the initial render (i.e.
// not a refresh because nothing has mounted yet).
const cache = seededCache;
seededCache = null;
return cache;
}
const data = new Map();
const version = caches.length + 1;
const cache = {
version,
data,
resolve(text) {
const record = data.get(text);
if (record === undefined) {
const newRecord = {
status: 'resolved',
value: text,
};
data.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'resolved';
record.value = text;
thenable.pings.forEach(t => t());
}
},
reject(text, error) {
const record = data.get(text);
if (record === undefined) {
const newRecord = {
status: 'rejected',
value: error,
};
data.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'rejected';
record.value = error;
thenable.pings.forEach(t => t());
}
},
};
caches.push(cache);
return cache;
}
function readText(text) {
const textCache = getCacheForType(createTextCache);
const record = textCache.data.get(text);
if (record !== undefined) {
switch (record.status) {
case 'pending':
Scheduler.log(`Suspend:${text}`);
throw record.value;
case 'rejected':
Scheduler.log(`Error:${text}`);
throw record.value;
case 'resolved':
return textCache.version;
}
} else {
Scheduler.log(`Suspend:${text}`);
const thenable = {
pings: [],
then(resolve) {
if (newRecord.status === 'pending') {
thenable.pings.push(resolve);
} else {
Promise.resolve().then(() => resolve(newRecord.value));
}
},
};
const newRecord = {
status: 'pending',
value: thenable,
};
textCache.data.set(text, newRecord);
throw thenable;
}
}
function Text({children = null, text}) {
Scheduler.log(`Text:${text} render`);
React.useLayoutEffect(() => {
Scheduler.log(`Text:${text} create layout`);
return () => {
Scheduler.log(`Text:${text} destroy layout`);
};
}, []);
React.useEffect(() => {
Scheduler.log(`Text:${text} create passive`);
return () => {
Scheduler.log(`Text:${text} destroy passive`);
};
}, []);
return <span prop={text}>{children}</span>;
}
function AsyncText({children = null, text}) {
readText(text);
Scheduler.log(`AsyncText:${text} render`);
React.useLayoutEffect(() => {
Scheduler.log(`AsyncText:${text} create layout`);
return () => {
Scheduler.log(`AsyncText:${text} destroy layout`);
};
}, []);
React.useEffect(() => {
Scheduler.log(`AsyncText:${text} create passive`);
return () => {
Scheduler.log(`AsyncText:${text} destroy passive`);
};
}, []);
return <span prop={text}>{children}</span>;
}
function resolveMostRecentTextCache(text) {
if (caches.length === 0) {
throw Error('Cache does not exist.');
} else {
// Resolve the most recently created cache. An older cache can by
// resolved with `caches[index].resolve(text)`.
caches[caches.length - 1].resolve(text);
}
}
const resolveText = resolveMostRecentTextCache;
function advanceTimers(ms) {
// Note: This advances Jest's virtual time but not React's. Use
// ReactNoop.expire for that.
if (typeof ms !== 'number') {
throw new Error('Must specify ms');
}
jest.advanceTimersByTime(ms);
// Wait until the end of the current tick
// We cannot use a timer since we're faking them
return Promise.resolve().then(() => {});
}
describe('when a component suspends during initial mount', () => {
// @gate enableLegacyCache
it('should not change behavior in concurrent mode', async () => {
class ClassText extends React.Component {
componentDidMount() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentDidMount`);
}
componentDidUpdate() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentDidUpdate`);
}
componentWillUnmount() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentWillUnmount`);
}
render() {
const {children, text} = this.props;
Scheduler.log(`ClassText:${text} render`);
return <span prop={text}>{children}</span>;
}
}
function App({children = null}) {
Scheduler.log('App render');
React.useLayoutEffect(() => {
Scheduler.log('App create layout');
return () => {
Scheduler.log('App destroy layout');
};
}, []);
React.useEffect(() => {
Scheduler.log('App create passive');
return () => {
Scheduler.log('App destroy passive');
};
}, []);
return (
<>
<Suspense fallback={<Text text="Fallback" />}>
<Text text="Inside:Before" />
{children}
<ClassText text="Inside:After" />
</Suspense>
<Text text="Outside" />
</>
);
}
// Mount and suspend.
await act(() => {
ReactNoop.render(
<App>
<AsyncText text="Async" ms={1000} />
</App>,
);
});
assertLog([
'App render',
'Text:Inside:Before render',
'Suspend:Async',
'Text:Fallback render',
'Text:Outside render',
'Text:Fallback create layout',
'Text:Outside create layout',
'App create layout',
'Text:Fallback create passive',
'Text:Outside create passive',
'App create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Fallback" />
<span prop="Outside" />
</>,
);
// Resolving the suspended resource should
await act(async () => {
await resolveText('Async');
});
assertLog([
'Text:Inside:Before render',
'AsyncText:Async render',
'ClassText:Inside:After render',
'Text:Fallback destroy layout',
'Text:Inside:Before create layout',
'AsyncText:Async create layout',
'ClassText:Inside:After componentDidMount',
'Text:Fallback destroy passive',
'Text:Inside:Before create passive',
'AsyncText:Async create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" />
<span prop="Async" />
<span prop="Inside:After" />
<span prop="Outside" />
</>,
);
await act(() => {
ReactNoop.render(null);
});
assertLog([
'App destroy layout',
'Text:Inside:Before destroy layout',
'AsyncText:Async destroy layout',
'ClassText:Inside:After componentWillUnmount',
'Text:Outside destroy layout',
'App destroy passive',
'Text:Inside:Before destroy passive',
'AsyncText:Async destroy passive',
'Text:Outside destroy passive',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
});
// @gate enableLegacyCache
it('should not change behavior in sync', async () => {
class ClassText extends React.Component {
componentDidMount() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentDidMount`);
}
componentDidUpdate() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentDidUpdate`);
}
componentWillUnmount() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentWillUnmount`);
}
render() {
const {children, text} = this.props;
Scheduler.log(`ClassText:${text} render`);
return <span prop={text}>{children}</span>;
}
}
function App({children = null}) {
Scheduler.log('App render');
React.useLayoutEffect(() => {
Scheduler.log('App create layout');
return () => {
Scheduler.log('App destroy layout');
};
}, []);
React.useEffect(() => {
Scheduler.log('App create passive');
return () => {
Scheduler.log('App destroy passive');
};
}, []);
return (
<>
<Suspense fallback={<Text text="Fallback" />}>
<Text text="Inside:Before" />
{children}
<ClassText text="Inside:After" />
</Suspense>
<Text text="Outside" />
</>
);
}
// Mount and suspend.
await act(() => {
ReactNoop.renderLegacySyncRoot(
<App>
<AsyncText text="Async" ms={1000} />
</App>,
);
});
assertLog([
'App render',
'Text:Inside:Before render',
'Suspend:Async',
'ClassText:Inside:After render',
'Text:Fallback render',
'Text:Outside render',
'Text:Inside:Before create layout',
'ClassText:Inside:After componentDidMount',
'Text:Fallback create layout',
'Text:Outside create layout',
'App create layout',
'Text:Inside:Before create passive',
'Text:Fallback create passive',
'Text:Outside create passive',
'App create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" hidden={true} />
<span prop="Inside:After" hidden={true} />
<span prop="Fallback" />
<span prop="Outside" />
</>,
);
// Resolving the suspended resource should
await act(async () => {
await resolveText('Async');
});
assertLog([
'AsyncText:Async render',
'Text:Fallback destroy layout',
'AsyncText:Async create layout',
'Text:Fallback destroy passive',
'AsyncText:Async create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" />
<span prop="Async" />
<span prop="Inside:After" />
<span prop="Outside" />
</>,
);
await act(() => {
ReactNoop.renderLegacySyncRoot(null);
});
assertLog([
'App destroy layout',
'Text:Inside:Before destroy layout',
'AsyncText:Async destroy layout',
'ClassText:Inside:After componentWillUnmount',
'Text:Outside destroy layout',
'App destroy passive',
'Text:Inside:Before destroy passive',
'AsyncText:Async destroy passive',
'Text:Outside destroy passive',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
});
});
describe('layout effects within a tree that re-suspends in an update', () => {
// @gate enableLegacyCache
it('should not be destroyed or recreated in legacy roots', async () => {
function App({children = null}) {
Scheduler.log('App render');
React.useLayoutEffect(() => {
Scheduler.log('App create layout');
return () => {
Scheduler.log('App destroy layout');
};
}, []);
React.useEffect(() => {
Scheduler.log('App create passive');
return () => {
Scheduler.log('App destroy passive');
};
}, []);
return (
<>
<Suspense fallback={<Text text="Fallback" />}>
<Text text="Inside:Before" />
{children}
<Text text="Inside:After" />
</Suspense>
<Text text="Outside" />
</>
);
}
// Mount
await act(() => {
ReactNoop.renderLegacySyncRoot(<App />);
});
assertLog([
'App render',
'Text:Inside:Before render',
'Text:Inside:After render',
'Text:Outside render',
'Text:Inside:Before create layout',
'Text:Inside:After create layout',
'Text:Outside create layout',
'App create layout',
'Text:Inside:Before create passive',
'Text:Inside:After create passive',
'Text:Outside create passive',
'App create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" />
<span prop="Inside:After" />
<span prop="Outside" />
</>,
);
// Schedule an update that causes React to suspend.
await act(() => {
ReactNoop.renderLegacySyncRoot(
<App>
<AsyncText text="Async" ms={1000} />
</App>,
);
});
assertLog([
'App render',
'Text:Inside:Before render',
'Suspend:Async',
'Text:Inside:After render',
'Text:Fallback render',
'Text:Outside render',
'Text:Fallback create layout',
'Text:Fallback create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" hidden={true} />
<span prop="Inside:After" hidden={true} />
<span prop="Fallback" />
<span prop="Outside" />
</>,
);
await advanceTimers(1000);
// Noop since sync root has already committed
assertLog([]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" hidden={true} />
<span prop="Inside:After" hidden={true} />
<span prop="Fallback" />
<span prop="Outside" />
</>,
);
// Resolving the suspended resource should re-create inner layout effects.
await act(async () => {
await resolveText('Async');
});
assertLog([
'AsyncText:Async render',
'Text:Fallback destroy layout',
'AsyncText:Async create layout',
'Text:Fallback destroy passive',
'AsyncText:Async create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" />
<span prop="Async" />
<span prop="Inside:After" />
<span prop="Outside" />
</>,
);
await act(() => {
ReactNoop.renderLegacySyncRoot(null);
});
assertLog([
'App destroy layout',
'Text:Inside:Before destroy layout',
'AsyncText:Async destroy layout',
'Text:Inside:After destroy layout',
'Text:Outside destroy layout',
'App destroy passive',
'Text:Inside:Before destroy passive',
'AsyncText:Async destroy passive',
'Text:Inside:After destroy passive',
'Text:Outside destroy passive',
]);
});
// @gate enableLegacyCache
it('should be destroyed and recreated for function components', async () => {
function App({children = null}) {
Scheduler.log('App render');
React.useLayoutEffect(() => {
Scheduler.log('App create layout');
return () => {
Scheduler.log('App destroy layout');
};
}, []);
React.useEffect(() => {
Scheduler.log('App create passive');
return () => {
Scheduler.log('App destroy passive');
};
}, []);
return (
<>
<Suspense fallback={<Text text="Fallback" />}>
<Text text="Inside:Before" />
{children}
<Text text="Inside:After" />
</Suspense>
<Text text="Outside" />
</>
);
}
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'App render',
'Text:Inside:Before render',
'Text:Inside:After render',
'Text:Outside render',
'Text:Inside:Before create layout',
'Text:Inside:After create layout',
'Text:Outside create layout',
'App create layout',
'Text:Inside:Before create passive',
'Text:Inside:After create passive',
'Text:Outside create passive',
'App create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" />
<span prop="Inside:After" />
<span prop="Outside" />
</>,
);
// Schedule an update that causes React to suspend.
await act(async () => {
ReactNoop.render(
<App>
<AsyncText text="Async" ms={1000} />
</App>,
);
await waitFor([
'App render',
'Text:Inside:Before render',
'Suspend:Async',
'Text:Fallback render',
'Text:Outside render',
'Text:Inside:Before destroy layout',
'Text:Inside:After destroy layout',
'Text:Fallback create layout',
]);
await waitForAll(['Text:Fallback create passive']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" hidden={true} />
<span prop="Inside:After" hidden={true} />
<span prop="Fallback" />
<span prop="Outside" />
</>,
);
});
// Resolving the suspended resource should re-create inner layout effects.
await act(async () => {
await resolveText('Async');
});
assertLog([
'Text:Inside:Before render',
'AsyncText:Async render',
'Text:Inside:After render',
'Text:Fallback destroy layout',
'Text:Inside:Before create layout',
'AsyncText:Async create layout',
'Text:Inside:After create layout',
'Text:Fallback destroy passive',
'AsyncText:Async create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" />
<span prop="Async" />
<span prop="Inside:After" />
<span prop="Outside" />
</>,
);
await act(() => {
ReactNoop.render(null);
});
assertLog([
'App destroy layout',
'Text:Inside:Before destroy layout',
'AsyncText:Async destroy layout',
'Text:Inside:After destroy layout',
'Text:Outside destroy layout',
'App destroy passive',
'Text:Inside:Before destroy passive',
'AsyncText:Async destroy passive',
'Text:Inside:After destroy passive',
'Text:Outside destroy passive',
]);
});
// @gate enableLegacyCache
it('should be destroyed and recreated for class components', async () => {
class ClassText extends React.Component {
componentDidMount() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentDidMount`);
}
componentDidUpdate() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentDidUpdate`);
}
componentWillUnmount() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentWillUnmount`);
}
render() {
const {children, text} = this.props;
Scheduler.log(`ClassText:${text} render`);
return <span prop={text}>{children}</span>;
}
}
function App({children = null}) {
Scheduler.log('App render');
React.useLayoutEffect(() => {
Scheduler.log('App create layout');
return () => {
Scheduler.log('App destroy layout');
};
}, []);
React.useEffect(() => {
Scheduler.log('App create passive');
return () => {
Scheduler.log('App destroy passive');
};
}, []);
return (
<>
<Suspense fallback={<ClassText text="Fallback" />}>
<ClassText text="Inside:Before" />
{children}
<ClassText text="Inside:After" />
</Suspense>
<ClassText text="Outside" />
</>
);
}
// Mount
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'App render',
'ClassText:Inside:Before render',
'ClassText:Inside:After render',
'ClassText:Outside render',
'ClassText:Inside:Before componentDidMount',
'ClassText:Inside:After componentDidMount',
'ClassText:Outside componentDidMount',
'App create layout',
'App create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" />
<span prop="Inside:After" />
<span prop="Outside" />
</>,
);
// Schedule an update that causes React to suspend.
await act(async () => {
ReactNoop.render(
<App>
<AsyncText text="Async" ms={1000} />
</App>,
);
await waitFor([
'App render',
'ClassText:Inside:Before render',
'Suspend:Async',
'ClassText:Fallback render',
'ClassText:Outside render',
'ClassText:Inside:Before componentWillUnmount',
'ClassText:Inside:After componentWillUnmount',
'ClassText:Fallback componentDidMount',
'ClassText:Outside componentDidUpdate',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" hidden={true} />
<span prop="Inside:After" hidden={true} />
<span prop="Fallback" />
<span prop="Outside" />
</>,
);
});
// Resolving the suspended resource should re-create inner layout effects.
await act(async () => {
await resolveText('Async');
});
assertLog([
'ClassText:Inside:Before render',
'AsyncText:Async render',
'ClassText:Inside:After render',
'ClassText:Fallback componentWillUnmount',
'ClassText:Inside:Before componentDidMount',
'AsyncText:Async create layout',
'ClassText:Inside:After componentDidMount',
'AsyncText:Async create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside:Before" />
<span prop="Async" />
<span prop="Inside:After" />
<span prop="Outside" />
</>,
);
await act(() => {
ReactNoop.render(null);
});
assertLog([
'App destroy layout',
'ClassText:Inside:Before componentWillUnmount',
'AsyncText:Async destroy layout',
'ClassText:Inside:After componentWillUnmount',
'ClassText:Outside componentWillUnmount',
'App destroy passive',
'AsyncText:Async destroy passive',
]);
});
// @gate enableLegacyCache
it('should be destroyed and recreated when nested below host components', async () => {
function App({children = null}) {
Scheduler.log('App render');
React.useLayoutEffect(() => {
Scheduler.log('App create layout');
return () => {
Scheduler.log('App destroy layout');
};
}, []);
React.useEffect(() => {
Scheduler.log('App create passive');
return () => {
Scheduler.log('App destroy passive');
};
}, []);
return (
<Suspense fallback={<Text text="Fallback" />}>
{children}
<Text text="Outer">
<Text text="Inner" />
</Text>
</Suspense>
);
}
// Mount
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'App render',
'Text:Outer render',
'Text:Inner render',
'Text:Inner create layout',
'Text:Outer create layout',
'App create layout',
'Text:Inner create passive',
'Text:Outer create passive',
'App create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Outer">
<span prop="Inner" />
</span>,
);
// Schedule an update that causes React to suspend.
await act(async () => {
ReactNoop.render(
<App>
<AsyncText text="Async" ms={1000} />
</App>,
);
await waitFor([
'App render',
'Suspend:Async',
'Text:Fallback render',
'Text:Outer destroy layout',
'Text:Inner destroy layout',
'Text:Fallback create layout',
]);
await waitForAll(['Text:Fallback create passive']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span hidden={true} prop="Outer">
<span prop="Inner" />
</span>
<span prop="Fallback" />
</>,
);
});
// Resolving the suspended resource should re-create inner layout effects.
await act(async () => {
await resolveText('Async');
});
assertLog([
'AsyncText:Async render',
'Text:Outer render',
'Text:Inner render',
'Text:Fallback destroy layout',
'AsyncText:Async create layout',
'Text:Inner create layout',
'Text:Outer create layout',
'Text:Fallback destroy passive',
'AsyncText:Async create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Async" />
<span prop="Outer">
<span prop="Inner" />
</span>
</>,
);
await act(() => {
ReactNoop.render(null);
});
assertLog([
'App destroy layout',
'AsyncText:Async destroy layout',
'Text:Outer destroy layout',
'Text:Inner destroy layout',
'App destroy passive',
'AsyncText:Async destroy passive',
'Text:Outer destroy passive',
'Text:Inner destroy passive',
]);
});
// @gate enableLegacyCache
it('should be destroyed and recreated even if there is a bailout because of memoization', async () => {
const MemoizedText = React.memo(Text, () => true);
function App({children = null}) {
Scheduler.log('App render');
React.useLayoutEffect(() => {
Scheduler.log('App create layout');
return () => {
Scheduler.log('App destroy layout');
};
}, []);
React.useEffect(() => {
Scheduler.log('App create passive');
return () => {
Scheduler.log('App destroy passive');
};
}, []);
return (
<Suspense fallback={<Text text="Fallback" />}>
{children}
<Text text="Outer">
<MemoizedText text="MemoizedInner" />
</Text>
</Suspense>
);
}
// Mount
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'App render',
'Text:Outer render',
'Text:MemoizedInner render',
'Text:MemoizedInner create layout',
'Text:Outer create layout',
'App create layout',
'Text:MemoizedInner create passive',
'Text:Outer create passive',
'App create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<span prop="Outer">
<span prop="MemoizedInner" />
</span>,
);
// Schedule an update that causes React to suspend.
await act(async () => {
ReactNoop.render(
<App>
<AsyncText text="Async" ms={1000} />
</App>,
);
await waitFor([
'App render',
'Suspend:Async',
// Text:MemoizedInner is memoized
'Text:Fallback render',
'Text:Outer destroy layout',
'Text:MemoizedInner destroy layout',
'Text:Fallback create layout',
]);
await waitForAll(['Text:Fallback create passive']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span hidden={true} prop="Outer">
<span prop="MemoizedInner" />
</span>
<span prop="Fallback" />
</>,
);
});
// Resolving the suspended resource should re-create inner layout effects.
await act(async () => {
await resolveText('Async');
});
assertLog([
'AsyncText:Async render',
'Text:Outer render',
'Text:Fallback destroy layout',
'AsyncText:Async create layout',
'Text:MemoizedInner create layout',
'Text:Outer create layout',
'Text:Fallback destroy passive',
'AsyncText:Async create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Async" />
<span prop="Outer">
<span prop="MemoizedInner" />
</span>
</>,
);
await act(() => {
ReactNoop.render(null);
});
assertLog([
'App destroy layout',
'AsyncText:Async destroy layout',
'Text:Outer destroy layout',
'Text:MemoizedInner destroy layout',
'App destroy passive',
'AsyncText:Async destroy passive',
'Text:Outer destroy passive',
'Text:MemoizedInner destroy passive',
]);
});
// @gate enableLegacyCache
it('should respect nested suspense boundaries', async () => {
function App({innerChildren = null, outerChildren = null}) {
return (
<Suspense fallback={<Text text="OuterFallback" />}>
<Text text="Outer" />
{outerChildren}
<Suspense fallback={<Text text="InnerFallback" />}>
<Text text="Inner" />
{innerChildren}
</Suspense>
</Suspense>
);
}
// Mount
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'Text:Outer render',
'Text:Inner render',
'Text:Outer create layout',
'Text:Inner create layout',
'Text:Outer create passive',
'Text:Inner create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Outer" />
<span prop="Inner" />
</>,
);
// Suspend the inner Suspense subtree (only inner effects should be destroyed)
await act(() => {
ReactNoop.render(
<App innerChildren={<AsyncText text="InnerAsync_1" ms={1000} />} />,
);
});
assertLog([
'Text:Outer render',
'Text:Inner render',
'Suspend:InnerAsync_1',
'Text:InnerFallback render',
'Text:Inner destroy layout',
'Text:InnerFallback create layout',
'Text:InnerFallback create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Outer" />
<span prop="Inner" hidden={true} />
<span prop="InnerFallback" />
</>,
);
// Suspend the outer Suspense subtree (outer effects and inner fallback effects should be destroyed)
// (This check also ensures we don't destroy effects for mounted inner fallback.)
await act(() => {
ReactNoop.render(
<App
outerChildren={<AsyncText text="OuterAsync_1" ms={1000} />}
innerChildren={<AsyncText text="InnerAsync_1" ms={1000} />}
/>,
);
});
await advanceTimers(1000);
assertLog([
'Text:Outer render',
'Suspend:OuterAsync_1',
'Text:OuterFallback render',
'Text:Outer destroy layout',
'Text:InnerFallback destroy layout',
'Text:OuterFallback create layout',
'Text:OuterFallback create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Outer" hidden={true} />
<span prop="Inner" hidden={true} />
<span prop="InnerFallback" hidden={true} />
<span prop="OuterFallback" />
</>,
);
// Show the inner Suspense subtree (no effects should be recreated)
await act(async () => {
await resolveText('InnerAsync_1');
});
assertLog(['Text:Outer render', 'Suspend:OuterAsync_1']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Outer" hidden={true} />
<span prop="Inner" hidden={true} />
<span prop="InnerFallback" hidden={true} />
<span prop="OuterFallback" />
</>,
);
// Suspend the inner Suspense subtree (no effects should be destroyed)
await act(() => {
ReactNoop.render(
<App
outerChildren={<AsyncText text="OuterAsync_1" ms={1000} />}
innerChildren={<AsyncText text="InnerAsync_2" ms={1000} />}
/>,
);
});
await advanceTimers(1000);
assertLog([
'Text:Outer render',
'Suspend:OuterAsync_1',
'Text:OuterFallback render',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Outer" hidden={true} />
<span prop="Inner" hidden={true} />
<span prop="InnerFallback" hidden={true} />
<span prop="OuterFallback" />
</>,
);
// Show the outer Suspense subtree (only outer effects should be recreated)
await act(async () => {
await resolveText('OuterAsync_1');
});
assertLog([
'Text:Outer render',
'AsyncText:OuterAsync_1 render',
'Text:Inner render',
'Suspend:InnerAsync_2',
'Text:InnerFallback render',
'Text:OuterFallback destroy layout',
'Text:Outer create layout',
'AsyncText:OuterAsync_1 create layout',
'Text:InnerFallback create layout',
'Text:OuterFallback destroy passive',
'AsyncText:OuterAsync_1 create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Outer" />
<span prop="OuterAsync_1" />
<span prop="Inner" hidden={true} />
<span prop="InnerFallback" />
</>,
);
// Show the inner Suspense subtree (only inner effects should be recreated)
await act(async () => {
await resolveText('InnerAsync_2');
});
assertLog([
'Text:Inner render',
'AsyncText:InnerAsync_2 render',
'Text:InnerFallback destroy layout',
'Text:Inner create layout',
'AsyncText:InnerAsync_2 create layout',
'Text:InnerFallback destroy passive',
'AsyncText:InnerAsync_2 create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Outer" />
<span prop="OuterAsync_1" />
<span prop="Inner" />
<span prop="InnerAsync_2" />
</>,
);
// Suspend the outer Suspense subtree (all effects should be destroyed)
await act(() => {
ReactNoop.render(
<App
outerChildren={<AsyncText text="OuterAsync_2" ms={1000} />}
innerChildren={<AsyncText text="InnerAsync_2" ms={1000} />}
/>,
);
});
assertLog([
'Text:Outer render',
'Suspend:OuterAsync_2',
'Text:OuterFallback render',
'Text:Outer destroy layout',
'AsyncText:OuterAsync_1 destroy layout',
'Text:Inner destroy layout',
'AsyncText:InnerAsync_2 destroy layout',
'Text:OuterFallback create layout',
'Text:OuterFallback create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Outer" hidden={true} />
<span prop="OuterAsync_1" hidden={true} />
<span prop="Inner" hidden={true} />
<span prop="InnerAsync_2" hidden={true} />
<span prop="OuterFallback" />
</>,
);
// Show the outer Suspense subtree (all effects should be recreated)
await act(async () => {
await resolveText('OuterAsync_2');
});
assertLog([
'Text:Outer render',
'AsyncText:OuterAsync_2 render',
'Text:Inner render',
'AsyncText:InnerAsync_2 render',
'Text:OuterFallback destroy layout',
'Text:Outer create layout',
'AsyncText:OuterAsync_2 create layout',
'Text:Inner create layout',
'AsyncText:InnerAsync_2 create layout',
'Text:OuterFallback destroy passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Outer" />
<span prop="OuterAsync_2" />
<span prop="Inner" />
<span prop="InnerAsync_2" />
</>,
);
});
// @gate enableLegacyCache
it('should show nested host nodes if multiple boundaries resolve at the same time', async () => {
function App({innerChildren = null, outerChildren = null}) {
return (
<Suspense fallback={<Text text="OuterFallback" />}>
<Text text="Outer" />
{outerChildren}
<Suspense fallback={<Text text="InnerFallback" />}>
<Text text="Inner" />
{innerChildren}
</Suspense>
</Suspense>
);
}
// Mount
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'Text:Outer render',
'Text:Inner render',
'Text:Outer create layout',
'Text:Inner create layout',
'Text:Outer create passive',
'Text:Inner create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Outer" />
<span prop="Inner" />
</>,
);
// Suspend the inner Suspense subtree (only inner effects should be destroyed)
await act(() => {
ReactNoop.render(
<App innerChildren={<AsyncText text="InnerAsync_1" ms={1000} />} />,
);
});
assertLog([
'Text:Outer render',
'Text:Inner render',
'Suspend:InnerAsync_1',
'Text:InnerFallback render',
'Text:Inner destroy layout',
'Text:InnerFallback create layout',
'Text:InnerFallback create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Outer" />
<span prop="Inner" hidden={true} />
<span prop="InnerFallback" />
</>,
);
// Suspend the outer Suspense subtree (outer effects and inner fallback effects should be destroyed)
// (This check also ensures we don't destroy effects for mounted inner fallback.)
await act(() => {
ReactNoop.render(
<App
outerChildren={<AsyncText text="OuterAsync_1" ms={1000} />}
innerChildren={<AsyncText text="InnerAsync_1" ms={1000} />}
/>,
);
});
assertLog([
'Text:Outer render',
'Suspend:OuterAsync_1',
'Text:OuterFallback render',
'Text:Outer destroy layout',
'Text:InnerFallback destroy layout',
'Text:OuterFallback create layout',
'Text:OuterFallback create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Outer" hidden={true} />
<span prop="Inner" hidden={true} />
<span prop="InnerFallback" hidden={true} />
<span prop="OuterFallback" />
</>,
);
// Resolve both suspended trees.
await act(async () => {
await resolveText('OuterAsync_1');
await resolveText('InnerAsync_1');
});
assertLog([
'Text:Outer render',
'AsyncText:OuterAsync_1 render',
'Text:Inner render',
'AsyncText:InnerAsync_1 render',
'Text:OuterFallback destroy layout',
'Text:Outer create layout',
'AsyncText:OuterAsync_1 create layout',
'Text:Inner create layout',
'AsyncText:InnerAsync_1 create layout',
'Text:OuterFallback destroy passive',
'Text:InnerFallback destroy passive',
'AsyncText:OuterAsync_1 create passive',
'AsyncText:InnerAsync_1 create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Outer" />
<span prop="OuterAsync_1" />
<span prop="Inner" />
<span prop="InnerAsync_1" />
</>,
);
});
// @gate enableLegacyCache
it('should be cleaned up inside of a fallback that suspends', async () => {
function App({fallbackChildren = null, outerChildren = null}) {
return (
<>
<Suspense
fallback={
<>
<Suspense fallback={<Text text="Fallback:Fallback" />}>
<Text text="Fallback:Inside" />
{fallbackChildren}
</Suspense>
<Text text="Fallback:Outside" />
</>
}>
<Text text="Inside" />
{outerChildren}
</Suspense>
<Text text="Outside" />
</>
);
}
// Mount
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'Text:Inside render',
'Text:Outside render',
'Text:Inside create layout',
'Text:Outside create layout',
'Text:Inside create passive',
'Text:Outside create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside" />
<span prop="Outside" />
</>,
);
// Suspend the outer shell
await act(async () => {
ReactNoop.render(
<App outerChildren={<AsyncText text="OutsideAsync" ms={1000} />} />,
);
await waitFor([
'Text:Inside render',
'Suspend:OutsideAsync',
'Text:Fallback:Inside render',
'Text:Fallback:Outside render',
'Text:Outside render',
'Text:Inside destroy layout',
'Text:Fallback:Inside create layout',
'Text:Fallback:Outside create layout',
]);
await waitForAll([
'Text:Fallback:Inside create passive',
'Text:Fallback:Outside create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside" hidden={true} />
<span prop="Fallback:Inside" />
<span prop="Fallback:Outside" />
<span prop="Outside" />
</>,
);
});
// Suspend the fallback and verify that it's effects get cleaned up as well
await act(async () => {
ReactNoop.render(
<App
fallbackChildren={<AsyncText text="FallbackAsync" ms={1000} />}
outerChildren={<AsyncText text="OutsideAsync" ms={1000} />}
/>,
);
await waitFor([
'Text:Inside render',
'Suspend:OutsideAsync',
'Text:Fallback:Inside render',
'Suspend:FallbackAsync',
'Text:Fallback:Fallback render',
'Text:Fallback:Outside render',
'Text:Outside render',
'Text:Fallback:Inside destroy layout',
'Text:Fallback:Fallback create layout',
]);
await waitForAll(['Text:Fallback:Fallback create passive']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside" hidden={true} />
<span prop="Fallback:Inside" hidden={true} />
<span prop="Fallback:Fallback" />
<span prop="Fallback:Outside" />
<span prop="Outside" />
</>,
);
});
// Resolving both resources should cleanup fallback effects and recreate main effects
await act(async () => {
await resolveText('FallbackAsync');
await resolveText('OutsideAsync');
});
assertLog([
'Text:Inside render',
'AsyncText:OutsideAsync render',
'Text:Fallback:Fallback destroy layout',
'Text:Fallback:Outside destroy layout',
'Text:Inside create layout',
'AsyncText:OutsideAsync create layout',
'Text:Fallback:Inside destroy passive',
'Text:Fallback:Fallback destroy passive',
'Text:Fallback:Outside destroy passive',
'AsyncText:OutsideAsync create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside" />
<span prop="OutsideAsync" />
<span prop="Outside" />
</>,
);
});
// @gate enableLegacyCache
it('should be cleaned up inside of a fallback that suspends (alternate)', async () => {
function App({fallbackChildren = null, outerChildren = null}) {
return (
<>
<Suspense
fallback={
<>
<Suspense fallback={<Text text="Fallback:Fallback" />}>
<Text text="Fallback:Inside" />
{fallbackChildren}
</Suspense>
<Text text="Fallback:Outside" />
</>
}>
<Text text="Inside" />
{outerChildren}
</Suspense>
<Text text="Outside" />
</>
);
}
// Mount
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'Text:Inside render',
'Text:Outside render',
'Text:Inside create layout',
'Text:Outside create layout',
'Text:Inside create passive',
'Text:Outside create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside" />
<span prop="Outside" />
</>,
);
// Suspend both the outer boundary and the fallback
await act(() => {
ReactNoop.render(
<App
outerChildren={<AsyncText text="OutsideAsync" ms={1000} />}
fallbackChildren={<AsyncText text="FallbackAsync" ms={1000} />}
/>,
);
});
assertLog([
'Text:Inside render',
'Suspend:OutsideAsync',
'Text:Fallback:Inside render',
'Suspend:FallbackAsync',
'Text:Fallback:Fallback render',
'Text:Fallback:Outside render',
'Text:Outside render',
'Text:Inside destroy layout',
'Text:Fallback:Fallback create layout',
'Text:Fallback:Outside create layout',
'Text:Fallback:Fallback create passive',
'Text:Fallback:Outside create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside" hidden={true} />
<span prop="Fallback:Fallback" />
<span prop="Fallback:Outside" />
<span prop="Outside" />
</>,
);
// Resolving the inside fallback
await act(async () => {
await resolveText('FallbackAsync');
});
assertLog([
'Text:Fallback:Inside render',
'AsyncText:FallbackAsync render',
'Text:Fallback:Fallback destroy layout',
'Text:Fallback:Inside create layout',
'AsyncText:FallbackAsync create layout',
'Text:Fallback:Fallback destroy passive',
'Text:Fallback:Inside create passive',
'AsyncText:FallbackAsync create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside" hidden={true} />
<span prop="Fallback:Inside" />
<span prop="FallbackAsync" />
<span prop="Fallback:Outside" />
<span prop="Outside" />
</>,
);
// Resolving the outer fallback only
await act(async () => {
await resolveText('OutsideAsync');
});
assertLog([
'Text:Inside render',
'AsyncText:OutsideAsync render',
'Text:Fallback:Inside destroy layout',
'AsyncText:FallbackAsync destroy layout',
'Text:Fallback:Outside destroy layout',
'Text:Inside create layout',
'AsyncText:OutsideAsync create layout',
'Text:Fallback:Inside destroy passive',
'AsyncText:FallbackAsync destroy passive',
'Text:Fallback:Outside destroy passive',
'AsyncText:OutsideAsync create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside" />
<span prop="OutsideAsync" />
<span prop="Outside" />
</>,
);
});
// @gate enableLegacyCache
it('should be cleaned up deeper inside of a subtree that suspends', async () => {
function ConditionalSuspense({shouldSuspend}) {
if (shouldSuspend) {
readText('Suspend');
}
return <Text text="Inside" />;
}
function App({children = null, shouldSuspend}) {
return (
<>
<Suspense fallback={<Text text="Fallback" />}>
<ConditionalSuspense shouldSuspend={shouldSuspend} />
</Suspense>
<Text text="Outside" />
</>
);
}
// Mount
await act(() => {
ReactNoop.render(<App shouldSuspend={false} />);
});
assertLog([
'Text:Inside render',
'Text:Outside render',
'Text:Inside create layout',
'Text:Outside create layout',
'Text:Inside create passive',
'Text:Outside create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside" />
<span prop="Outside" />
</>,
);
// Suspending a component in the middle of the tree
// should still properly cleanup effects deeper in the tree
await act(async () => {
ReactNoop.render(<App shouldSuspend={true} />);
await waitFor([
'Suspend:Suspend',
'Text:Fallback render',
'Text:Outside render',
'Text:Inside destroy layout',
'Text:Fallback create layout',
]);
await waitForAll(['Text:Fallback create passive']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside" hidden={true} />
<span prop="Fallback" />
<span prop="Outside" />
</>,
);
});
// Resolving should cleanup.
await act(async () => {
await resolveText('Suspend');
});
assertLog([
'Text:Inside render',
'Text:Fallback destroy layout',
'Text:Inside create layout',
'Text:Fallback destroy passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Inside" />
<span prop="Outside" />
</>,
);
});
describe('that throw errors', () => {
// @gate enableLegacyCache
it('are properly handled for componentDidMount', async () => {
let componentDidMountShouldThrow = false;
class ThrowsInDidMount extends React.Component {
componentWillUnmount() {
Scheduler.log('ThrowsInDidMount componentWillUnmount');
}
componentDidMount() {
Scheduler.log('ThrowsInDidMount componentDidMount');
if (componentDidMountShouldThrow) {
throw Error('expected');
}
}
render() {
Scheduler.log('ThrowsInDidMount render');
return <span prop="ThrowsInDidMount" />;
}
}
function App({children = null}) {
Scheduler.log('App render');
React.useLayoutEffect(() => {
Scheduler.log('App create layout');
return () => {
Scheduler.log('App destroy layout');
};
}, []);
return (
<>
<Suspense fallback={<Text text="Fallback" />}>
{children}
<ThrowsInDidMount />
<Text text="Inside" />
</Suspense>
<Text text="Outside" />
</>
);
}
await act(() => {
ReactNoop.render(
<ErrorBoundary fallback={<Text text="Error" />}>
<App />
</ErrorBoundary>,
);
});
assertLog([
'ErrorBoundary render: try',
'App render',
'ThrowsInDidMount render',
'Text:Inside render',
'Text:Outside render',
'ThrowsInDidMount componentDidMount',
'Text:Inside create layout',
'Text:Outside create layout',
'App create layout',
'Text:Inside create passive',
'Text:Outside create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="ThrowsInDidMount" />
<span prop="Inside" />
<span prop="Outside" />
</>,
);
// Schedule an update that causes React to suspend.
await act(() => {
ReactNoop.render(
<ErrorBoundary fallback={<Text text="Error" />}>
<App>
<AsyncText text="Async" ms={1000} />
</App>
</ErrorBoundary>,
);
});
assertLog([
'ErrorBoundary render: try',
'App render',
'Suspend:Async',
'Text:Fallback render',
'Text:Outside render',
'ThrowsInDidMount componentWillUnmount',
'Text:Inside destroy layout',
'Text:Fallback create layout',
'Text:Fallback create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="ThrowsInDidMount" hidden={true} />
<span prop="Inside" hidden={true} />
<span prop="Fallback" />
<span prop="Outside" />
</>,
);
// Resolve the pending suspense and throw
componentDidMountShouldThrow = true;
await act(async () => {
await resolveText('Async');
});
assertLog([
'AsyncText:Async render',
'ThrowsInDidMount render',
'Text:Inside render',
'Text:Fallback destroy layout',
'AsyncText:Async create layout',
// Even though an error was thrown in componentDidMount,
// subsequent layout effects should still be destroyed.
'ThrowsInDidMount componentDidMount',
'Text:Inside create layout',
// Finish the in-progress commit
'Text:Fallback destroy passive',
'AsyncText:Async create passive',
// Destroy layout and passive effects in the errored tree.
'App destroy layout',
'AsyncText:Async destroy layout',
'ThrowsInDidMount componentWillUnmount',
'Text:Inside destroy layout',
'Text:Outside destroy layout',
'AsyncText:Async destroy passive',
'Text:Inside destroy passive',
'Text:Outside destroy passive',
// Render fallback
'ErrorBoundary render: catch',
'Text:Error render',
'Text:Error create layout',
'Text:Error create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Error" />);
});
// @gate enableLegacyCache
it('are properly handled for componentWillUnmount', async () => {
class ThrowsInWillUnmount extends React.Component {
componentDidMount() {
Scheduler.log('ThrowsInWillUnmount componentDidMount');
}
componentWillUnmount() {
Scheduler.log('ThrowsInWillUnmount componentWillUnmount');
throw Error('expected');
}
render() {
Scheduler.log('ThrowsInWillUnmount render');
return <span prop="ThrowsInWillUnmount" />;
}
}
function App({children = null}) {
Scheduler.log('App render');
React.useLayoutEffect(() => {
Scheduler.log('App create layout');
return () => {
Scheduler.log('App destroy layout');
};
}, []);
return (
<>
<Suspense fallback={<Text text="Fallback" />}>
{children}
<ThrowsInWillUnmount />
<Text text="Inside" />
</Suspense>
<Text text="Outside" />
</>
);
}
await act(() => {
ReactNoop.render(
<ErrorBoundary fallback={<Text text="Error" />}>
<App />
</ErrorBoundary>,
);
});
assertLog([
'ErrorBoundary render: try',
'App render',
'ThrowsInWillUnmount render',
'Text:Inside render',
'Text:Outside render',
'ThrowsInWillUnmount componentDidMount',
'Text:Inside create layout',
'Text:Outside create layout',
'App create layout',
'Text:Inside create passive',
'Text:Outside create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="ThrowsInWillUnmount" />
<span prop="Inside" />
<span prop="Outside" />
</>,
);
// Schedule an update that suspends and triggers our error code.
await act(() => {
ReactNoop.render(
<ErrorBoundary fallback={<Text text="Error" />}>
<App>
<AsyncText text="Async" ms={1000} />
</App>
</ErrorBoundary>,
);
});
assertLog([
'ErrorBoundary render: try',
'App render',
'Suspend:Async',
'Text:Fallback render',
'Text:Outside render',
// Even though an error was thrown in componentWillUnmount,
// subsequent layout effects should still be destroyed.
'ThrowsInWillUnmount componentWillUnmount',
'Text:Inside destroy layout',
// Finish the in-progress commit
'Text:Fallback create layout',
'Text:Fallback create passive',
// Destroy layout and passive effects in the errored tree.
'App destroy layout',
'Text:Fallback destroy layout',
'Text:Outside destroy layout',
'Text:Inside destroy passive',
'Text:Fallback destroy passive',
'Text:Outside destroy passive',
// Render fallback
'ErrorBoundary render: catch',
'Text:Error render',
'Text:Error create layout',
'Text:Error create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Error" />);
});
// @gate enableLegacyCache
// @gate replayFailedUnitOfWorkWithInvokeGuardedCallback
it('are properly handled for layout effect creation', async () => {
let useLayoutEffectShouldThrow = false;
function ThrowsInLayoutEffect() {
Scheduler.log('ThrowsInLayoutEffect render');
React.useLayoutEffect(() => {
Scheduler.log('ThrowsInLayoutEffect useLayoutEffect create');
if (useLayoutEffectShouldThrow) {
throw Error('expected');
}
return () => {
Scheduler.log('ThrowsInLayoutEffect useLayoutEffect destroy');
};
}, []);
return <span prop="ThrowsInLayoutEffect" />;
}
function App({children = null}) {
Scheduler.log('App render');
React.useLayoutEffect(() => {
Scheduler.log('App create layout');
return () => {
Scheduler.log('App destroy layout');
};
}, []);
return (
<>
<Suspense fallback={<Text text="Fallback" />}>
{children}
<ThrowsInLayoutEffect />
<Text text="Inside" />
</Suspense>
<Text text="Outside" />
</>
);
}
await act(() => {
ReactNoop.render(
<ErrorBoundary fallback={<Text text="Error" />}>
<App />
</ErrorBoundary>,
);
});
assertLog([
'ErrorBoundary render: try',
'App render',
'ThrowsInLayoutEffect render',
'Text:Inside render',
'Text:Outside render',
'ThrowsInLayoutEffect useLayoutEffect create',
'Text:Inside create layout',
'Text:Outside create layout',
'App create layout',
'Text:Inside create passive',
'Text:Outside create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="ThrowsInLayoutEffect" />
<span prop="Inside" />
<span prop="Outside" />
</>,
);
// Schedule an update that causes React to suspend.
await act(() => {
ReactNoop.render(
<ErrorBoundary fallback={<Text text="Error" />}>
<App>
<AsyncText text="Async" ms={1000} />
</App>
</ErrorBoundary>,
);
});
assertLog([
'ErrorBoundary render: try',
'App render',
'Suspend:Async',
'Text:Fallback render',
'Text:Outside render',
'ThrowsInLayoutEffect useLayoutEffect destroy',
'Text:Inside destroy layout',
'Text:Fallback create layout',
'Text:Fallback create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="ThrowsInLayoutEffect" hidden={true} />
<span prop="Inside" hidden={true} />
<span prop="Fallback" />
<span prop="Outside" />
</>,
);
// Resolve the pending suspense and throw
useLayoutEffectShouldThrow = true;
await act(async () => {
await resolveText('Async');
});
assertLog([
'AsyncText:Async render',
'ThrowsInLayoutEffect render',
'Text:Inside render',
'Text:Fallback destroy layout',
// Even though an error was thrown in useLayoutEffect,
// subsequent layout effects should still be created.
'AsyncText:Async create layout',
'ThrowsInLayoutEffect useLayoutEffect create',
'Text:Inside create layout',
// Finish the in-progress commit
'Text:Fallback destroy passive',
'AsyncText:Async create passive',
// Destroy layout and passive effects in the errored tree.
'App destroy layout',
'AsyncText:Async destroy layout',
'Text:Inside destroy layout',
'Text:Outside destroy layout',
'AsyncText:Async destroy passive',
'Text:Inside destroy passive',
'Text:Outside destroy passive',
// Render fallback
'ErrorBoundary render: catch',
'Text:Error render',
'Text:Error create layout',
'Text:Error create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Error" />);
});
// @gate enableLegacyCache
// @gate replayFailedUnitOfWorkWithInvokeGuardedCallback
it('are properly handled for layout effect destruction', async () => {
function ThrowsInLayoutEffectDestroy() {
Scheduler.log('ThrowsInLayoutEffectDestroy render');
React.useLayoutEffect(() => {
Scheduler.log('ThrowsInLayoutEffectDestroy useLayoutEffect create');
return () => {
Scheduler.log(
'ThrowsInLayoutEffectDestroy useLayoutEffect destroy',
);
throw Error('expected');
};
}, []);
return <span prop="ThrowsInLayoutEffectDestroy" />;
}
function App({children = null}) {
Scheduler.log('App render');
React.useLayoutEffect(() => {
Scheduler.log('App create layout');
return () => {
Scheduler.log('App destroy layout');
};
}, []);
return (
<>
<Suspense fallback={<Text text="Fallback" />}>
{children}
<ThrowsInLayoutEffectDestroy />
<Text text="Inside" />
</Suspense>
<Text text="Outside" />
</>
);
}
await act(() => {
ReactNoop.render(
<ErrorBoundary fallback={<Text text="Error" />}>
<App />
</ErrorBoundary>,
);
});
assertLog([
'ErrorBoundary render: try',
'App render',
'ThrowsInLayoutEffectDestroy render',
'Text:Inside render',
'Text:Outside render',
'ThrowsInLayoutEffectDestroy useLayoutEffect create',
'Text:Inside create layout',
'Text:Outside create layout',
'App create layout',
'Text:Inside create passive',
'Text:Outside create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="ThrowsInLayoutEffectDestroy" />
<span prop="Inside" />
<span prop="Outside" />
</>,
);
// Schedule an update that suspends and triggers our error code.
await act(() => {
ReactNoop.render(
<ErrorBoundary fallback={<Text text="Error" />}>
<App>
<AsyncText text="Async" ms={1000} />
</App>
</ErrorBoundary>,
);
});
assertLog([
'ErrorBoundary render: try',
'App render',
'Suspend:Async',
'Text:Fallback render',
'Text:Outside render',
// Even though an error was thrown in useLayoutEffect destroy,
// subsequent layout effects should still be destroyed.
'ThrowsInLayoutEffectDestroy useLayoutEffect destroy',
'Text:Inside destroy layout',
// Finish the in-progress commit
'Text:Fallback create layout',
'Text:Fallback create passive',
// Destroy layout and passive effects in the errored tree.
'App destroy layout',
'Text:Fallback destroy layout',
'Text:Outside destroy layout',
'Text:Inside destroy passive',
'Text:Fallback destroy passive',
'Text:Outside destroy passive',
// Render fallback
'ErrorBoundary render: catch',
'Text:Error render',
'Text:Error create layout',
'Text:Error create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Error" />);
});
});
// @gate enableLegacyCache
it('should be only destroy layout effects once if a tree suspends in multiple places', async () => {
class ClassText extends React.Component {
componentDidMount() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentDidMount`);
}
componentDidUpdate() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentDidUpdate`);
}
componentWillUnmount() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentWillUnmount`);
}
render() {
const {children, text} = this.props;
Scheduler.log(`ClassText:${text} render`);
return <span prop={text}>{children}</span>;
}
}
function App({children = null}) {
return (
<Suspense fallback={<ClassText text="Fallback" />}>
<Text text="Function" />
{children}
<ClassText text="Class" />
</Suspense>
);
}
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'Text:Function render',
'ClassText:Class render',
'Text:Function create layout',
'ClassText:Class componentDidMount',
'Text:Function create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Function" />
<span prop="Class" />
</>,
);
// Schedule an update that causes React to suspend.
await act(async () => {
ReactNoop.render(
<App>
<AsyncText text="Async_1" ms={1000} />
<AsyncText text="Async_2" ms={2000} />
</App>,
);
await waitFor([
'Text:Function render',
'Suspend:Async_1',
'ClassText:Fallback render',
'Text:Function destroy layout',
'ClassText:Class componentWillUnmount',
'ClassText:Fallback componentDidMount',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Function" hidden={true} />
<span prop="Class" hidden={true} />
<span prop="Fallback" />
</>,
);
});
// Resolving the suspended resource should re-create inner layout effects.
await act(async () => {
await resolveText('Async_1');
});
assertLog([
'Text:Function render',
'AsyncText:Async_1 render',
'Suspend:Async_2',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Function" hidden={true} />
<span prop="Class" hidden={true} />
<span prop="Fallback" />
</>,
);
// Resolving the suspended resource should re-create inner layout effects.
await act(async () => {
await resolveText('Async_2');
});
assertLog([
'Text:Function render',
'AsyncText:Async_1 render',
'AsyncText:Async_2 render',
'ClassText:Class render',
'ClassText:Fallback componentWillUnmount',
'Text:Function create layout',
'AsyncText:Async_1 create layout',
'AsyncText:Async_2 create layout',
'ClassText:Class componentDidMount',
'AsyncText:Async_1 create passive',
'AsyncText:Async_2 create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Function" />
<span prop="Async_1" />
<span prop="Async_2" />
<span prop="Class" />
</>,
);
await act(() => {
ReactNoop.render(null);
});
assertLog([
'Text:Function destroy layout',
'AsyncText:Async_1 destroy layout',
'AsyncText:Async_2 destroy layout',
'ClassText:Class componentWillUnmount',
'Text:Function destroy passive',
'AsyncText:Async_1 destroy passive',
'AsyncText:Async_2 destroy passive',
]);
});
// @gate enableLegacyCache
it('should be only destroy layout effects once if a component suspends multiple times', async () => {
class ClassText extends React.Component {
componentDidMount() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentDidMount`);
}
componentDidUpdate() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentDidUpdate`);
}
componentWillUnmount() {
const {text} = this.props;
Scheduler.log(`ClassText:${text} componentWillUnmount`);
}
render() {
const {children, text} = this.props;
Scheduler.log(`ClassText:${text} render`);
return <span prop={text}>{children}</span>;
}
}
let textToRead = null;
function Suspender() {
Scheduler.log(`Suspender "${textToRead}" render`);
if (textToRead !== null) {
readText(textToRead);
}
return <span prop="Suspender" />;
}
function App({children = null}) {
return (
<Suspense fallback={<ClassText text="Fallback" />}>
<Text text="Function" />
<Suspender />
<ClassText text="Class" />
</Suspense>
);
}
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'Text:Function render',
'Suspender "null" render',
'ClassText:Class render',
'Text:Function create layout',
'ClassText:Class componentDidMount',
'Text:Function create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Function" />
<span prop="Suspender" />
<span prop="Class" />
</>,
);
// Schedule an update that causes React to suspend.
textToRead = 'A';
await act(async () => {
ReactNoop.render(<App />);
await waitFor([
'Text:Function render',
'Suspender "A" render',
'Suspend:A',
'ClassText:Fallback render',
'Text:Function destroy layout',
'ClassText:Class componentWillUnmount',
'ClassText:Fallback componentDidMount',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Function" hidden={true} />
<span prop="Suspender" hidden={true} />
<span prop="Class" hidden={true} />
<span prop="Fallback" />
</>,
);
});
// Resolving the suspended resource should re-create inner layout effects.
textToRead = 'B';
await act(async () => {
await resolveText('A');
});
assertLog(['Text:Function render', 'Suspender "B" render', 'Suspend:B']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Function" hidden={true} />
<span prop="Suspender" hidden={true} />
<span prop="Class" hidden={true} />
<span prop="Fallback" />
</>,
);
// Resolving the suspended resource should re-create inner layout effects.
await act(async () => {
await resolveText('B');
});
assertLog([
'Text:Function render',
'Suspender "B" render',
'ClassText:Class render',
'ClassText:Fallback componentWillUnmount',
'Text:Function create layout',
'ClassText:Class componentDidMount',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Function" />
<span prop="Suspender" />
<span prop="Class" />
</>,
);
await act(() => {
ReactNoop.render(null);
});
assertLog([
'Text:Function destroy layout',
'ClassText:Class componentWillUnmount',
'Text:Function destroy passive',
]);
});
});
describe('refs within a tree that re-suspends in an update', () => {
function RefCheckerOuter({Component}) {
const refObject = React.useRef(null);
const manualRef = React.useMemo(() => ({current: null}), []);
const refCallback = React.useCallback(value => {
Scheduler.log(`RefCheckerOuter refCallback value? ${value != null}`);
manualRef.current = value;
}, []);
Scheduler.log(`RefCheckerOuter render`);
React.useLayoutEffect(() => {
Scheduler.log(
`RefCheckerOuter create layout refObject? ${
refObject.current != null
} refCallback? ${manualRef.current != null}`,
);
return () => {
Scheduler.log(
`RefCheckerOuter destroy layout refObject? ${
refObject.current != null
} refCallback? ${manualRef.current != null}`,
);
};
}, []);
return (
<>
<Component ref={refObject} prop="refObject">
<RefCheckerInner forwardedRef={refObject} text="refObject" />
</Component>
<Component ref={refCallback} prop="refCallback">
<RefCheckerInner forwardedRef={manualRef} text="refCallback" />
</Component>
</>
);
}
function RefCheckerInner({forwardedRef, text}) {
Scheduler.log(`RefCheckerInner:${text} render`);
React.useLayoutEffect(() => {
Scheduler.log(
`RefCheckerInner:${text} create layout ref? ${
forwardedRef.current != null
}`,
);
return () => {
Scheduler.log(
`RefCheckerInner:${text} destroy layout ref? ${
forwardedRef.current != null
}`,
);
};
}, []);
return null;
}
// @gate enableLegacyCache
it('should not be cleared within legacy roots', async () => {
class ClassComponent extends React.Component {
render() {
Scheduler.log(`ClassComponent:${this.props.prop} render`);
return this.props.children;
}
}
function App({children}) {
Scheduler.log(`App render`);
return (
<Suspense fallback={<Text text="Fallback" />}>
{children}
<RefCheckerOuter Component={ClassComponent} />
</Suspense>
);
}
await act(() => {
ReactNoop.renderLegacySyncRoot(<App />);
});
assertLog([
'App render',
'RefCheckerOuter render',
'ClassComponent:refObject render',
'RefCheckerInner:refObject render',
'ClassComponent:refCallback render',
'RefCheckerInner:refCallback render',
'RefCheckerInner:refObject create layout ref? false',
'RefCheckerInner:refCallback create layout ref? false',
'RefCheckerOuter refCallback value? true',
'RefCheckerOuter create layout refObject? true refCallback? true',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
// Suspend the inner Suspense subtree (only inner effects should be destroyed)
await act(() => {
ReactNoop.renderLegacySyncRoot(
<App children={<AsyncText text="Async" ms={1000} />} />,
);
});
await advanceTimers(1000);
assertLog([
'App render',
'Suspend:Async',
'RefCheckerOuter render',
'ClassComponent:refObject render',
'RefCheckerInner:refObject render',
'ClassComponent:refCallback render',
'RefCheckerInner:refCallback render',
'Text:Fallback render',
'Text:Fallback create layout',
'Text:Fallback create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Fallback" />);
// Resolving the suspended resource should re-create inner layout effects.
await act(async () => {
await resolveText('Async');
});
assertLog([
'AsyncText:Async render',
'Text:Fallback destroy layout',
'AsyncText:Async create layout',
'Text:Fallback destroy passive',
'AsyncText:Async create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Async" />);
await act(() => {
ReactNoop.renderLegacySyncRoot(null);
});
assertLog([
'AsyncText:Async destroy layout',
'RefCheckerOuter destroy layout refObject? true refCallback? true',
'RefCheckerInner:refObject destroy layout ref? false',
'RefCheckerOuter refCallback value? false',
'RefCheckerInner:refCallback destroy layout ref? false',
'AsyncText:Async destroy passive',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
});
// @gate enableLegacyCache
it('should be cleared and reset for host components', async () => {
function App({children}) {
Scheduler.log(`App render`);
return (
<Suspense fallback={<Text text="Fallback" />}>
{children}
<RefCheckerOuter Component="span" />
</Suspense>
);
}
// Mount
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'App render',
'RefCheckerOuter render',
'RefCheckerInner:refObject render',
'RefCheckerInner:refCallback render',
'RefCheckerInner:refObject create layout ref? false',
'RefCheckerInner:refCallback create layout ref? false',
'RefCheckerOuter refCallback value? true',
'RefCheckerOuter create layout refObject? true refCallback? true',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="refObject" />
<span prop="refCallback" />
</>,
);
// Suspend the inner Suspense subtree (only inner effects should be destroyed)
await act(() => {
ReactNoop.render(
<App children={<AsyncText text="Async" ms={1000} />} />,
);
});
await advanceTimers(1000);
assertLog([
'App render',
'Suspend:Async',
'Text:Fallback render',
'RefCheckerOuter destroy layout refObject? true refCallback? true',
'RefCheckerInner:refObject destroy layout ref? false',
'RefCheckerOuter refCallback value? false',
'RefCheckerInner:refCallback destroy layout ref? false',
'Text:Fallback create layout',
'Text:Fallback create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="refObject" hidden={true} />
<span prop="refCallback" hidden={true} />
<span prop="Fallback" />
</>,
);
// Resolving the suspended resource should re-create inner layout effects.
await act(async () => {
await resolveText('Async');
});
assertLog([
'AsyncText:Async render',
'RefCheckerOuter render',
'RefCheckerInner:refObject render',
'RefCheckerInner:refCallback render',
'Text:Fallback destroy layout',
'AsyncText:Async create layout',
'RefCheckerInner:refObject create layout ref? false',
'RefCheckerInner:refCallback create layout ref? false',
'RefCheckerOuter refCallback value? true',
'RefCheckerOuter create layout refObject? true refCallback? true',
'Text:Fallback destroy passive',
'AsyncText:Async create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="Async" />
<span prop="refObject" />
<span prop="refCallback" />
</>,
);
await act(() => {
ReactNoop.render(null);
});
assertLog([
'AsyncText:Async destroy layout',
'RefCheckerOuter destroy layout refObject? true refCallback? true',
'RefCheckerInner:refObject destroy layout ref? false',
'RefCheckerOuter refCallback value? false',
'RefCheckerInner:refCallback destroy layout ref? false',
'AsyncText:Async destroy passive',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
});
// @gate enableLegacyCache
it('should be cleared and reset for class components', async () => {
class ClassComponent extends React.Component {
render() {
Scheduler.log(`ClassComponent:${this.props.prop} render`);
return this.props.children;
}
}
function App({children}) {
Scheduler.log(`App render`);
return (
<Suspense fallback={<Text text="Fallback" />}>
{children}
<RefCheckerOuter Component={ClassComponent} />
</Suspense>
);
}
// Mount
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'App render',
'RefCheckerOuter render',
'ClassComponent:refObject render',
'RefCheckerInner:refObject render',
'ClassComponent:refCallback render',
'RefCheckerInner:refCallback render',
'RefCheckerInner:refObject create layout ref? false',
'RefCheckerInner:refCallback create layout ref? false',
'RefCheckerOuter refCallback value? true',
'RefCheckerOuter create layout refObject? true refCallback? true',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
// Suspend the inner Suspense subtree (only inner effects should be destroyed)
await act(() => {
ReactNoop.render(
<App children={<AsyncText text="Async" ms={1000} />} />,
);
});
await advanceTimers(1000);
assertLog([
'App render',
'Suspend:Async',
'Text:Fallback render',
'RefCheckerOuter destroy layout refObject? true refCallback? true',
'RefCheckerInner:refObject destroy layout ref? false',
'RefCheckerOuter refCallback value? false',
'RefCheckerInner:refCallback destroy layout ref? false',
'Text:Fallback create layout',
'Text:Fallback create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Fallback" />);
// Resolving the suspended resource should re-create inner layout effects.
await act(async () => {
await resolveText('Async');
});
assertLog([
'AsyncText:Async render',
'RefCheckerOuter render',
'ClassComponent:refObject render',
'RefCheckerInner:refObject render',
'ClassComponent:refCallback render',
'RefCheckerInner:refCallback render',
'Text:Fallback destroy layout',
'AsyncText:Async create layout',
'RefCheckerInner:refObject create layout ref? false',
'RefCheckerInner:refCallback create layout ref? false',
'RefCheckerOuter refCallback value? true',
'RefCheckerOuter create layout refObject? true refCallback? true',
'Text:Fallback destroy passive',
'AsyncText:Async create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Async" />);
await act(() => {
ReactNoop.render(null);
});
assertLog([
'AsyncText:Async destroy layout',
'RefCheckerOuter destroy layout refObject? true refCallback? true',
'RefCheckerInner:refObject destroy layout ref? false',
'RefCheckerOuter refCallback value? false',
'RefCheckerInner:refCallback destroy layout ref? false',
'AsyncText:Async destroy passive',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
});
// @gate enableLegacyCache
it('should be cleared and reset for function components with useImperativeHandle', async () => {
const FunctionComponent = React.forwardRef((props, ref) => {
Scheduler.log('FunctionComponent render');
React.useImperativeHandle(
ref,
() => ({
// Noop
}),
[],
);
return props.children;
});
FunctionComponent.displayName = 'FunctionComponent';
function App({children}) {
Scheduler.log(`App render`);
return (
<Suspense fallback={<Text text="Fallback" />}>
{children}
<RefCheckerOuter Component={FunctionComponent} />
</Suspense>
);
}
// Mount
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'App render',
'RefCheckerOuter render',
'FunctionComponent render',
'RefCheckerInner:refObject render',
'FunctionComponent render',
'RefCheckerInner:refCallback render',
'RefCheckerInner:refObject create layout ref? false',
'RefCheckerInner:refCallback create layout ref? false',
'RefCheckerOuter refCallback value? true',
'RefCheckerOuter create layout refObject? true refCallback? true',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
// Suspend the inner Suspense subtree (only inner effects should be destroyed)
await act(() => {
ReactNoop.render(
<App children={<AsyncText text="Async" ms={1000} />} />,
);
});
await advanceTimers(1000);
assertLog([
'App render',
'Suspend:Async',
'Text:Fallback render',
'RefCheckerOuter destroy layout refObject? true refCallback? true',
'RefCheckerInner:refObject destroy layout ref? false',
'RefCheckerOuter refCallback value? false',
'RefCheckerInner:refCallback destroy layout ref? false',
'Text:Fallback create layout',
'Text:Fallback create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Fallback" />);
// Resolving the suspended resource should re-create inner layout effects.
await act(async () => {
await resolveText('Async');
});
assertLog([
'AsyncText:Async render',
'RefCheckerOuter render',
'FunctionComponent render',
'RefCheckerInner:refObject render',
'FunctionComponent render',
'RefCheckerInner:refCallback render',
'Text:Fallback destroy layout',
'AsyncText:Async create layout',
'RefCheckerInner:refObject create layout ref? false',
'RefCheckerInner:refCallback create layout ref? false',
'RefCheckerOuter refCallback value? true',
'RefCheckerOuter create layout refObject? true refCallback? true',
'Text:Fallback destroy passive',
'AsyncText:Async create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Async" />);
await act(() => {
ReactNoop.render(null);
});
assertLog([
'AsyncText:Async destroy layout',
'RefCheckerOuter destroy layout refObject? true refCallback? true',
'RefCheckerInner:refObject destroy layout ref? false',
'RefCheckerOuter refCallback value? false',
'RefCheckerInner:refCallback destroy layout ref? false',
'AsyncText:Async destroy passive',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
});
// @gate enableLegacyCache
it('should not reset for user-managed values', async () => {
function RefChecker({forwardedRef}) {
Scheduler.log(`RefChecker render`);
React.useLayoutEffect(() => {
Scheduler.log(
`RefChecker create layout ref? ${forwardedRef.current === 'test'}`,
);
return () => {
Scheduler.log(
`RefChecker destroy layout ref? ${
forwardedRef.current === 'test'
}`,
);
};
}, []);
return null;
}
function App({children = null}) {
const ref = React.useRef('test');
Scheduler.log(`App render`);
React.useLayoutEffect(() => {
Scheduler.log(`App create layout ref? ${ref.current === 'test'}`);
return () => {
Scheduler.log(`App destroy layout ref? ${ref.current === 'test'}`);
};
}, []);
return (
<Suspense fallback={<Text text="Fallback" />}>
{children}
<RefChecker forwardedRef={ref} />
</Suspense>
);
}
// Mount
await act(() => {
ReactNoop.render(<App />);
});
assertLog([
'App render',
'RefChecker render',
'RefChecker create layout ref? true',
'App create layout ref? true',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
// Suspend the inner Suspense subtree (only inner effects should be destroyed)
await act(() => {
ReactNoop.render(
<App children={<AsyncText text="Async" ms={1000} />} />,
);
});
await advanceTimers(1000);
assertLog([
'App render',
'Suspend:Async',
'Text:Fallback render',
'RefChecker destroy layout ref? true',
'Text:Fallback create layout',
'Text:Fallback create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Fallback" />);
// Resolving the suspended resource should re-create inner layout effects.
await act(async () => {
await resolveText('Async');
});
assertLog([
'AsyncText:Async render',
'RefChecker render',
'Text:Fallback destroy layout',
'AsyncText:Async create layout',
'RefChecker create layout ref? true',
'Text:Fallback destroy passive',
'AsyncText:Async create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Async" />);
await act(() => {
ReactNoop.render(null);
});
assertLog([
'App destroy layout ref? true',
'AsyncText:Async destroy layout',
'RefChecker destroy layout ref? true',
'AsyncText:Async destroy passive',
]);
expect(ReactNoop).toMatchRenderedOutput(null);
});
describe('that throw errors', () => {
// @gate enableLegacyCache
// @gate replayFailedUnitOfWorkWithInvokeGuardedCallback
it('are properly handled in ref callbacks', async () => {
let useRefCallbackShouldThrow = false;
function ThrowsInRefCallback() {
Scheduler.log('ThrowsInRefCallback render');
const refCallback = React.useCallback(value => {
Scheduler.log('ThrowsInRefCallback refCallback ref? ' + !!value);
if (useRefCallbackShouldThrow) {
throw Error('expected');
}
}, []);
return <span ref={refCallback} prop="ThrowsInRefCallback" />;
}
function App({children = null}) {
Scheduler.log('App render');
React.useLayoutEffect(() => {
Scheduler.log('App create layout');
return () => {
Scheduler.log('App destroy layout');
};
}, []);
return (
<>
<Suspense fallback={<Text text="Fallback" />}>
{children}
<ThrowsInRefCallback />
<Text text="Inside" />
</Suspense>
<Text text="Outside" />
</>
);
}
await act(() => {
ReactNoop.render(
<ErrorBoundary fallback={<Text text="Error" />}>
<App />
</ErrorBoundary>,
);
});
assertLog([
'ErrorBoundary render: try',
'App render',
'ThrowsInRefCallback render',
'Text:Inside render',
'Text:Outside render',
'ThrowsInRefCallback refCallback ref? true',
'Text:Inside create layout',
'Text:Outside create layout',
'App create layout',
'Text:Inside create passive',
'Text:Outside create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="ThrowsInRefCallback" />
<span prop="Inside" />
<span prop="Outside" />
</>,
);
// Schedule an update that causes React to suspend.
await act(() => {
ReactNoop.render(
<ErrorBoundary fallback={<Text text="Error" />}>
<App>
<AsyncText text="Async" ms={1000} />
</App>
</ErrorBoundary>,
);
});
assertLog([
'ErrorBoundary render: try',
'App render',
'Suspend:Async',
'Text:Fallback render',
'Text:Outside render',
'ThrowsInRefCallback refCallback ref? false',
'Text:Inside destroy layout',
'Text:Fallback create layout',
'Text:Fallback create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(
<>
<span prop="ThrowsInRefCallback" hidden={true} />
<span prop="Inside" hidden={true} />
<span prop="Fallback" />
<span prop="Outside" />
</>,
);
// Resolve the pending suspense and throw
useRefCallbackShouldThrow = true;
await act(async () => {
await resolveText('Async');
});
assertLog([
'AsyncText:Async render',
'ThrowsInRefCallback render',
'Text:Inside render',
// Even though an error was thrown in refCallback,
// subsequent layout effects should still be created.
'Text:Fallback destroy layout',
'AsyncText:Async create layout',
'ThrowsInRefCallback refCallback ref? true',
'Text:Inside create layout',
// Finish the in-progress commit
'Text:Fallback destroy passive',
'AsyncText:Async create passive',
// Destroy layout and passive effects in the errored tree.
'App destroy layout',
'AsyncText:Async destroy layout',
'ThrowsInRefCallback refCallback ref? false',
'Text:Inside destroy layout',
'Text:Outside destroy layout',
'AsyncText:Async destroy passive',
'Text:Inside destroy passive',
'Text:Outside destroy passive',
// Render fallback
'ErrorBoundary render: catch',
'Text:Error render',
'Text:Error create layout',
'Text:Error create passive',
]);
expect(ReactNoop).toMatchRenderedOutput(<span prop="Error" />);
});
});
});
});