let React; let ReactNoop; let Scheduler; let act; let LegacyHidden; let Activity; let Suspense; let useState; let useEffect; let startTransition; let textCache; let waitFor; let waitForPaint; let assertLog; let use; describe('Activity Suspense', () => { beforeEach(() => { jest.resetModules(); React = require('react'); ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); act = require('internal-test-utils').act; LegacyHidden = React.unstable_LegacyHidden; Activity = React.Activity; Suspense = React.Suspense; useState = React.useState; useEffect = React.useEffect; startTransition = React.startTransition; use = React.use; const InternalTestUtils = require('internal-test-utils'); waitFor = InternalTestUtils.waitFor; waitForPaint = InternalTestUtils.waitForPaint; assertLog = InternalTestUtils.assertLog; textCache = new Map(); }); function resolveText(text) { const record = textCache.get(text); if (record === undefined) { const promise = Promise.resolve(text); promise.status = 'fulfilled'; promise.value = text; const newRecord = { promise, }; textCache.set(text, newRecord); } else if (record.promise.status === 'pending') { const resolve = record.resolve; record.promise.status = 'fulfilled'; record.promise.value = text; resolve(text); } } function readText(text) { let record = textCache.get(text); if (record === undefined) { let resolve; const promise = new Promise(_resolve => { resolve = _resolve; }); promise.status = 'pending'; record = { promise, resolve, }; textCache.set(text, record); } if (record.promise.status === 'pending') { Scheduler.log(`Suspend! [${text}]`); } return use(record.promise); } function Text({text}) { Scheduler.log(text); return text; } function AsyncText({text}) { readText(text); Scheduler.log(text); return text; } it('basic example of suspending inside hidden tree', async () => { const root = ReactNoop.createRoot(); function App() { return ( }> ); } // The hidden tree hasn't finished loading, but we should still be able to // show the surrounding contents. The outer Suspense boundary // isn't affected. await act(() => { root.render(); }); assertLog(['Visible', 'Suspend! [Hidden]']); expect(root).toMatchRenderedOutput(Visible); // When the data resolves, we should be able to finish prerendering // the hidden tree. await act(async () => { await resolveText('Hidden'); }); assertLog(['Hidden']); expect(root).toMatchRenderedOutput( <> Visible , ); }); // @gate enableLegacyHidden test('LegacyHidden does not handle suspense', async () => { const root = ReactNoop.createRoot(); function App() { return ( }> ); } // Unlike Activity, LegacyHidden never captures if something suspends await act(() => { root.render(); }); assertLog(['Visible', 'Suspend! [Hidden]', 'Loading...']); // Nearest Suspense boundary switches to a fallback even though the // suspended content is hidden. expect(root).toMatchRenderedOutput( <> Loading... , ); }); // @gate __DEV__ test('Regression: Suspending on hide should not infinite loop.', async () => { // This regression only repros in public act. global.IS_REACT_ACT_ENVIRONMENT = true; const root = ReactNoop.createRoot(); let setMode; function Container({text}) { const [mode, _setMode] = React.useState('visible'); setMode = _setMode; useEffect(() => { return () => { Scheduler.log(`Clear [${text}]`); textCache.delete(text); }; }); return ( //$FlowFixMe ); } await React.act(() => { root.render(); }); assertLog([ 'Suspend! [hello]', // pre-warming 'Suspend! [hello]', ]); expect(root).toMatchRenderedOutput('Loading'); await React.act(async () => { await resolveText('hello'); }); assertLog(['hello']); expect(root).toMatchRenderedOutput('hello'); await React.act(() => { setMode('hidden'); }); assertLog(['Clear [hello]', 'Suspend! [hello]']); expect(root).toMatchRenderedOutput(''); }); test("suspending inside currently hidden tree that's switching to visible", async () => { const root = ReactNoop.createRoot(); function Details({open, children}) { return ( }> {children} ); } // The hidden tree hasn't finished loading, but we should still be able to // show the surrounding contents. It doesn't matter that there's no // Suspense boundary because the unfinished content isn't visible. await act(() => { root.render(
, ); }); assertLog(['Closed', 'Suspend! [Async]']); expect(root).toMatchRenderedOutput(Closed); // But when we switch the boundary from hidden to visible, it should // now bubble to the nearest Suspense boundary. await act(() => { startTransition(() => { root.render(
, ); }); }); assertLog([ 'Open', 'Suspend! [Async]', // pre-warming 'Loading...', ]); // It should suspend with delay to prevent the already-visible Suspense // boundary from switching to a fallback expect(root).toMatchRenderedOutput(Closed); // Resolve the data and finish rendering await act(async () => { await resolveText('Async'); }); assertLog([ // pre-warming 'Open', // end pre-warming 'Async', ]); expect(root).toMatchRenderedOutput( <> Open Async , ); }); test("suspending inside currently visible tree that's switching to hidden", async () => { const root = ReactNoop.createRoot(); function Details({open, children}) { return ( }> {children} ); } // Initial mount. Nothing suspends await act(() => { root.render(
, ); }); assertLog(['Open', '(empty)']); expect(root).toMatchRenderedOutput( <> Open (empty) , ); // Update that suspends inside the currently visible tree await act(() => { startTransition(() => { root.render(
, ); }); }); assertLog([ 'Open', 'Suspend! [Async]', // pre-warming 'Loading...', ]); // It should suspend with delay to prevent the already-visible Suspense // boundary from switching to a fallback expect(root).toMatchRenderedOutput( <> Open (empty) , ); // Update that hides the suspended tree await act(() => { startTransition(() => { root.render(
, ); }); }); // Now the visible part of the tree can commit without being blocked // by the suspended content, which is hidden. assertLog(['Closed', 'Suspend! [Async]']); expect(root).toMatchRenderedOutput( <> Closed , ); // Resolve the data and finish rendering await act(async () => { await resolveText('Async'); }); assertLog(['Async']); expect(root).toMatchRenderedOutput( <> Closed , ); }); test('update that suspends inside hidden tree', async () => { let setText; function Child() { const [text, _setText] = useState('A'); setText = _setText; return ; } function App({show}) { return ( ); } const root = ReactNoop.createRoot(); resolveText('A'); await act(() => { root.render(); }); assertLog(['A']); await act(() => { startTransition(() => { setText('B'); }); }); }); test('updates at multiple priorities that suspend inside hidden tree', async () => { let setText; let setStep; function Child() { const [text, _setText] = useState('A'); setText = _setText; const [step, _setStep] = useState(0); setStep = _setStep; return ; } function App({show}) { return ( ); } const root = ReactNoop.createRoot(); resolveText('A0'); await act(() => { root.render(); }); assertLog(['A0']); expect(root).toMatchRenderedOutput(); await act(() => { React.startTransition(() => { setStep(1); }); ReactNoop.flushSync(() => { setText('B'); }); }); assertLog([ // The high priority render suspends again 'Suspend! [B0]', // There's still pending work in another lane, so we should attempt // that, too. 'Suspend! [B1]', ]); expect(root).toMatchRenderedOutput(); // Resolve the data and finish rendering await act(() => { resolveText('B1'); }); assertLog(['B1']); expect(root).toMatchRenderedOutput(); }); test('detect updates to a hidden tree during a concurrent event', async () => { // This is a pretty complex test case. It relates to how we detect if an // update is made to a hidden tree: when scheduling the update, we walk up // the fiber return path to see if any of the parents is a hidden Activity // component. This doesn't work if there's already a render in progress, // because the tree might be about to flip to hidden. To avoid a data race, // queue updates atomically: wait to queue the update until after the // current render has finished. let setInner; function Child({outer}) { const [inner, _setInner] = useState(0); setInner = _setInner; useEffect(() => { // Inner and outer values are always updated simultaneously, so they // should always be consistent. if (inner !== outer) { Scheduler.log('Tearing! Inner and outer are inconsistent!'); } else { Scheduler.log('Inner and outer are consistent'); } }, [inner, outer]); return ; } let setOuter; function App({show}) { const [outer, _setOuter] = useState(0); setOuter = _setOuter; return ( <> }> ); } // Render a hidden tree const root = ReactNoop.createRoot(); resolveText('Async: 0'); await act(() => { root.render(); }); assertLog([ 'Inner: 0', 'Outer: 0', 'Sibling: 0', 'Inner and outer are consistent', ]); expect(root).toMatchRenderedOutput( <> Inner: 0 Outer: 0 Sibling: 0 , ); await act(async () => { // Update a value both inside and outside the hidden tree. These values // must always be consistent. startTransition(() => { setOuter(1); setInner(1); // In the same render, also hide the offscreen tree. root.render(); }); await waitFor([ // The outer update will commit, but the inner update is deferred until // a later render. 'Outer: 1', ]); // Assert that we haven't committed quite yet expect(root).toMatchRenderedOutput( <> Inner: 0 Outer: 0 Sibling: 0 , ); // Before the tree commits, schedule a concurrent event. The inner update // is to a tree that's just about to be hidden. startTransition(() => { setOuter(2); setInner(2); }); // Finish rendering and commit the in-progress render. await waitForPaint(['Sibling: 1']); expect(root).toMatchRenderedOutput( <> Outer: 1 Sibling: 1 , ); // Now reveal the hidden tree at high priority. ReactNoop.flushSync(() => { root.render(); }); assertLog([ // There are two pending updates on Inner, but only the first one // is processed, even though they share the same lane. If the second // update were erroneously processed, then Inner would be inconsistent // with Outer. 'Inner: 1', 'Outer: 1', 'Sibling: 1', 'Inner and outer are consistent', ]); }); assertLog([ 'Inner: 2', 'Outer: 2', 'Sibling: 2', 'Inner and outer are consistent', ]); expect(root).toMatchRenderedOutput( <> Inner: 2 Outer: 2 Sibling: 2 , ); }); });