/
githubmr
/
facebook-react
Обзор
Документация
Войти
/
githubmr
/
facebook-react
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
main
packages/react-reconciler/src/__tests__/ReactTransition-test.js
982 строки
27 KB
Jack Pope
Clean up enableSiblingPrerendering flag (#32319)
09 май 2025, 03:49
Не верифицирован
09 май 2025, 03:49
4ca97e4
Код
Авторство
О чём код?
/** * 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. * * @emails react-core * @jest-environment node */ 'use strict'; let React; let ReactNoop; let Scheduler; let Suspense; let useState; let useLayoutEffect; let useTransition; let startTransition; let act; let getCacheForType; let waitForAll; let waitFor; let waitForPaint; let assertLog; let caches; let seededCache; describe('ReactTransition', () => { beforeEach(() => { jest.resetModules(); React = require('react'); ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); useState = React.useState; useLayoutEffect = React.useLayoutEffect; useTransition = React.useTransition; Suspense = React.Suspense; startTransition = React.startTransition; getCacheForType = React.unstable_getCacheForType; act = require('internal-test-utils').act; const InternalTestUtils = require('internal-test-utils'); waitForAll = InternalTestUtils.waitForAll; waitFor = InternalTestUtils.waitFor; waitForPaint = InternalTestUtils.waitForPaint; assertLog = InternalTestUtils.assertLog; 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 text; } function AsyncText({text}) { readText(text); Scheduler.log(text); return text; } function seedNextTextCache(text) { if (seededCache === null) { seededCache = createTextCache(); } seededCache.resolve(text); } function resolveText(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); } } // @gate enableLegacyCache it('isPending works even if called from outside an input event', async () => { let start; function App() { const [show, setShow] = useState(false); const [isPending, _start] = useTransition(); start = () => _start(() => setShow(true)); return ( <Suspense fallback={<Text text="Loading..." />}> {isPending ? <Text text="Pending..." /> : null} {show ? <AsyncText text="Async" /> : <Text text="(empty)" />} </Suspense> ); } const root = ReactNoop.createRoot(); await act(() => { root.render(<App />); }); assertLog(['(empty)']); expect(root).toMatchRenderedOutput('(empty)'); await act(async () => { start(); await waitForAll([ 'Pending...', '(empty)', 'Suspend! [Async]', 'Loading...', ]); expect(root).toMatchRenderedOutput('Pending...(empty)'); await resolveText('Async'); }); assertLog(['Async']); expect(root).toMatchRenderedOutput('Async'); }); // @gate enableLegacyCache it( 'when multiple transitions update the same queue, only the most recent ' + 'one is allowed to finish (no intermediate states)', async () => { let update; function App() { const [isContentPending, startContentChange] = useTransition(); const [label, setLabel] = useState('A'); const [contents, setContents] = useState('A'); update = value => { ReactNoop.discreteUpdates(() => { setLabel(value); startContentChange(() => { setContents(value); }); }); }; return ( <> <Text text={ label + ' label' + (isContentPending ? ' (loading...)' : '') } /> <div> <Suspense fallback={<Text text="Loading..." />}> <AsyncText text={contents + ' content'} /> </Suspense> </div> </> ); } // Initial render const root = ReactNoop.createRoot(); await act(() => { seedNextTextCache('A content'); root.render(<App />); }); assertLog(['A label', 'A content']); expect(root).toMatchRenderedOutput( <> A label<div>A content</div> </>, ); // Switch to B await act(() => { update('B'); }); assertLog([ // Commit pending state 'B label (loading...)', 'A content', // Attempt to render B, but it suspends 'B label', 'Suspend! [B content]', 'Loading...', ]); // This is a refresh transition so it shouldn't show a fallback expect(root).toMatchRenderedOutput( <> B label (loading...)<div>A content</div> </>, ); // Before B finishes loading, switch to C await act(() => { update('C'); }); assertLog([ // Commit pending state 'C label (loading...)', 'A content', // Attempt to render C, but it suspends 'C label', 'Suspend! [C content]', 'Loading...', ]); expect(root).toMatchRenderedOutput( <> C label (loading...)<div>A content</div> </>, ); // Finish loading B. But we're not allowed to render B because it's // entangled with C. So we're still pending. await act(() => { resolveText('B content'); }); assertLog([ // Attempt to render C, but it suspends 'C label', 'Suspend! [C content]', 'Loading...', ]); expect(root).toMatchRenderedOutput( <> C label (loading...)<div>A content</div> </>, ); // Now finish loading C. This is the terminal update, so it can finish. await act(() => { resolveText('C content'); }); assertLog(['C label', 'C content']); expect(root).toMatchRenderedOutput( <> C label<div>C content</div> </>, ); }, ); // Same as previous test, but for class update queue. // @gate enableLegacyCache it( 'when multiple transitions update the same queue, only the most recent ' + 'one is allowed to finish (no intermediate states) (classes)', async () => { let update; class App extends React.Component { state = { label: 'A', contents: 'A', }; render() { update = value => { ReactNoop.discreteUpdates(() => { this.setState({label: value}); startTransition(() => { this.setState({contents: value}); }); }); }; const label = this.state.label; const contents = this.state.contents; const isContentPending = label !== contents; return ( <> <Text text={ label + ' label' + (isContentPending ? ' (loading...)' : '') } /> <div> <Suspense fallback={<Text text="Loading..." />}> <AsyncText text={contents + ' content'} /> </Suspense> </div> </> ); } } // Initial render const root = ReactNoop.createRoot(); await act(() => { seedNextTextCache('A content'); root.render(<App />); }); assertLog(['A label', 'A content']); expect(root).toMatchRenderedOutput( <> A label<div>A content</div> </>, ); // Switch to B await act(() => { update('B'); }); assertLog([ // Commit pending state 'B label (loading...)', 'A content', // Attempt to render B, but it suspends 'B label', 'Suspend! [B content]', 'Loading...', ]); // This is a refresh transition so it shouldn't show a fallback expect(root).toMatchRenderedOutput( <> B label (loading...)<div>A content</div> </>, ); // Before B finishes loading, switch to C await act(() => { update('C'); }); assertLog([ // Commit pending state 'C label (loading...)', 'A content', // Attempt to render C, but it suspends 'C label', 'Suspend! [C content]', 'Loading...', ]); expect(root).toMatchRenderedOutput( <> C label (loading...)<div>A content</div> </>, ); // Finish loading B. But we're not allowed to render B because it's // entangled with C. So we're still pending. await act(() => { resolveText('B content'); }); assertLog([ // Attempt to render C, but it suspends 'C label', 'Suspend! [C content]', 'Loading...', ]); expect(root).toMatchRenderedOutput( <> C label (loading...)<div>A content</div> </>, ); // Now finish loading C. This is the terminal update, so it can finish. await act(() => { resolveText('C content'); }); assertLog(['C label', 'C content']); expect(root).toMatchRenderedOutput( <> C label<div>C content</div> </>, ); }, ); // @gate enableLegacyCache it( 'when multiple transitions update overlapping queues, all the transitions ' + 'across all the queues are entangled', async () => { let setShowA; let setShowB; let setShowC; function App() { const [showA, _setShowA] = useState(false); const [showB, _setShowB] = useState(false); const [showC, _setShowC] = useState(false); setShowA = _setShowA; setShowB = _setShowB; setShowC = _setShowC; // Only one of these children should be visible at a time. Except // instead of being modeled as a single state, it's three separate // states that are updated simultaneously. This may seem a bit // contrived, but it's more common than you might think. Usually via // a framework or indirection. For example, consider a tooltip manager // that only shows a single tooltip at a time. Or a router that // highlights links to the active route. return ( <> <Suspense fallback={<Text text="Loading..." />}> {showA ? <AsyncText text="A" /> : null} {showB ? <AsyncText text="B" /> : null} {showC ? <AsyncText text="C" /> : null} </Suspense> </> ); } // Initial render. Start with all children hidden. const root = ReactNoop.createRoot(); await act(() => { root.render(<App />); }); assertLog([]); expect(root).toMatchRenderedOutput(null); // Switch to A. await act(() => { startTransition(() => { setShowA(true); }); }); assertLog(['Suspend! [A]', 'Loading...']); expect(root).toMatchRenderedOutput(null); // Before A loads, switch to B. This should entangle A with B. await act(() => { startTransition(() => { setShowA(false); setShowB(true); }); }); assertLog(['Suspend! [B]', 'Loading...']); expect(root).toMatchRenderedOutput(null); // Before A or B loads, switch to C. This should entangle C with B, and // transitively entangle C with A. await act(() => { startTransition(() => { setShowB(false); setShowC(true); }); }); assertLog(['Suspend! [C]', 'Loading...']); expect(root).toMatchRenderedOutput(null); // Now the data starts resolving out of order. // First resolve B. This will attempt to render C, since everything is // entangled. await act(() => { startTransition(() => { resolveText('B'); }); }); assertLog(['Suspend! [C]', 'Loading...']); expect(root).toMatchRenderedOutput(null); // Now resolve A. Again, this will attempt to render C, since everything // is entangled. await act(() => { startTransition(() => { resolveText('A'); }); }); assertLog(['Suspend! [C]', 'Loading...']); expect(root).toMatchRenderedOutput(null); // Finally, resolve C. This time we can finish. await act(() => { startTransition(() => { resolveText('C'); }); }); assertLog(['C']); expect(root).toMatchRenderedOutput('C'); }, ); // @gate enableLegacyCache it('interrupt a refresh transition if a new transition is scheduled', async () => { const root = ReactNoop.createRoot(); await act(() => { root.render( <> <Suspense fallback={<Text text="Loading..." />} /> <Text text="Initial" /> </>, ); }); assertLog(['Initial']); expect(root).toMatchRenderedOutput('Initial'); await act(async () => { // Start a refresh transition startTransition(() => { root.render( <> <Suspense fallback={<Text text="Loading..." />}> <AsyncText text="Async" /> </Suspense> <Text text="After Suspense" /> <Text text="Sibling" /> </>, ); }); // Partially render it. await waitFor([ // Once we the update suspends, we know it's a refresh transition, // because the Suspense boundary has already mounted. 'Suspend! [Async]', 'Loading...', 'After Suspense', ]); // Schedule a new transition startTransition(async () => { root.render( <> <Suspense fallback={<Text text="Loading..." />} /> <Text text="Updated" /> </>, ); }); }); // Because the first one is going to suspend regardless, we should // immediately switch to rendering the new transition. assertLog(['Updated']); expect(root).toMatchRenderedOutput('Updated'); }); // @gate enableLegacyCache it( "interrupt a refresh transition when something suspends and we've " + 'already bailed out on another transition in a parent', async () => { let setShouldSuspend; function Parent({children}) { const [shouldHideInParent, _setShouldHideInParent] = useState(false); setShouldHideInParent = _setShouldHideInParent; Scheduler.log('shouldHideInParent: ' + shouldHideInParent); if (shouldHideInParent) { return <Text text="(empty)" />; } return children; } let setShouldHideInParent; function App() { const [shouldSuspend, _setShouldSuspend] = useState(false); setShouldSuspend = _setShouldSuspend; return ( <> <Text text="A" /> <Parent> <Suspense fallback={<Text text="Loading..." />}> {shouldSuspend ? <AsyncText text="Async" /> : null} </Suspense> </Parent> <Text text="B" /> <Text text="C" /> </> ); } const root = ReactNoop.createRoot(); await act(async () => { root.render(<App />); await waitForAll(['A', 'shouldHideInParent: false', 'B', 'C']); expect(root).toMatchRenderedOutput('ABC'); // Schedule an update startTransition(() => { setShouldSuspend(true); }); // Now we need to trigger schedule another transition in a different // lane from the first one. At the time this was written, all transitions are worked on // simultaneously, unless a transition was already in progress when a // new one was scheduled. So, partially render the first transition. await waitFor(['A']); // Now schedule a second transition. We won't interrupt the first one. React.startTransition(() => { setShouldHideInParent(true); }); // Continue rendering the first transition. await waitFor([ 'shouldHideInParent: false', 'Suspend! [Async]', 'Loading...', 'B', ]); // Should not have committed loading state expect(root).toMatchRenderedOutput('ABC'); // At this point, we've processed the parent update queue, so we know // that it has a pending update from the second transition, even though // we skipped it during this render. And we know this is a refresh // transition, because we had to render a loading state. So the next // time we re-enter the work loop (we don't interrupt immediately, we // just wait for the next time slice), we should throw out the // suspended first transition and try the second one. await waitForPaint(['shouldHideInParent: true', '(empty)']); expect(root).toMatchRenderedOutput('A(empty)BC'); // Since the two transitions are not entangled, we then later go back // and finish retry the first transition. Not really relevant to this // test but I'll assert the result anyway. await waitForAll([ 'A', 'shouldHideInParent: true', '(empty)', 'B', 'C', ]); expect(root).toMatchRenderedOutput('A(empty)BC'); }); }, ); // @gate enableLegacyCache it( 'interrupt a refresh transition when something suspends and a parent ' + 'component received an interleaved update after its queue was processed', async () => { // Title is confusing so I'll try to explain further: This is similar to // the previous test, except instead of skipped over a transition update // in a parent, the parent receives an interleaved update *after* its // begin phase has already finished. function App({shouldSuspend, step}) { return ( <> <Text text={`A${step}`} /> <Suspense fallback={<Text text="Loading..." />}> {shouldSuspend ? <AsyncText text="Async" /> : null} </Suspense> <Text text={`B${step}`} /> <Text text={`C${step}`} /> </> ); } const root = ReactNoop.createRoot(); await act(() => { root.render(<App shouldSuspend={false} step={0} />); }); assertLog(['A0', 'B0', 'C0']); expect(root).toMatchRenderedOutput('A0B0C0'); await act(async () => { // This update will suspend. startTransition(() => { root.render(<App shouldSuspend={true} step={1} />); }); // Flush past the root, but stop before the async component. await waitFor(['A1']); // Schedule another transition on the root, which already completed. startTransition(() => { root.render(<App shouldSuspend={false} step={2} />); }); // We'll keep working on the first update. await waitFor([ // Now the async component suspends 'Suspend! [Async]', 'Loading...', 'B1', ]); // Should not have committed loading state expect(root).toMatchRenderedOutput('A0B0C0'); // After suspending, should abort the first update and switch to the // second update. So, C1 should not appear in the log. // TODO: This should work even if React does not yield to the main // thread. Should use same mechanism as selective hydration to interrupt // the render before the end of the current slice of work. await waitForAll(['A2', 'B2', 'C2']); expect(root).toMatchRenderedOutput('A2B2C2'); }); }, ); it('should render normal pri updates scheduled after transitions before transitions', async () => { let updateTransitionPri; let updateNormalPri; function App() { const [normalPri, setNormalPri] = useState(0); const [transitionPri, setTransitionPri] = useState(0); updateTransitionPri = () => startTransition(() => setTransitionPri(n => n + 1)); updateNormalPri = () => setNormalPri(n => n + 1); useLayoutEffect(() => { Scheduler.log('Commit'); }); return ( <Suspense fallback={<Text text="Loading..." />}> <Text text={'Transition pri: ' + transitionPri} /> {', '} <Text text={'Normal pri: ' + normalPri} /> </Suspense> ); } const root = ReactNoop.createRoot(); await act(() => { root.render(<App />); }); // Initial render. assertLog(['Transition pri: 0', 'Normal pri: 0', 'Commit']); expect(root).toMatchRenderedOutput('Transition pri: 0, Normal pri: 0'); await act(() => { updateTransitionPri(); updateNormalPri(); }); assertLog([ // Normal update first. 'Transition pri: 0', 'Normal pri: 1', 'Commit', // Then transition update. 'Transition pri: 1', 'Normal pri: 1', 'Commit', ]); expect(root).toMatchRenderedOutput('Transition pri: 1, Normal pri: 1'); }); // @gate enableLegacyCache it('should render normal pri updates before transition suspense retries', async () => { let updateTransitionPri; let updateNormalPri; function App() { const [transitionPri, setTransitionPri] = useState(false); const [normalPri, setNormalPri] = useState(0); updateTransitionPri = () => startTransition(() => setTransitionPri(true)); updateNormalPri = () => setNormalPri(n => n + 1); useLayoutEffect(() => { Scheduler.log('Commit'); }); return ( <Suspense fallback={<Text text="Loading..." />}> {transitionPri ? <AsyncText text="Async" /> : <Text text="(empty)" />} {', '} <Text text={'Normal pri: ' + normalPri} /> </Suspense> ); } const root = ReactNoop.createRoot(); await act(() => { root.render(<App />); }); // Initial render. assertLog(['(empty)', 'Normal pri: 0', 'Commit']); expect(root).toMatchRenderedOutput('(empty), Normal pri: 0'); await act(() => { updateTransitionPri(); }); assertLog([ // Suspend. 'Suspend! [Async]', // pre-warming 'Normal pri: 0', // end pre-warming 'Loading...', ]); expect(root).toMatchRenderedOutput('(empty), Normal pri: 0'); await act(async () => { await resolveText('Async'); updateNormalPri(); }); assertLog([ // Normal pri update. '(empty)', 'Normal pri: 1', 'Commit', // Promise resolved, retry flushed. 'Async', 'Normal pri: 1', 'Commit', ]); expect(root).toMatchRenderedOutput('Async, Normal pri: 1'); }); it('should not interrupt transitions with normal pri updates', async () => { let updateNormalPri; let updateTransitionPri; function App() { const [transitionPri, setTransitionPri] = useState(0); const [normalPri, setNormalPri] = useState(0); updateTransitionPri = () => startTransition(() => setTransitionPri(n => n + 1)); updateNormalPri = () => setNormalPri(n => n + 1); useLayoutEffect(() => { Scheduler.log('Commit'); }); return ( <> <Text text={'Transition pri: ' + transitionPri} /> {', '} <Text text={'Normal pri: ' + normalPri} /> </> ); } const root = ReactNoop.createRoot(); await act(() => { root.render(<App />); }); assertLog(['Transition pri: 0', 'Normal pri: 0', 'Commit']); expect(root).toMatchRenderedOutput('Transition pri: 0, Normal pri: 0'); await act(async () => { updateTransitionPri(); await waitFor([ // Start transition update. 'Transition pri: 1', ]); // Schedule normal pri update during transition update. // This should not interrupt. updateNormalPri(); }); assertLog([ 'Normal pri: 0', 'Commit', // Normal pri update. 'Transition pri: 1', 'Normal pri: 1', 'Commit', ]); expect(root).toMatchRenderedOutput('Transition pri: 1, Normal pri: 1'); }); it('tracks two pending flags for nested startTransition (#26226)', async () => { let update; function App() { const [isPendingA, startTransitionA] = useTransition(); const [isPendingB, startTransitionB] = useTransition(); const [state, setState] = useState(0); update = function () { startTransitionA(() => { startTransitionB(() => { setState(1); }); }); }; return ( <> <Text text={state} /> {', '} <Text text={'A ' + isPendingA} /> {', '} <Text text={'B ' + isPendingB} /> </> ); } const root = ReactNoop.createRoot(); await act(async () => { root.render(<App />); }); assertLog([0, 'A false', 'B false']); expect(root).toMatchRenderedOutput('0, A false, B false'); await act(async () => { update(); }); assertLog([0, 'A true', 'B true', 1, 'A false', 'B false']); expect(root).toMatchRenderedOutput('1, A false, B false'); }); });