Skip to content

Commit efd498b

Browse files
authored
test(TimeTravel): async atom snapshot history (#170)
* test(TimeTravel): async atom snapshot history * chore: bump Jotai version * fix: inspect promise values more reliably
1 parent 5c19880 commit efd498b

File tree

6 files changed

+240
-43
lines changed

6 files changed

+240
-43
lines changed

__tests__/devtools/TimeTravel.test.tsx

+71-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import React, { useMemo } from 'react';
22
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
33
import { userEvent } from '@testing-library/user-event';
4-
import { atom, useAtomValue, useSetAtom } from 'jotai';
5-
import { DevTools, DevToolsProps } from 'jotai-devtools';
4+
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
5+
import { DevTools, type DevToolsProps } from 'jotai-devtools';
66
import { customRender } from '../custom-render';
77

88
const BasicAtomsWithDevTools = (props: DevToolsProps) => {
@@ -456,6 +456,75 @@ describe('DevTools - TimeTravel', () => {
456456
screen.getAllByTestId(/jotai-devtools-snapshot-[0-9]/),
457457
).toHaveLength(3);
458458
});
459+
460+
it('should not add another snapshot history entry when restoring an async atom', async () => {
461+
const countAtom = atom(0);
462+
countAtom.debugLabel = 'countAtom';
463+
const asyncAtom = atom<Promise<number> | null>(null);
464+
asyncAtom.debugLabel = 'asyncAtom';
465+
466+
let resolvePromise: (value: number) => void = () => {};
467+
const pendingPromise = new Promise<number>((resolve) => {
468+
resolvePromise = resolve;
469+
});
470+
471+
const AsyncAtomsWithDevTools = () => {
472+
const [request, setRequest] = useAtom(asyncAtom);
473+
const setCount = useSetAtom(countAtom);
474+
475+
const handleFetchClick = async () => {
476+
setRequest(pendingPromise);
477+
};
478+
479+
return (
480+
<div>
481+
<DevTools isInitialOpen={true} />
482+
<span data-testid="request-value">
483+
{request ? 'Resolved' : 'Pending'}
484+
</span>
485+
<button onClick={() => setCount((c) => c + 1)} type="button">
486+
Increment
487+
</button>
488+
<button onClick={handleFetchClick} type="button">
489+
Fetch
490+
</button>
491+
</div>
492+
);
493+
};
494+
495+
customRender(
496+
<React.Suspense fallback={<div>Loading...</div>}>
497+
<AsyncAtomsWithDevTools />
498+
</React.Suspense>,
499+
);
500+
501+
fireEvent.click(screen.getByText('Time travel'));
502+
fireEvent.click(screen.getByLabelText('Record snapshot history'));
503+
fireEvent.click(screen.getByText('Increment'));
504+
505+
expect(
506+
screen.getAllByTestId(/jotai-devtools-snapshot-[0-9]/),
507+
).toHaveLength(1);
508+
509+
await userEvent.click(screen.getByText('Fetch'));
510+
511+
resolvePromise(1);
512+
513+
await waitFor(() =>
514+
expect(
515+
screen.getAllByTestId(/jotai-devtools-snapshot-[0-9]/),
516+
).toHaveLength(3),
517+
);
518+
519+
expect(
520+
screen.getAllByTestId('json-tree-view-container'),
521+
).toMatchSnapshot();
522+
523+
await userEvent.click(screen.getByTestId('jotai-devtools-snapshot-2'));
524+
expect(
525+
screen.getAllByTestId('json-tree-view-container'),
526+
).toMatchSnapshot();
527+
});
459528
});
460529

461530
describe('Time travel', () => {

__tests__/devtools/__snapshots__/TimeTravel.test.tsx.snap

+104
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,110 @@ exports[`DevTools - TimeTravel Snapshot details should display the full state in
574574
</div>
575575
`;
576576

577+
exports[`DevTools - TimeTravel Snapshot details should not add another snapshot history entry when restoring an async atom 1`] = `
578+
[
579+
<div
580+
class="internal-jotai-devtools-json-tree-wrapper"
581+
data-testid="json-tree-view-container"
582+
>
583+
<ul
584+
style="border: 0px; padding: 0.625rem; margin: 0px; list-style: none; background-color: rgba(248, 249, 250, 0.65);"
585+
>
586+
<li
587+
style="padding: 0px; margin: 0px;"
588+
>
589+
<ul
590+
style="padding: 0px; margin: 0px; list-style: none;"
591+
>
592+
<li
593+
style="position: relative; padding-top: 0.25em; margin-left: 0px; padding-left: 0px;"
594+
>
595+
<div
596+
style="display: inline-block; padding-right: 0.5em; padding-left: 0px; cursor: pointer;"
597+
>
598+
<div
599+
style="color: rgb(52, 58, 64); margin-left: 0px; transition: 150ms; transform: rotateZ(0deg); transform-origin: 45% 50%; position: relative; line-height: 1.1em; font-size: 0.75em; padding-left: 0.2em;"
600+
>
601+
602+
</div>
603+
</div>
604+
<label
605+
style="display: inline-block; color: rgb(52, 58, 64); margin: 0px; padding: 0px; cursor: pointer;"
606+
>
607+
<span>
608+
asyncAtom
609+
:
610+
</span>
611+
</label>
612+
<span
613+
style="padding-left: 0.5em; cursor: pointer; color: rgb(144, 146, 150); display: inline;"
614+
>
615+
{ status: "fulfilled", value: 1 }
616+
</span>
617+
<ul
618+
style="padding: 0px; margin: 0px; list-style: none; display: block;"
619+
/>
620+
</li>
621+
</ul>
622+
</li>
623+
</ul>
624+
</div>,
625+
]
626+
`;
627+
628+
exports[`DevTools - TimeTravel Snapshot details should not add another snapshot history entry when restoring an async atom 2`] = `
629+
[
630+
<div
631+
class="internal-jotai-devtools-json-tree-wrapper"
632+
data-testid="json-tree-view-container"
633+
>
634+
<ul
635+
style="border: 0px; padding: 0.625rem; margin: 0px; list-style: none; background-color: rgba(248, 249, 250, 0.65);"
636+
>
637+
<li
638+
style="padding: 0px; margin: 0px;"
639+
>
640+
<ul
641+
style="padding: 0px; margin: 0px; list-style: none;"
642+
>
643+
<li
644+
style="padding-top: 0.25em; padding-right: 0px; margin-left: 0.875em; word-wrap: break-word; padding-left: 1.25em; text-indent: -0.5em; word-break: break-all;"
645+
>
646+
<label
647+
style="display: inline-block; color: rgb(52, 58, 64); margin: 0px 0.5em 0px 0px;"
648+
>
649+
<span>
650+
asyncAtom
651+
:
652+
</span>
653+
</label>
654+
<span>
655+
<span
656+
style="position: relative; padding: 2px 3px; border-radius: 3px; text-decoration: line-through; background-color: rgba(224, 49, 49, 0.65);"
657+
>
658+
null
659+
</span>
660+
<span
661+
style="position: relative; padding: 2px 3px; border-radius: 3px; color: rgb(144, 146, 150);"
662+
>
663+
=&gt;
664+
</span>
665+
<span
666+
style="position: relative; padding: 2px 3px; border-radius: 3px; background-color: rgba(47, 158, 68, 0.65);"
667+
>
668+
{
669+
status: "pending"
670+
}
671+
</span>
672+
</span>
673+
</li>
674+
</ul>
675+
</li>
676+
</ul>
677+
</div>,
678+
]
679+
`;
680+
577681
exports[`DevTools - TimeTravel Snapshot list Search should display an error if no snapshots are found 1`] = `
578682
<div
579683
class="internal-jotai-devtools-time-travel-wrapper m_8bffd616 __m__-r2c3"

src/DevTools/Extension/components/Shell/components/TimeTravel/useSyncSnapshotHistory.ts

+21-30
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,22 @@ const generateShortId = () =>
2424
Date.now().toString(36).substring(5) +
2525
Math.random().toString(36).substring(8);
2626

27-
type PromiseWithMeta = Promise<unknown> & {
28-
status?: 'pending' | 'fulfilled' | 'rejected';
29-
value?: unknown;
30-
reason?: unknown;
31-
};
27+
const inspectPromise = async (promise: Promise<unknown>) => {
28+
const immediatePromise = Promise.resolve();
29+
30+
try {
31+
const winner = await Promise.race([promise, immediatePromise]);
3232

33-
export const isPromiseWithMeta = (x: unknown): x is PromiseWithMeta =>
34-
x instanceof Promise;
33+
if (winner === undefined) {
34+
return { status: 'pending' };
35+
}
36+
37+
const value = await promise;
38+
return { status: 'fulfilled', value };
39+
} catch (error) {
40+
return { status: 'rejected', reason: error };
41+
}
42+
};
3543

3644
export default function useSyncSnapshotHistory() {
3745
const userStore = useUserStore();
@@ -73,7 +81,7 @@ export default function useSyncSnapshotHistory() {
7381
});
7482
};
7583

76-
const collectValues = () => {
84+
const collectValues = async () => {
7785
const values: AtomsValues = new Map();
7886
const displayValues: SnapshotHistory['displayValues'] = {};
7987
const dependents: AtomsDependents = new Map();
@@ -84,26 +92,9 @@ export default function useSyncSnapshotHistory() {
8492
values.set(atom, atomState.v);
8593
// if atom is not private, we'll add it to displayValues
8694
if (!atom.debugPrivate) {
87-
if (isPromiseWithMeta(atomState.v)) {
88-
if (atomState.v.status === 'pending') {
89-
displayValues[atomToPrintable(atom)] = {
90-
status: 'pending',
91-
};
92-
} else if (atomState.v.status === 'rejected') {
93-
displayValues[atomToPrintable(atom)] = {
94-
status: atomState.v.status,
95-
reason: atomState.v.reason,
96-
};
97-
} else if (atomState.v.status === 'fulfilled') {
98-
displayValues[atomToPrintable(atom)] = {
99-
status: atomState.v.status,
100-
value: atomState.v.value,
101-
};
102-
} else {
103-
displayValues[atomToPrintable(atom)] = {
104-
status: 'pending',
105-
};
106-
}
95+
if (atomState.v instanceof Promise) {
96+
const promiseResult = await inspectPromise(atomState.v);
97+
displayValues[atomToPrintable(atom)] = promiseResult;
10798
} else {
10899
displayValues[atomToPrintable(atom)] = atomState.v;
109100
}
@@ -117,7 +108,7 @@ export default function useSyncSnapshotHistory() {
117108
}
118109
addToHistoryStack({ values, dependents }, displayValues);
119110
};
120-
const cb = (
111+
const cb = async (
121112
action: Parameters<Parameters<typeof userStore.subscribeStore>[0]>[0],
122113
) => {
123114
const isWrite = action.type === 'set' || action.type === 'async-get';
@@ -126,7 +117,7 @@ export default function useSyncSnapshotHistory() {
126117
shouldRecordSnapshotHistory &&
127118
!store.get(isTimeTravelingAtom)
128119
) {
129-
collectValues();
120+
await collectValues();
130121
}
131122
};
132123

src/stories/Default/Playground/Async.tsx

+26-7
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import { Box, Button, Flex, Text, Title } from '@mantine/core';
33
import { useAtom, useAtomValue } from 'jotai/react';
44
import { atom } from 'jotai/vanilla';
55

6+
const RESPONSE_DELAY = 1000;
67
const delayedPromise = (data: any) =>
78
new Promise((resolve, reject) => {
89
const timeout = setTimeout(() => {
910
clearTimeout(timeout);
1011
resolve(data);
11-
}, 1000);
12+
}, RESPONSE_DELAY);
1213
});
1314

1415
const getRandomNumberBetweenMinAndMax = (min = 1, max = 100) => {
@@ -23,6 +24,14 @@ const makeRandomFetchReq = async () => {
2324
},
2425
);
2526
};
27+
28+
const makeRandomFetchReqError = async () => {
29+
return new Promise((resolve, reject) => {
30+
setTimeout(() => {
31+
reject('foo');
32+
}, RESPONSE_DELAY);
33+
});
34+
};
2635
// const asyncAtom = atom<Promise<any>>(Promise.resolve(null));
2736
const asyncAtom = atom<Promise<any> | null>(null);
2837
asyncAtom.debugLabel = 'asyncAtom';
@@ -49,26 +58,36 @@ export const Async = () => {
4958
setShowResult((v) => !v);
5059
};
5160

61+
const handleFetchErrorClick = () => {
62+
setRequest(makeRandomFetchReqError); // Will suspend until request resolves
63+
};
64+
5265
return (
5366
<Box>
5467
<Title size="h5">Async</Title>
5568
<Text mb={10} c="dark.2">
56-
Out-of-the-box Suspense support. <i>Timeout: 8000 ms</i>
69+
Out-of-the-box Suspense support. <i>Delay: {RESPONSE_DELAY / 1000}s</i>
5770
</Text>
5871
{/* User: {userId} */}
5972
{showResult && <ConditionalAsync />}
6073
<Text>Request status: {!request ? 'Ready' : '✅ Success'} </Text>
61-
<Flex>
62-
<Button onClick={handleFetchClick} size="xs" tt="uppercase" mt={5}>
63-
Fetch
74+
<Flex mt={5} gap={5}>
75+
<Button onClick={handleFetchClick} size="xs" tt="uppercase">
76+
Fetch Success
6477
</Button>
6578

79+
<Button
80+
onClick={handleFetchErrorClick}
81+
size="xs"
82+
tt="uppercase"
83+
color="red"
84+
>
85+
Fetch Error
86+
</Button>
6687
<Button
6788
onClick={handleShowResultClick}
6889
size="xs"
6990
tt="uppercase"
70-
mt={5}
71-
ml={5}
7291
color="green"
7392
>
7493
Toggle result

0 commit comments

Comments
 (0)