/** * 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.useInsertionEffect(() => { Scheduler.log(`Text:${text} create insertion`); return () => { Scheduler.log(`Text:${text} destroy insertion`); }; }, []); 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 {children}; } 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 {children}; } 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 {children}; } } 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 ( <> }> {children} ); } // Mount and suspend. await act(() => { ReactNoop.render( , ); }); assertLog([ 'App render', 'Text:Inside:Before render', 'Suspend:Async', 'Text:Fallback render', 'Text:Outside render', 'Text:Fallback create insertion', 'Text:Outside create insertion', 'Text:Fallback create layout', 'Text:Outside create layout', 'App create layout', 'Text:Fallback create passive', 'Text:Outside create passive', 'App create passive', // pre-warming 'Text:Inside:Before render', 'Suspend:Async', 'ClassText:Inside:After render', ]); expect(ReactNoop).toMatchRenderedOutput( <> , ); // 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 insertion', 'Text:Fallback destroy layout', 'Text:Inside:Before create insertion', '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( <> , ); await act(() => { ReactNoop.render(null); }); assertLog([ 'App destroy layout', 'Text:Inside:Before destroy insertion', 'Text:Inside:Before destroy layout', 'AsyncText:Async destroy layout', 'ClassText:Inside:After componentWillUnmount', 'Text:Outside destroy insertion', '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 && !disableLegacyMode 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 {children}; } } 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 ( <> }> {children} ); } // Mount and suspend. await act(() => { ReactNoop.renderLegacySyncRoot( , ); }); assertLog([ 'App render', 'Text:Inside:Before render', 'Suspend:Async', 'ClassText:Inside:After render', 'Text:Fallback render', 'Text:Outside render', 'Text:Inside:Before create insertion', 'Text:Fallback create insertion', 'Text:Outside create insertion', '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( <>