let React; let Fragment; let ReactNoop; let Scheduler; let act; let waitFor; let waitForAll; let waitForMicrotasks; let assertLog; let waitForPaint; let Suspense; let startTransition; let getCacheForType; let caches; let seededCache; describe('ReactSuspenseWithNoopRenderer', () => { beforeEach(() => { jest.resetModules(); React = require('react'); Fragment = React.Fragment; ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); act = require('internal-test-utils').act; Suspense = React.Suspense; startTransition = React.startTransition; const InternalTestUtils = require('internal-test-utils'); waitFor = InternalTestUtils.waitFor; waitForAll = InternalTestUtils.waitForAll; waitForPaint = InternalTestUtils.waitForPaint; waitForMicrotasks = InternalTestUtils.waitForMicrotasks; assertLog = InternalTestUtils.assertLog; getCacheForType = React.unstable_getCacheForType; caches = []; seededCache = null; }); 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({text}) { Scheduler.log(text); return ; } function AsyncText({text, showVersion}) { const version = readText(text); const fullText = showVersion ? `${text} [v${version}]` : text; Scheduler.log(fullText); return ; } function seedNextTextCache(text) { if (seededCache === null) { seededCache = createTextCache(); } seededCache.resolve(text); } 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 rejectMostRecentTextCache(text, error) { 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].reject(text, error)`. caches[caches.length - 1].reject(text, error); } } const rejectText = rejectMostRecentTextCache; 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(() => {}); } // Note: This is based on a similar component we use in www. We can delete // once the extra div wrapper is no longer necessary. function LegacyHiddenDiv({children, mode}) { return ( ); } // @gate enableLegacyCache it("does not restart if there's a ping during initial render", async () => { function Bar(props) { Scheduler.log('Bar'); return props.children; } function Foo() { Scheduler.log('Foo'); return ( <> }> ); } React.startTransition(() => { ReactNoop.render(); }); await waitFor([ 'Foo', 'Bar', // A suspends 'Suspend! [A]', // We immediately unwind and switch to a fallback without // rendering siblings. 'Loading...', 'C', // Yield before rendering D ]); expect(ReactNoop).toMatchRenderedOutput(null); // Flush the promise completely await act(async () => { await resolveText('A'); // Even though the promise has resolved, we should now flush // and commit the in progress render instead of restarting. await waitForPaint(['D']); expect(ReactNoop).toMatchRenderedOutput( <> , ); // Next, we'll flush the complete content. await waitForAll(['Bar', 'A', 'B']); }); expect(ReactNoop).toMatchRenderedOutput( <> , ); }); // @gate enableLegacyCache it('suspends rendering and continues later', async () => { function Bar(props) { Scheduler.log('Bar'); return props.children; } function Foo({renderBar}) { Scheduler.log('Foo'); return ( }> {renderBar ? ( ) : null} ); } // Render empty shell. ReactNoop.render(); await waitForAll(['Foo']); // The update will suspend. React.startTransition(() => { ReactNoop.render(); }); await waitForAll([ 'Foo', 'Bar', // A suspends 'Suspend! [A]', // pre-warming 'B', // end pre-warming // We immediately unwind and switch to a fallback without // rendering siblings. 'Loading...', ]); expect(ReactNoop).toMatchRenderedOutput(null); // Resolve the data await resolveText('A'); // Renders successfully await waitForAll(['Foo', 'Bar', 'A', 'B']); expect(ReactNoop).toMatchRenderedOutput( <> , ); }); // @gate enableLegacyCache it('suspends siblings and later recovers each independently', async () => { // Render two sibling Suspense components ReactNoop.render( }> }> , ); await waitForAll([ 'Suspend! [A]', 'Loading A...', 'Suspend! [B]', 'Loading B...', // pre-warming 'Suspend! [A]', 'Suspend! [B]', ]); expect(ReactNoop).toMatchRenderedOutput( <> , ); // Resolve first Suspense's promise so that it switches switches back to the // normal view. The second Suspense should still show the placeholder. await act(() => resolveText('A')); assertLog([ 'A', ...(gate('alwaysThrottleRetries') ? ['Suspend! [B]', 'Suspend! [B]'] : []), ]); expect(ReactNoop).toMatchRenderedOutput( <> , ); // Resolve the second Suspense's promise so that it switches back to the // normal view. await act(() => resolveText('B')); assertLog(['B']); expect(ReactNoop).toMatchRenderedOutput( <> , ); }); // @gate enableLegacyCache it('when something suspends, unwinds immediately without rendering siblings', async () => { // A shell is needed. The update cause it to suspend. ReactNoop.render(} />); await waitForAll([]); React.startTransition(() => { ReactNoop.render( }> , ); }); // B suspends. Render a fallback await waitForAll([ 'A', 'Suspend! [B]', // pre-warming 'C', 'D', // end pre-warming 'Loading...', ]); // Did not commit yet. expect(ReactNoop).toMatchRenderedOutput(null); // Wait for data to resolve await resolveText('B'); await waitForAll(['A', 'B', 'C', 'D']); // Renders successfully expect(ReactNoop).toMatchRenderedOutput( <> , ); }); // Second condition is redundant but guarantees that the test runs in prod. // @gate enableLegacyCache it('retries on error', async () => { class ErrorBoundary extends React.Component { state = {error: null}; componentDidCatch(error) { this.setState({error}); } reset() { this.setState({error: null}); } render() { if (this.state.error !== null) { return ; } return this.props.children; } } const errorBoundary = React.createRef(); function App({renderContent}) { return ( }> {renderContent ? ( ) : null} ); } ReactNoop.render(); await waitForAll([]); expect(ReactNoop).toMatchRenderedOutput(null); React.startTransition(() => { ReactNoop.render(); }); await waitForAll(['Suspend! [Result]', 'Loading...']); expect(ReactNoop).toMatchRenderedOutput(null); await rejectText('Result', new Error('Failed to load: Result')); await waitForAll([ 'Error! [Result]', // React retries one more time 'Error! [Result]', // Errored again on retry. Now handle it. 'Caught error: Failed to load: Result', ]); expect(ReactNoop).toMatchRenderedOutput( , ); }); // Second condition is redundant but guarantees that the test runs in prod. // @gate enableLegacyCache it('retries on error after falling back to a placeholder', async () => { class ErrorBoundary extends React.Component { state = {error: null}; componentDidCatch(error) { this.setState({error}); } reset() { this.setState({error: null}); } render() { if (this.state.error !== null) { return ; } return this.props.children; } } const errorBoundary = React.createRef(); function App() { return ( }> ); } ReactNoop.render(); await waitForAll([ 'Suspend! [Result]', 'Loading...', // pre-warming 'Suspend! [Result]', ]); expect(ReactNoop).toMatchRenderedOutput(); await act(() => rejectText('Result', new Error('Failed to load: Result'))); assertLog([ 'Error! [Result]', // React retries one more time 'Error! [Result]', // Errored again on retry. Now handle it. 'Caught error: Failed to load: Result', ]); expect(ReactNoop).toMatchRenderedOutput( , ); }); // @gate enableLegacyCache it('can update at a higher priority while in a suspended state', async () => { let setHighPri; function HighPri() { const [text, setText] = React.useState('A'); setHighPri = setText; return ; } let setLowPri; function LowPri() { const [text, setText] = React.useState('1'); setLowPri = setText; return ; } function App() { return ( <> }> ); } // Initial mount await act(() => ReactNoop.render()); assertLog([ 'A', 'Suspend! [1]', 'Loading...', // pre-warming 'Suspend! [1]', ]); await act(() => resolveText('1')); assertLog(['1']); expect(ReactNoop).toMatchRenderedOutput( <> , ); // Update the low-pri text await act(() => startTransition(() => setLowPri('2'))); // Suspends assertLog(['Suspend! [2]', 'Loading...']); // While we're still waiting for the low-pri update to complete, update the // high-pri text at high priority. ReactNoop.flushSync(() => { setHighPri('B'); }); assertLog(['B']); expect(ReactNoop).toMatchRenderedOutput( <> , ); // Unblock the low-pri text and finish. Nothing in the UI changes because // the update was overriden await act(() => resolveText('2')); assertLog(['2']); expect(ReactNoop).toMatchRenderedOutput( <> , ); }); // @gate enableLegacyCache it('keeps working on lower priority work after being pinged', async () => { function App(props) { return ( }> {props.showA && } {props.showB && } ); } ReactNoop.render(); await waitForAll([]); expect(ReactNoop).toMatchRenderedOutput(null); React.startTransition(() => { ReactNoop.render(); }); await waitForAll(['Suspend! [A]', 'Loading...']); expect(ReactNoop).toMatchRenderedOutput(null); React.startTransition(() => { ReactNoop.render(); }); await waitForAll([ 'Suspend! [A]', // pre-warming 'B', // end pre-warming 'Loading...', ]); expect(ReactNoop).toMatchRenderedOutput(null); await resolveText('A'); await waitForAll(['A', 'B']); expect(ReactNoop).toMatchRenderedOutput( <> , ); }); // @gate enableLegacyCache it('tries rendering a lower priority pending update even if a higher priority one suspends', async () => { function App(props) { if (props.hide) { return ; } return ( ); } // Schedule a default pri update and a low pri update, without rendering in between. // Default pri ReactNoop.render(); // Low pri React.startTransition(() => { ReactNoop.render(); }); await waitForAll([ // The first update suspends 'Suspend! [Async]', // but we have another pending update that we can work on '(empty)', ]); expect(ReactNoop).toMatchRenderedOutput(); }); // Note: This test was written to test a heuristic used in the expiration // times model. Might not make sense in the new model. // TODO: This test doesn't over what it was originally designed to test. // Either rewrite or delete. it('tries each subsequent level after suspending', async () => { const root = ReactNoop.createRoot(); function App({step, shouldSuspend}) { return ( {shouldSuspend ? ( ) : ( )} ); } function interrupt() { // React has a heuristic to batch all updates that occur within the same // event. This is a trick to circumvent that heuristic. ReactNoop.flushSync(() => { ReactNoop.renderToRootWithID(null, 'other-root'); }); } // Mount the Suspense boundary without suspending, so that the subsequent // updates suspend with a delay. await act(() => { root.render(); }); await advanceTimers(1000); assertLog(['Sibling', 'Step 0']); // Schedule an update at several distinct expiration times await act(async () => { React.startTransition(() => { root.render(); }); Scheduler.unstable_advanceTime(1000); await waitFor(['Sibling']); interrupt(); React.startTransition(() => { root.render(); }); Scheduler.unstable_advanceTime(1000); await waitFor(['Sibling']); interrupt(); React.startTransition(() => { root.render(); }); Scheduler.unstable_advanceTime(1000); await waitFor(['Sibling']); interrupt(); root.render(); }); assertLog(['Sibling', 'Step 4']); }); // @gate enableLegacyCache it('switches to an inner fallback after suspending for a while', async () => { // Advance the virtual time so that we're closer to the edge of a bucket. ReactNoop.expire(200); ReactNoop.render( }> }> , ); await waitForAll([ 'Sync', // The async content suspends 'Suspend! [Outer content]', 'Loading outer...', // pre-warming 'Suspend! [Outer content]', 'Suspend! [Inner content]', 'Loading inner...', ]); // The outer loading state finishes immediately. expect(ReactNoop).toMatchRenderedOutput( <> , ); // Resolve the outer promise. await resolveText('Outer content'); await waitForAll([ 'Outer content', 'Suspend! [Inner content]', 'Loading inner...', ]); // Don't commit the inner placeholder yet. expect(ReactNoop).toMatchRenderedOutput( <> , ); // Expire the inner timeout. ReactNoop.expire(500); await advanceTimers(500); // Now that 750ms have elapsed since the outer placeholder timed out, // we can timeout the inner placeholder. expect(ReactNoop).toMatchRenderedOutput( <> , ); // Finally, flush the inner promise. We should see the complete screen. await act(() => resolveText('Inner content')); assertLog(['Inner content']); expect(ReactNoop).toMatchRenderedOutput( <> , ); }); // @gate enableLegacyCache it('renders an Suspense boundary synchronously', async () => { spyOnDev(console, 'error'); // Synchronously render a tree that suspends ReactNoop.flushSync(() => ReactNoop.render( }> , ), ); assertLog([ // The async child suspends 'Suspend! [Async]', // We immediately render the fallback UI 'Loading...', // Continue on the sibling 'Sync', ]); // The tree commits synchronously expect(ReactNoop).toMatchRenderedOutput( <> , ); // Once the promise resolves, we render the suspended view await act(() => resolveText('Async')); assertLog(['Async']); expect(ReactNoop).toMatchRenderedOutput( <> , ); }); // @gate enableLegacyCache it('suspending inside an expired expiration boundary will bubble to the next one', async () => { ReactNoop.flushSync(() => ReactNoop.render( }> }> , ), ); assertLog([ 'Suspend! [Async]', 'Suspend! [Loading (inner)...]', 'Loading (outer)...', ]); // The tree commits synchronously expect(ReactNoop).toMatchRenderedOutput(); }); // @gate enableLegacyCache it('resolves successfully even if fallback render is pending', async () => { const root = ReactNoop.createRoot(); root.render( <> } /> , ); await waitForAll([]); expect(root).toMatchRenderedOutput(null); React.startTransition(() => { root.render( <> }> , ); }); await waitFor(['Suspend! [Async]']); await resolveText('Async'); // Because we're already showing a fallback, interrupt the current render // and restart immediately. await waitForAll(['Async', 'Sibling']); expect(root).toMatchRenderedOutput( <> , ); }); // @gate enableLegacyCache it('in concurrent mode, does not error when an update suspends without a Suspense boundary during a sync update', () => { // NOTE: We may change this to be a warning in the future. expect(() => { ReactNoop.flushSync(() => { ReactNoop.render(); }); }).not.toThrow(); }); // @gate enableLegacyCache && !disableLegacyMode it('in legacy mode, errors when an update suspends without a Suspense boundary during a sync update', async () => { const root = ReactNoop.createLegacyRoot(); await expect(async () => { await act(() => root.render()); }).rejects.toThrow( 'A component suspended while responding to synchronous input.', ); }); // @gate enableLegacyCache it('a Suspense component correctly handles more than one suspended child', async () => { ReactNoop.render( }> , ); await waitForAll([ 'Suspend! [A]', 'Loading...', // pre-warming 'Suspend! [A]', 'Suspend! [B]', ]); expect(ReactNoop).toMatchRenderedOutput(); await act(() => { resolveText('A'); resolveText('B'); }); assertLog(['A', 'B']); expect(ReactNoop).toMatchRenderedOutput( <> , ); }); // @gate enableLegacyCache it('can resume rendering earlier than a timeout', async () => { ReactNoop.render(} />); await waitForAll([]); React.startTransition(() => { ReactNoop.render( }> , ); }); await waitForAll(['Suspend! [Async]', 'Loading...']); expect(ReactNoop).toMatchRenderedOutput(null); // Resolve the promise await resolveText('Async'); // We can now resume rendering await waitForAll(['Async']); expect(ReactNoop).toMatchRenderedOutput(); }); // @gate enableLegacyCache it('starts working on an update even if its priority falls between two suspended levels', async () => { function App(props) { return ( }> {props.text === 'C' || props.text === 'S' ? ( ) : ( )} ); } // First mount without suspending. This ensures we already have content // showing so that subsequent updates will suspend. ReactNoop.render(); await waitForAll(['S']); // Schedule an update, and suspend for up to 5 seconds. React.startTransition(() => ReactNoop.render()); // The update should suspend. await waitForAll(['Suspend! [A]', 'Loading...']); expect(ReactNoop).toMatchRenderedOutput(); // Advance time until right before it expires. await advanceTimers(4999); ReactNoop.expire(4999); await waitForAll([]); expect(ReactNoop).toMatchRenderedOutput(); // Schedule another low priority update. React.startTransition(() => ReactNoop.render()); // This update should also suspend. await waitForAll(['Suspend! [B]', 'Loading...']); expect(ReactNoop).toMatchRenderedOutput(); // Schedule a regular update. Its expiration time will fall between // the expiration times of the previous two updates. ReactNoop.render(); await waitForAll(['C']); expect(ReactNoop).toMatchRenderedOutput(); // Flush the remaining work. await resolveText('A'); await resolveText('B'); // Nothing else to render. await waitForAll([]); expect(ReactNoop).toMatchRenderedOutput(); }); // @gate enableLegacyCache it('a suspended update that expires', async () => { // Regression test. This test used to fall into an infinite loop. function ExpensiveText({text}) { // This causes the update to expire. Scheduler.unstable_advanceTime(10000); // Then something suspends. return ; } function App() { return ( ); } ReactNoop.render(); await waitForAll([ 'Suspend! [A]', // pre-warming 'Suspend! [A]', 'Suspend! [B]', 'Suspend! [C]', ]); expect(ReactNoop).toMatchRenderedOutput('Loading...'); await resolveText('A'); await resolveText('B'); await resolveText('C'); await waitForAll(['A', 'B', 'C']); expect(ReactNoop).toMatchRenderedOutput( <> , ); }); describe('legacy mode mode', () => { // @gate enableLegacyCache && !disableLegacyMode it('times out immediately', async () => { function App() { return ( }> ); } // Times out immediately, ignoring the specified threshold. ReactNoop.renderLegacySyncRoot(); assertLog(['Suspend! [Result]', 'Loading...']); expect(ReactNoop).toMatchRenderedOutput(); await act(() => { resolveText('Result'); }); assertLog(['Result']); expect(ReactNoop).toMatchRenderedOutput(); }); // @gate enableLegacyCache && !disableLegacyMode it('times out immediately when Suspense is in legacy mode', async () => { class UpdatingText extends React.Component { state = {step: 1}; render() { return ; } } function Spinner() { return ( ); } const text = React.createRef(null); function App() { return ( }> ); } // Initial mount. await seedNextTextCache('Step: 1'); ReactNoop.renderLegacySyncRoot(); assertLog(['Step: 1', 'Sibling']); expect(ReactNoop).toMatchRenderedOutput( <> , ); // Update. text.current.setState({step: 2}, () => Scheduler.log('Update did commit'), ); expect(ReactNoop.flushNextYield()).toEqual([ 'Suspend! [Step: 2]', 'Loading (1)', 'Loading (2)', 'Loading (3)', 'Update did commit', ]); expect(ReactNoop).toMatchRenderedOutput( <>