let React;
let ReactNoop;
let Scheduler;
let act;
let assertLog;
let useTransition;
let useState;
let useOptimistic;
let textCache;
describe('ReactAsyncActions', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
act = require('internal-test-utils').act;
assertLog = require('internal-test-utils').assertLog;
useTransition = React.useTransition;
useState = React.useState;
useOptimistic = React.useOptimistic;
textCache = new Map();
});
function resolveText(text) {
const record = textCache.get(text);
if (record === undefined) {
const newRecord = {
status: 'resolved',
value: text,
};
textCache.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'resolved';
record.value = text;
thenable.pings.forEach(t => t());
}
}
function readText(text) {
const record = textCache.get(text);
if (record !== undefined) {
switch (record.status) {
case 'pending':
Scheduler.log(`Suspend! [${text}]`);
throw record.value;
case 'rejected':
throw record.value;
case 'resolved':
return record.value;
}
} 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.set(text, newRecord);
throw thenable;
}
}
function getText(text) {
const record = textCache.get(text);
if (record === undefined) {
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.set(text, newRecord);
return thenable;
} else {
switch (record.status) {
case 'pending':
return record.value;
case 'rejected':
return Promise.reject(record.value);
case 'resolved':
return Promise.resolve(record.value);
}
}
}
function Text({text}) {
Scheduler.log(text);
return text;
}
function AsyncText({text}) {
readText(text);
Scheduler.log(text);
return text;
}
// @gate enableAsyncActions
test('isPending remains true until async action finishes', async () => {
let startTransition;
function App() {
const [isPending, _start] = useTransition();
startTransition = _start;
return <Text text={'Pending: ' + isPending} />;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App />);
});
assertLog(['Pending: false']);
expect(root).toMatchRenderedOutput('Pending: false');
// At the start of an async action, isPending is set to true.
await act(() => {
startTransition(async () => {
Scheduler.log('Async action started');
await getText('Wait');
Scheduler.log('Async action ended');
});
});
assertLog(['Async action started', 'Pending: true']);
expect(root).toMatchRenderedOutput('Pending: true');
// Once the action finishes, isPending is set back to false.
await act(() => resolveText('Wait'));
assertLog(['Async action ended', 'Pending: false']);
expect(root).toMatchRenderedOutput('Pending: false');
});
// @gate enableAsyncActions
test('multiple updates in an async action scope are entangled together', async () => {
let startTransition;
function App({text}) {
const [isPending, _start] = useTransition();
startTransition = _start;
return (
<>
<span>
<Text text={'Pending: ' + isPending} />
</span>
<span>
<Text text={text} />
</span>
</>
);
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App text="A" />);
});
assertLog(['Pending: false', 'A']);
expect(root).toMatchRenderedOutput(
<>
<span>Pending: false</span>
<span>A</span>
</>,
);
await act(() => {
startTransition(async () => {
Scheduler.log('Async action started');
await getText('Yield before updating');
Scheduler.log('Async action ended');
startTransition(() => root.render(<App text="B" />));
});
});
assertLog(['Async action started', 'Pending: true', 'A']);
expect(root).toMatchRenderedOutput(
<>
<span>Pending: true</span>
<span>A</span>
</>,
);
await act(() => resolveText('Yield before updating'));
assertLog(['Async action ended', 'Pending: false', 'B']);
expect(root).toMatchRenderedOutput(
<>
<span>Pending: false</span>
<span>B</span>
</>,
);
});
// @gate enableAsyncActions
test('multiple async action updates in the same scope are entangled together', async () => {
let setStepA;
function A() {
const [step, setStep] = useState(0);
setStepA = setStep;
return <AsyncText text={'A' + step} />;
}
let setStepB;
function B() {
const [step, setStep] = useState(0);
setStepB = setStep;
return <AsyncText text={'B' + step} />;
}
let setStepC;
function C() {
const [step, setStep] = useState(0);
setStepC = setStep;
return <AsyncText text={'C' + step} />;
}
let startTransition;
function App() {
const [isPending, _start] = useTransition();
startTransition = _start;
return (
<>
<span>
<Text text={'Pending: ' + isPending} />
</span>
<span>
<A />, <B />, <C />
</span>
</>
);
}
const root = ReactNoop.createRoot();
resolveText('A0');
resolveText('B0');
resolveText('C0');
await act(() => {
root.render(<App text="A" />);
});
assertLog(['Pending: false', 'A0', 'B0', 'C0']);
expect(root).toMatchRenderedOutput(
<>
<span>Pending: false</span>
<span>A0, B0, C0</span>
</>,
);
await act(() => {
startTransition(async () => {
Scheduler.log('Async action started');
setStepA(1);
await getText('Wait before updating B');
startTransition(() => setStepB(1));
await getText('Wait before updating C');
startTransition(() => setStepC(1));
Scheduler.log('Async action ended');
});
});
assertLog(['Async action started', 'Pending: true', 'A0', 'B0', 'C0']);
expect(root).toMatchRenderedOutput(
<>
<span>Pending: true</span>
<span>A0, B0, C0</span>
</>,
);
// This will schedule an update on B, but nothing will render yet because
// the async action scope hasn't finished.
await act(() => resolveText('Wait before updating B'));
assertLog([]);
expect(root).toMatchRenderedOutput(
<>
<span>Pending: true</span>
<span>A0, B0, C0</span>
</>,
);
// This will schedule an update on C, and also the async action scope
// will end. This will allow React to attempt to render the updates.
await act(() => resolveText('Wait before updating C'));
assertLog(['Async action ended', 'Pending: false', 'Suspend! [A1]']);
expect(root).toMatchRenderedOutput(
<>
<span>Pending: true</span>
<span>A0, B0, C0</span>
</>,
);
// Progressively load the all the data. Because they are all entangled
// together, only when the all of A, B, and C updates are unblocked is the
// render allowed to proceed.
await act(() => resolveText('A1'));
assertLog(['Pending: false', 'A1', 'Suspend! [B1]']);
expect(root).toMatchRenderedOutput(
<>
<span>Pending: true</span>
<span>A0, B0, C0</span>
</>,
);
await act(() => resolveText('B1'));
assertLog(['Pending: false', 'A1', 'B1', 'Suspend! [C1]']);
expect(root).toMatchRenderedOutput(
<>
<span>Pending: true</span>
<span>A0, B0, C0</span>
</>,
);
// Finally, all the data has loaded and the transition is complete.
await act(() => resolveText('C1'));
assertLog(['Pending: false', 'A1', 'B1', 'C1']);
expect(root).toMatchRenderedOutput(
<>
<span>Pending: false</span>
<span>A1, B1, C1</span>
</>,
);
});
// @gate enableAsyncActions
test('urgent updates are not blocked during an async action', async () => {
let setStepA;
function A() {
const [step, setStep] = useState(0);
setStepA = setStep;
return <Text text={'A' + step} />;
}
let setStepB;
function B() {
const [step, setStep] = useState(0);
setStepB = setStep;
return <Text text={'B' + step} />;
}
let startTransition;
function App() {
const [isPending, _start] = useTransition();
startTransition = _start;
return (
<>
<span>
<Text text={'Pending: ' + isPending} />
</span>
<span>
<A />, <B />
</span>
</>
);
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App text="A" />);
});
assertLog(['Pending: false', 'A0', 'B0']);
expect(root).toMatchRenderedOutput(
<>
<span>Pending: false</span>
<span>A0, B0</span>
</>,
);
await act(() => {
startTransition(async () => {
Scheduler.log('Async action started');
startTransition(() => setStepA(1));
await getText('Wait');
Scheduler.log('Async action ended');
});
});
assertLog(['Async action started', 'Pending: true', 'A0', 'B0']);
expect(root).toMatchRenderedOutput(
<>
<span>Pending: true</span>
<span>A0, B0</span>
</>,
);
// Update B at urgent priority. This should be allowed to finish.
await act(() => setStepB(1));
assertLog(['B1']);
expect(root).toMatchRenderedOutput(
<>
<span>Pending: true</span>
<span>A0, B1</span>
</>,
);
// Finish the async action.
await act(() => resolveText('Wait'));
assertLog(['Async action ended', 'Pending: false', 'A1', 'B1']);
expect(root).toMatchRenderedOutput(
<>
<span>Pending: false</span>
<span>A1, B1</span>
</>,
);
});
// @gate enableAsyncActions
test("if a sync action throws, it's rethrown from the `useTransition`", async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error) {
return <Text text={this.state.error.message} />;
}
return this.props.children;
}
}
let startTransition;
function App() {
const [isPending, _start] = useTransition();
startTransition = _start;
return <Text text={'Pending: ' + isPending} />;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(
<ErrorBoundary>
<App />
</ErrorBoundary>,
);
});
assertLog(['Pending: false']);
expect(root).toMatchRenderedOutput('Pending: false');
await act(() => {
startTransition(() => {
throw new Error('Oops!');
});
});
assertLog(['Pending: true', 'Oops!', 'Oops!']);
expect(root).toMatchRenderedOutput('Oops!');
});
// @gate enableAsyncActions
test("if an async action throws, it's rethrown from the `useTransition`", async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error) {
return <Text text={this.state.error.message} />;
}
return this.props.children;
}
}
let startTransition;
function App() {
const [isPending, _start] = useTransition();
startTransition = _start;
return <Text text={'Pending: ' + isPending} />;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(
<ErrorBoundary>
<App />
</ErrorBoundary>,
);
});
assertLog(['Pending: false']);
expect(root).toMatchRenderedOutput('Pending: false');
await act(() => {
startTransition(async () => {
Scheduler.log('Async action started');
await getText('Wait');
throw new Error('Oops!');
});
});
assertLog(['Async action started', 'Pending: true']);
expect(root).toMatchRenderedOutput('Pending: true');
await act(() => resolveText('Wait'));
assertLog(['Oops!', 'Oops!']);
expect(root).toMatchRenderedOutput('Oops!');
});
// @gate !enableAsyncActions
test('when enableAsyncActions is disabled, and a sync action throws, `isPending` is turned off', async () => {
let startTransition;
function App() {
const [isPending, _start] = useTransition();
startTransition = _start;
return <Text text={'Pending: ' + isPending} />;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App />);
});
assertLog(['Pending: false']);
expect(root).toMatchRenderedOutput('Pending: false');
await act(() => {
expect(() => {
startTransition(() => {
throw new Error('Oops!');
});
}).toThrow('Oops!');
});
assertLog(['Pending: true', 'Pending: false']);
expect(root).toMatchRenderedOutput('Pending: false');
});
// @gate enableAsyncActions
test('if there are multiple entangled actions, and one of them errors, it only affects that action', async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error) {
return <Text text={this.state.error.message} />;
}
return this.props.children;
}
}
let startTransitionA;
function ActionA() {
const [isPendingA, start] = useTransition();
startTransitionA = start;
return <Text text={'Pending A: ' + isPendingA} />;
}
let startTransitionB;
function ActionB() {
const [isPending, start] = useTransition();
startTransitionB = start;
return <Text text={'Pending B: ' + isPending} />;
}
let startTransitionC;
function ActionC() {
const [isPending, start] = useTransition();
startTransitionC = start;
return <Text text={'Pending C: ' + isPending} />;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(
<>
<div>
<ErrorBoundary>
<ActionA />
</ErrorBoundary>
</div>
<div>
<ErrorBoundary>
<ActionB />
</ErrorBoundary>
</div>
<div>
<ErrorBoundary>
<ActionC />
</ErrorBoundary>
</div>
</>,
);
});
assertLog(['Pending A: false', 'Pending B: false', 'Pending C: false']);
expect(root).toMatchRenderedOutput(
<>
<div>Pending A: false</div>
<div>Pending B: false</div>
<div>Pending C: false</div>
</>,
);
// Start a bunch of entangled transitions. A and C throw errors, but B
// doesn't. A and should surface their respective errors, but B should
// finish successfully.
await act(() => {
startTransitionC(async () => {
startTransitionB(async () => {
startTransitionA(async () => {
await getText('Wait for A');
throw new Error('Oops A!');
});
await getText('Wait for B');
});
await getText('Wait for C');
throw new Error('Oops C!');
});
});
assertLog(['Pending A: true', 'Pending B: true', 'Pending C: true']);
// Finish action A. We can't commit the result yet because it's entangled
// with B and C.
await act(() => resolveText('Wait for A'));
assertLog([]);
// Finish action B. Same as above.
await act(() => resolveText('Wait for B'));
assertLog([]);
// Now finish action C. This is the last action in the entangled set, so
// rendering can proceed.
await act(() => resolveText('Wait for C'));
assertLog([
// A and C result in (separate) errors, but B does not.
'Oops A!',
'Pending B: false',
'Oops C!',
// Because there was an error, React will try rendering one more time.
'Oops A!',
'Pending B: false',
'Oops C!',
]);
expect(root).toMatchRenderedOutput(
<>
<div>Oops A!</div>
<div>Pending B: false</div>
<div>Oops C!</div>
</>,
);
});
// @gate enableAsyncActions
test('useOptimistic can be used to implement a pending state', async () => {
const startTransition = React.startTransition;
let setIsPending;
function App({text}) {
const [isPending, _setIsPending] = useOptimistic(false);
setIsPending = _setIsPending;
return (
<>
<Text text={'Pending: ' + isPending} />
<AsyncText text={text} />
</>
);
}
// Initial render
const root = ReactNoop.createRoot();
resolveText('A');
await act(() => root.render(<App text="A" />));
assertLog(['Pending: false', 'A']);
expect(root).toMatchRenderedOutput('Pending: falseA');
// Start a transition
await act(() =>
startTransition(() => {
setIsPending(true);
root.render(<App text="B" />);
}),
);
assertLog([
// Render the pending state immediately
'Pending: true',
'A',
// Then attempt to render the transition. The pending state will be
// automatically reverted.
'Pending: false',
'Suspend! [B]',
]);
// Resolve the transition
await act(() => resolveText('B'));
assertLog([
// Render the pending state immediately
'Pending: false',
'B',
]);
});
// @gate enableAsyncActions
test('useOptimistic rebases pending updates on top of passthrough value', async () => {
let serverCart = ['A'];
async function submitNewItem(item) {
await getText('Adding item ' + item);
serverCart = [...serverCart, item];
React.startTransition(() => {
root.render(<App cart={serverCart} />);
});
}
let addItemToCart;
function App({cart}) {
const [isPending, startTransition] = useTransition();
const savedCartSize = cart.length;
const [optimisticCartSize, setOptimisticCartSize] =
useOptimistic(savedCartSize);
addItemToCart = item => {
startTransition(async () => {
setOptimisticCartSize(n => n + 1);
await submitNewItem(item);
});
};
return (
<>
<div>
<Text text={'Pending: ' + isPending} />
</div>
<div>
<Text text={'Items in cart: ' + optimisticCartSize} />
</div>
<ul>
{cart.map(item => (
<li key={item}>
<Text text={'Item ' + item} />
</li>
))}
</ul>
</>
);
}
// Initial render
const root = ReactNoop.createRoot();
await act(() => root.render(<App cart={serverCart} />));
assertLog(['Pending: false', 'Items in cart: 1', 'Item A']);
expect(root).toMatchRenderedOutput(
<>
<div>Pending: false</div>
<div>Items in cart: 1</div>
<ul>
<li>Item A</li>
</ul>
</>,
);
// The cart size is incremented even though B hasn't been added yet.
await act(() => addItemToCart('B'));
assertLog(['Pending: true', 'Items in cart: 2', 'Item A']);
expect(root).toMatchRenderedOutput(
<>
<div>Pending: true</div>
<div>Items in cart: 2</div>
<ul>
<li>Item A</li>
</ul>
</>,
);
// While B is still pending, another item gets added to the cart
// out-of-band.
serverCart = [...serverCart, 'C'];
// NOTE: This is a synchronous update only because we don't yet support
// parallel transitions; all transitions are entangled together. Once we add
// support for parallel transitions, we can update this test.
ReactNoop.flushSync(() => root.render(<App cart={serverCart} />));
assertLog([
'Pending: true',
// Note that the optimistic cart size is still correct, because the
// pending update was rebased on top new value.
'Items in cart: 3',
'Item A',
'Item C',
]);
expect(root).toMatchRenderedOutput(
<>
<div>Pending: true</div>
<div>Items in cart: 3</div>
<ul>
<li>Item A</li>
<li>Item C</li>
</ul>
</>,
);
// Finish loading B. The optimistic state is reverted.
await act(() => resolveText('Adding item B'));
assertLog([
'Pending: false',
'Items in cart: 3',
'Item A',
'Item C',
'Item B',
]);
expect(root).toMatchRenderedOutput(
<>
<div>Pending: false</div>
<div>Items in cart: 3</div>
<ul>
<li>Item A</li>
<li>Item C</li>
<li>Item B</li>
</ul>
</>,
);
});
// @gate enableAsyncActions
test('regression: useOptimistic during setState-in-render', async () => {
// This is a regression test for a very specific case where useOptimistic is
// the first hook in the component, it has a pending update, and a later
// hook schedules a local (setState-in-render) update. Don't sweat about
// deleting this test if the implementation details change.
let setOptimisticState;
let startTransition;
function App() {
const [optimisticState, _setOptimisticState] = useOptimistic(0);
setOptimisticState = _setOptimisticState;
const [, _startTransition] = useTransition();
startTransition = _startTransition;
const [derivedState, setDerivedState] = useState(0);
if (derivedState !== optimisticState) {
setDerivedState(optimisticState);
}
return <Text text={optimisticState} />;
}
const root = ReactNoop.createRoot();
await act(() => root.render(<App />));
assertLog([0]);
expect(root).toMatchRenderedOutput('0');
await act(() => {
startTransition(async () => {
setOptimisticState(1);
await getText('Wait');
});
});
assertLog([1]);
expect(root).toMatchRenderedOutput('1');
});
// @gate enableAsyncActions
test('useOptimistic accepts a custom reducer', async () => {
let serverCart = ['A'];
async function submitNewItem(item) {
await getText('Adding item ' + item);
serverCart = [...serverCart, item];
React.startTransition(() => {
root.render(<App cart={serverCart} />);
});
}
let addItemToCart;
function App({cart}) {
const [isPending, startTransition] = useTransition();
const savedCartSize = cart.length;
const [optimisticCartSize, addToOptimisticCart] = useOptimistic(
savedCartSize,
(prevSize, newItem) => {
Scheduler.log('Increment optimistic cart size for ' + newItem);
return prevSize + 1;
},
);
addItemToCart = item => {
startTransition(async () => {
addToOptimisticCart(item);
await submitNewItem(item);
});
};
return (
<>
<div>
<Text text={'Pending: ' + isPending} />
</div>
<div>
<Text text={'Items in cart: ' + optimisticCartSize} />
</div>
<ul>
{cart.map(item => (
<li key={item}>
<Text text={'Item ' + item} />
</li>
))}
</ul>
</>
);
}
// Initial render
const root = ReactNoop.createRoot();
await act(() => root.render(<App cart={serverCart} />));
assertLog(['Pending: false', 'Items in cart: 1', 'Item A']);
expect(root).toMatchRenderedOutput(
<>
<div>Pending: false</div>
<div>Items in cart: 1</div>
<ul>
<li>Item A</li>
</ul>
</>,
);
// The cart size is incremented even though B hasn't been added yet.
await act(() => addItemToCart('B'));
assertLog([
'Increment optimistic cart size for B',
'Pending: true',
'Items in cart: 2',
'Item A',
]);
expect(root).toMatchRenderedOutput(
<>
<div>Pending: true</div>
<div>Items in cart: 2</div>
<ul>
<li>Item A</li>
</ul>
</>,
);
// While B is still pending, another item gets added to the cart
// out-of-band.
serverCart = [...serverCart, 'C'];
// NOTE: This is a synchronous update only because we don't yet support
// parallel transitions; all transitions are entangled together. Once we add
// support for parallel transitions, we can update this test.
ReactNoop.flushSync(() => root.render(<App cart={serverCart} />));
assertLog([
'Increment optimistic cart size for B',
'Pending: true',
// Note that the optimistic cart size is still correct, because the
// pending update was rebased on top new value.
'Items in cart: 3',
'Item A',
'Item C',
]);
expect(root).toMatchRenderedOutput(
<>
<div>Pending: true</div>
<div>Items in cart: 3</div>
<ul>
<li>Item A</li>
<li>Item C</li>
</ul>
</>,
);
// Finish loading B. The optimistic state is reverted.
await act(() => resolveText('Adding item B'));
assertLog([
'Pending: false',
'Items in cart: 3',
'Item A',
'Item C',
'Item B',
]);
expect(root).toMatchRenderedOutput(
<>
<div>Pending: false</div>
<div>Items in cart: 3</div>
<ul>
<li>Item A</li>
<li>Item C</li>
<li>Item B</li>
</ul>
</>,
);
});
// @gate enableAsyncActions
test('useOptimistic rebases if the passthrough is updated during a render phase update', async () => {
// This is kind of an esoteric case where it's hard to come up with a
// realistic real-world scenario but it should still work.
let increment;
let setCount;
function App() {
const [isPending, startTransition] = useTransition(2);
const [count, _setCount] = useState(0);
setCount = _setCount;
const [optimisticCount, setOptimisticCount] = useOptimistic(
count,
prev => {
Scheduler.log('Increment optimistic count');
return prev + 1;
},
);
if (count === 1) {
Scheduler.log('Render phase update count from 1 to 2');
setCount(2);
}
increment = () =>
startTransition(async () => {
setOptimisticCount(n => n + 1);
await getText('Wait to increment');
React.startTransition(() => setCount(n => n + 1));
});
return (
<>
<div>
<Text text={'Count: ' + count} />
</div>
{isPending ? (
<div>
<Text text={'Optimistic count: ' + optimisticCount} />
</div>
) : null}
</>
);
}
const root = ReactNoop.createRoot();
await act(() => root.render(<App />));
assertLog(['Count: 0']);
expect(root).toMatchRenderedOutput(<div>Count: 0</div>);
await act(() => increment());
assertLog([
'Increment optimistic count',
'Count: 0',
'Optimistic count: 1',
]);
expect(root).toMatchRenderedOutput(
<>
<div>Count: 0</div>
<div>Optimistic count: 1</div>
</>,
);
await act(() => setCount(1));
assertLog([
'Increment optimistic count',
'Render phase update count from 1 to 2',
// The optimistic update is rebased on top of the new passthrough value.
'Increment optimistic count',
'Count: 2',
'Optimistic count: 3',
]);
expect(root).toMatchRenderedOutput(
<>
<div>Count: 2</div>
<div>Optimistic count: 3</div>
</>,
);
// Finish the action
await act(() => resolveText('Wait to increment'));
assertLog(['Count: 3']);
expect(root).toMatchRenderedOutput(<div>Count: 3</div>);
});
// @gate enableAsyncActions
test('useOptimistic rebases if the passthrough is updated during a render phase update (initial mount)', async () => {
// This is kind of an esoteric case where it's hard to come up with a
// realistic real-world scenario but it should still work.
function App() {
const [count, setCount] = useState(0);
const [optimisticCount] = useOptimistic(count);
if (count === 0) {
Scheduler.log('Render phase update count from 1 to 2');
setCount(1);
}
return (
<>
<div>
<Text text={'Count: ' + count} />
</div>
<div>
<Text text={'Optimistic count: ' + optimisticCount} />
</div>
</>
);
}
const root = ReactNoop.createRoot();
await act(() => root.render(<App />));
assertLog([
'Render phase update count from 1 to 2',
'Count: 1',
'Optimistic count: 1',
]);
expect(root).toMatchRenderedOutput(
<>
<div>Count: 1</div>
<div>Optimistic count: 1</div>
</>,
);
});
// @gate enableAsyncActions
test('useOptimistic can update repeatedly in the same async action', async () => {
let startTransition;
let setLoadingProgress;
let setText;
function App() {
const [, _startTransition] = useTransition();
const [text, _setText] = useState('A');
const [loadingProgress, _setLoadingProgress] = useOptimistic(0);
startTransition = _startTransition;
setText = _setText;
setLoadingProgress = _setLoadingProgress;
return (
<>
{loadingProgress !== 0 ? (
<div key="progress">
<Text text={`Loading... (${loadingProgress})`} />
</div>
) : null}
<div key="real">
<Text text={text} />
</div>
</>
);
}
// Initial render
const root = ReactNoop.createRoot();
await act(() => root.render(<App />));
assertLog(['A']);
expect(root).toMatchRenderedOutput(<div>A</div>);
await act(async () => {
startTransition(async () => {
setLoadingProgress('25%');
await getText('Wait 1');
setLoadingProgress('75%');
await getText('Wait 2');
startTransition(() => setText('B'));
});
});
assertLog(['Loading... (25%)', 'A']);
expect(root).toMatchRenderedOutput(
<>
<div>Loading... (25%)</div>
<div>A</div>
</>,
);
await act(() => resolveText('Wait 1'));
assertLog(['Loading... (75%)', 'A']);
expect(root).toMatchRenderedOutput(
<>
<div>Loading... (75%)</div>
<div>A</div>
</>,
);
await act(() => resolveText('Wait 2'));
assertLog(['B']);
expect(root).toMatchRenderedOutput(<div>B</div>);
});
// @gate enableAsyncActions
test('useOptimistic warns if outside of a transition', async () => {
let startTransition;
let setLoadingProgress;
let setText;
function App() {
const [, _startTransition] = useTransition();
const [text, _setText] = useState('A');
const [loadingProgress, _setLoadingProgress] = useOptimistic(0);
startTransition = _startTransition;
setText = _setText;
setLoadingProgress = _setLoadingProgress;
return (
<>
{loadingProgress !== 0 ? (
<div key="progress">
<Text text={`Loading... (${loadingProgress})`} />
</div>
) : null}
<div key="real">
<Text text={text} />
</div>
</>
);
}
// Initial render
const root = ReactNoop.createRoot();
await act(() => root.render(<App />));
assertLog(['A']);
expect(root).toMatchRenderedOutput(<div>A</div>);
await expect(async () => {
await act(() => {
setLoadingProgress('25%');
startTransition(() => setText('B'));
});
}).toErrorDev(
'An optimistic state update occurred outside a transition or ' +
'action. To fix, move the update to an action, or wrap ' +
'with startTransition.',
{withoutStack: true},
);
assertLog(['Loading... (25%)', 'A', 'B']);
expect(root).toMatchRenderedOutput(<div>B</div>);
});
});