mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
983 lines
27 KiB
JavaScript
983 lines
27 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
});
|