addons/isl/src/__tests__/jotaiUtils.test.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {Atom} from 'jotai';
b69ab319import type {AtomFamilyWeak} from '../jotaiUtils';
b69ab3110import type {Platform} from '../platform';
b69ab3111import type {LocalStorageName} from '../types';
b69ab3112
b69ab3113import {act, render} from '@testing-library/react';
b69ab3114import {List} from 'immutable';
b69ab3115import {Provider, atom, createStore, useAtomValue} from 'jotai';
b69ab3116import {StrictMode} from 'react';
b69ab3117import {SelfUpdate} from 'shared/immutableExt';
b69ab3118import {gc, nextTick} from 'shared/testUtils';
b69ab3119import {
b69ab3120 atomFamilyWeak,
b69ab3121 atomResetOnDepChange,
b69ab3122 lazyAtom,
b69ab3123 localStorageBackedAtomFamily,
b69ab3124 readAtom,
b69ab3125 useAtomGet,
b69ab3126 useAtomHas,
b69ab3127 writeAtom,
b69ab3128} from '../jotaiUtils';
b69ab3129
b69ab3130class Foo extends SelfUpdate<List<number>> {}
b69ab3131
b69ab3132const testFooAtom = atom<Foo>(new Foo(List()));
b69ab3133
b69ab3134describe('Jotai compatibility', () => {
b69ab3135 it('does not freeze SelfUpdate types', () => {
b69ab3136 function MyTestComponent() {
b69ab3137 const foo2 = useAtomValue(testFooAtom);
b69ab3138 expect(Object.isSealed(foo2)).toBe(false);
b69ab3139 expect(Object.isFrozen(foo2)).toBe(false);
b69ab3140
b69ab3141 return null;
b69ab3142 }
b69ab3143 render(
b69ab3144 <Provider>
b69ab3145 <MyTestComponent />
b69ab3146 </Provider>,
b69ab3147 );
b69ab3148 });
b69ab3149});
b69ab3150
b69ab3151describe('lazyAtom', () => {
b69ab3152 it('returns sync load() value', () => {
b69ab3153 const a = lazyAtom(() => 1, 2);
b69ab3154 expect(readAtom(a)).toBe(1);
b69ab3155 });
b69ab3156
b69ab3157 it('returns fallback value and sets async load() value', async () => {
b69ab3158 const a = lazyAtom(() => Promise.resolve(1), 2);
b69ab3159 expect(readAtom(a)).toBe(2);
b69ab3160 await nextTick();
b69ab3161 expect(readAtom(a)).toBe(1);
b69ab3162 });
b69ab3163
b69ab3164 it('can depend on another atom', () => {
b69ab3165 const a = atom<number | undefined>(undefined);
b69ab3166 const b = lazyAtom(() => 2, 2, a);
b69ab3167
b69ab3168 expect(readAtom(a)).toBe(undefined);
b69ab3169
b69ab3170 // Reading `b` triggers updating `a`.
b69ab3171 expect(readAtom(b)).toBe(2);
b69ab3172 expect(readAtom(a)).toBe(2);
b69ab3173
b69ab3174 // If `a` is updated to be not `undefined`, `b` will be the same value.
b69ab3175 writeAtom(a, 3);
b69ab3176 expect(readAtom(b)).toBe(3);
b69ab3177
b69ab3178 // Updating `b` updates `a` too.
b69ab3179 writeAtom(b, 4);
b69ab3180 expect(readAtom(a)).toBe(4);
b69ab3181
b69ab3182 // If `a` is updated to be `undefined`, `b` will be the fallback value.
b69ab3183 writeAtom(a, undefined);
b69ab3184 expect(readAtom(b)).toBe(2);
b69ab3185 });
b69ab3186});
b69ab3187
b69ab3188describe('atomFamilyWeak', () => {
b69ab3189 let recalcFamilyCount = 0;
b69ab3190 let recalcAtomCount = 0;
b69ab3191 const family = atomFamilyWeak((key: number) => {
b69ab3192 recalcFamilyCount += 1;
b69ab3193 return atom(_get => {
b69ab3194 recalcAtomCount += 1;
b69ab3195 return key;
b69ab3196 });
b69ab3197 });
b69ab3198
b69ab3199 let store = createStore();
b69ab31100
b69ab31101 function TestComponent({k, f}: {k: number; f?: AtomFamilyWeak<number, Atom<number>>}) {
b69ab31102 const value = useAtomValue((f ?? family)(k));
b69ab31103 return <div>{value}</div>;
b69ab31104 }
b69ab31105 function TestApp({
b69ab31106 keys,
b69ab31107 family,
b69ab31108 }: {
b69ab31109 keys: Array<number>;
b69ab31110 family?: AtomFamilyWeak<number, Atom<number>>;
b69ab31111 }) {
b69ab31112 return (
b69ab31113 <Provider store={store}>
b69ab31114 {keys.map(k => (
b69ab31115 <TestComponent k={k} key={k} f={family} />
b69ab31116 ))}
b69ab31117 </Provider>
b69ab31118 );
b69ab31119 }
b69ab31120
b69ab31121 beforeEach(() => {
b69ab31122 store = createStore();
b69ab31123 family.clear();
b69ab31124 recalcFamilyCount = 0;
b69ab31125 recalcAtomCount = 0;
b69ab31126 });
b69ab31127
b69ab31128 it('with "strong" cache enabled (default), unmount/remount skips re-calculate', async () => {
b69ab31129 const rendered = render(<TestApp keys={[1, 2, 3]} />);
b69ab31130 expect(recalcFamilyCount).toBe(3);
b69ab31131 expect(recalcAtomCount).toBe(3);
b69ab31132
b69ab31133 rendered.rerender(<TestApp keys={[1, 2, 3]} />);
b69ab31134 expect(recalcFamilyCount).toBe(3);
b69ab31135 expect(recalcAtomCount).toBe(3);
b69ab31136
b69ab31137 // After unmount, part of the atomFamily cache can still be used when re-mounting.
b69ab31138 rendered.unmount();
b69ab31139 await gc();
b69ab31140
b69ab31141 render(<TestApp keys={[3, 2, 1]} />);
b69ab31142 expect(recalcFamilyCount).toBeLessThan(6);
b69ab31143 expect(recalcAtomCount).toBeLessThan(6);
b69ab31144 });
b69ab31145
b69ab31146 it('with "strong" cache disabled, unmount/remount might re-calculate', async () => {
b69ab31147 let count = 0;
b69ab31148 const family = atomFamilyWeak(
b69ab31149 (key: number) => {
b69ab31150 count += 1;
b69ab31151 return atom(_get => key);
b69ab31152 },
b69ab31153 {useStrongCache: false},
b69ab31154 );
b69ab31155
b69ab31156 const rendered = render(<TestApp keys={[1, 2, 3]} family={family} />);
b69ab31157 expect(count).toBe(3);
b69ab31158
b69ab31159 rendered.unmount();
b69ab31160 await gc();
b69ab31161
b69ab31162 render(<TestApp keys={[1, 2, 3]} family={family} />);
b69ab31163 expect(count).toBe(6);
b69ab31164 });
b69ab31165
b69ab31166 it('"cleanup" does not clean in-use states', () => {
b69ab31167 const rendered = render(<TestApp keys={[1, 2, 3]} />);
b69ab31168 expect(recalcFamilyCount).toBe(3);
b69ab31169 expect(recalcAtomCount).toBe(3);
b69ab31170 family.cleanup();
b69ab31171
b69ab31172 // re-render can still use cached state after "cleanup" (count remains the same).
b69ab31173 rendered.rerender(<TestApp keys={[1, 2, 3]} />);
b69ab31174 expect(recalcFamilyCount).toBe(3);
b69ab31175 expect(recalcAtomCount).toBe(3);
b69ab31176 });
b69ab31177
b69ab31178 it('"cleanup" can release memory', async () => {
b69ab31179 const rendered = render(<TestApp keys={[1, 2, 3]} />);
b69ab31180 expect(recalcFamilyCount).toBe(3);
b69ab31181 expect(recalcAtomCount).toBe(3);
b69ab31182 rendered.unmount();
b69ab31183
b69ab31184 await gc();
b69ab31185 family.cleanup();
b69ab31186 await gc();
b69ab31187 family.cleanup();
b69ab31188
b69ab31189 // umount, then re-render will recalculate all atoms (count increases).
b69ab31190 render(<TestApp keys={[3, 2, 1]} />);
b69ab31191 expect(recalcFamilyCount).toBe(6);
b69ab31192 expect(recalcAtomCount).toBe(6);
b69ab31193 });
b69ab31194
b69ab31195 it('"clear" releases memory', () => {
b69ab31196 const rendered = render(<TestApp keys={[1, 2, 3]} />);
b69ab31197 expect(recalcFamilyCount).toBe(3);
b69ab31198 expect(recalcAtomCount).toBe(3);
b69ab31199 family.clear();
b69ab31200
b69ab31201 // re-render will recalculate all atoms.
b69ab31202 rendered.rerender(<TestApp keys={[1, 2, 3]} />);
b69ab31203 expect(recalcFamilyCount).toBe(6);
b69ab31204 expect(recalcAtomCount).toBe(6);
b69ab31205 rendered.unmount();
b69ab31206 family.clear();
b69ab31207
b69ab31208 // umount, then re-render will recalculate all atoms (count increases).
b69ab31209 render(<TestApp keys={[3, 2, 1]} />);
b69ab31210 expect(recalcFamilyCount).toBe(9);
b69ab31211 expect(recalcAtomCount).toBe(9);
b69ab31212 });
b69ab31213
b69ab31214 it('"cleanup" runs automatically to reduce cache size', async () => {
b69ab31215 const N = 10;
b69ab31216 const M = 30;
b69ab31217
b69ab31218 // Render N items.
b69ab31219 const rendered = render(<TestApp keys={Array.from({length: N}, (_, i) => i)} />);
b69ab31220
b69ab31221 // Umount to drop references to the atoms.
b69ab31222 rendered.unmount();
b69ab31223
b69ab31224 // Force GC to run to stabilize the test.
b69ab31225 await gc();
b69ab31226
b69ab31227 // After GC, render M items with different keys.
b69ab31228 // This would trigger `family.cleanup` transparently.
b69ab31229 render(<TestApp keys={Array.from({length: M}, (_, i) => N + i)} />);
b69ab31230
b69ab31231 // Neither of the caches should have N + M items (which means no cleanup).
b69ab31232 expect(family.weakCache.size).toBeLessThan(N + M);
b69ab31233 expect(family.strongCache.size).toBeLessThan(N + M);
b69ab31234 });
b69ab31235
b69ab31236 it('provides debugLabel', () => {
b69ab31237 const family = atomFamilyWeak((v: string) => atom(v));
b69ab31238 family.debugLabel = 'prefix1';
b69ab31239 const atom1 = family('a');
b69ab31240 expect(atom1.debugLabel).toBe('prefix1:a');
b69ab31241 });
b69ab31242});
b69ab31243
b69ab31244describe('useAtomGet and useAtomSet', () => {
b69ab31245 const initialMap = new Map([
b69ab31246 ['a', 1],
b69ab31247 ['b', 2],
b69ab31248 ['c', 3],
b69ab31249 ]);
b69ab31250 const initialSet = new Set(['a', 'b']);
b69ab31251
b69ab31252 // Render an App, change the map and set, check what
b69ab31253 // Runs a test and report re-render and atom states.
b69ab31254 // insertMap specifies changes to the map (initially {a: 1, b: 2, c: 3}).
b69ab31255 // changeSet specifies changes to the set (initially {a, b}).
b69ab31256 function findRerender(props: {
b69ab31257 insertMap?: Iterable<[string, number]>;
b69ab31258 replaceSet?: Iterable<string>;
b69ab31259 }): Array<string> {
b69ab31260 // container types
b69ab31261 const map = atom<Map<string, number>>(initialMap);
b69ab31262 const set = atom<Set<string>>(initialSet);
b69ab31263 const rerenderKeys = new Set<string>();
b69ab31264
b69ab31265 // test UI components
b69ab31266 function Item({k}: {k: string}) {
b69ab31267 const mapValue = useAtomGet(map, k);
b69ab31268 const setValue = useAtomHas(set, k);
b69ab31269 rerenderKeys.add(k);
b69ab31270 return (
b69ab31271 <span>
b69ab31272 {mapValue} {setValue}
b69ab31273 </span>
b69ab31274 );
b69ab31275 }
b69ab31276
b69ab31277 const store = createStore();
b69ab31278
b69ab31279 function TestApp({keys}: {keys: Array<string>}) {
b69ab31280 return (
b69ab31281 <StrictMode>
b69ab31282 <Provider store={store}>
b69ab31283 {keys.map(k => (
b69ab31284 <Item k={k} key={k} />
b69ab31285 ))}
b69ab31286 </Provider>
b69ab31287 </StrictMode>
b69ab31288 );
b69ab31289 }
b69ab31290
b69ab31291 const keys = ['a', 'b', 'c'];
b69ab31292 render(<TestApp keys={keys} />);
b69ab31293
b69ab31294 rerenderKeys.clear();
b69ab31295
b69ab31296 const {insertMap, replaceSet} = props;
b69ab31297
b69ab31298 act(() => {
b69ab31299 if (insertMap) {
b69ab31300 store.set(map, oldMap => new Map([...oldMap, ...insertMap]));
b69ab31301 }
b69ab31302 if (replaceSet) {
b69ab31303 const newSet = new Set([...replaceSet]);
b69ab31304 store.set(set, newSet);
b69ab31305 }
b69ab31306 });
b69ab31307
b69ab31308 return [...rerenderKeys];
b69ab31309 }
b69ab31310
b69ab31311 it('avoids re-rendering with changing to unrelated keys', () => {
b69ab31312 expect(findRerender({insertMap: [['unrelated-key', 3]]})).toEqual([]);
b69ab31313 expect(findRerender({replaceSet: [...initialSet, 'unrelated-key']})).toEqual([]);
b69ab31314 });
b69ab31315
b69ab31316 it('only re-render changed items', () => {
b69ab31317 const replaceSet = [...initialSet, 'c']; // add 'c' to the set.
b69ab31318 expect(findRerender({insertMap: [['b', 5]]})).toEqual(['b']);
b69ab31319 expect(findRerender({replaceSet})).toEqual(['c']);
b69ab31320 expect(findRerender({insertMap: [['b', 5]], replaceSet})).toEqual(['b', 'c']);
b69ab31321 });
b69ab31322});
b69ab31323
b69ab31324describe('atomResetOnDepChange', () => {
b69ab31325 it('works like a primitive atom', () => {
b69ab31326 const depAtom = atom(0);
b69ab31327 const testAtom = atomResetOnDepChange(1, depAtom);
b69ab31328 const doubleAtom = atom(get => get(testAtom) * 2);
b69ab31329 expect(readAtom(doubleAtom)).toBe(2);
b69ab31330 expect(readAtom(testAtom)).toBe(1);
b69ab31331 writeAtom(testAtom, 2);
b69ab31332 expect(readAtom(doubleAtom)).toBe(4);
b69ab31333 expect(readAtom(testAtom)).toBe(2);
b69ab31334 });
b69ab31335
b69ab31336 it('gets reset on dependency change', () => {
b69ab31337 const depAtom = atom(0);
b69ab31338 const testAtom = atomResetOnDepChange(1, depAtom);
b69ab31339
b69ab31340 writeAtom(testAtom, 2);
b69ab31341
b69ab31342 // Change depAtom should reset testAtom.
b69ab31343 writeAtom(depAtom, 10);
b69ab31344 expect(readAtom(testAtom)).toBe(1);
b69ab31345
b69ab31346 // Set depAtom to the same value does not reset testAtom.
b69ab31347 writeAtom(testAtom, 3);
b69ab31348 writeAtom(depAtom, 10);
b69ab31349 expect(readAtom(testAtom)).toBe(3);
b69ab31350 });
b69ab31351});
b69ab31352
b69ab31353describe('localStorageBackedAtomFamily', () => {
b69ab31354 function setupTestPlatform<T>(initialValues: Record<string, T>): Platform {
b69ab31355 const state = {...initialValues};
b69ab31356 const mockPlatform = {
b69ab31357 getAllPersistedState: jest.fn().mockImplementation((): Record<string, T> => {
b69ab31358 return state;
b69ab31359 }),
b69ab31360 getPersistedState: jest.fn().mockImplementation((key: string): T => {
b69ab31361 return state[key];
b69ab31362 }),
b69ab31363 setPersistedState: jest.fn().mockImplementation((key: string, value: T) => {
b69ab31364 state[key] = value;
b69ab31365 }),
b69ab31366 } as Partial<Platform> as Platform;
b69ab31367 return mockPlatform;
b69ab31368 }
b69ab31369
b69ab31370 const testKey = 'test_' as LocalStorageName;
b69ab31371
b69ab31372 it('loads initial state from storage', () => {
b69ab31373 const mockPlatform = setupTestPlatform({test_a: {data: 1, date: Date.now()}});
b69ab31374 const family = localStorageBackedAtomFamily(testKey, () => 0, 1, mockPlatform);
b69ab31375
b69ab31376 expect(readAtom(family('a'))).toEqual(1);
b69ab31377 expect(readAtom(family('b'))).toEqual(0);
b69ab31378 });
b69ab31379
b69ab31380 it('evicts old initial state from storage', () => {
b69ab31381 const mockPlatform = setupTestPlatform({
b69ab31382 test_a: {data: 1, date: 0},
b69ab31383 test_b: {data: 2, date: Date.now()},
b69ab31384 });
b69ab31385 const family = localStorageBackedAtomFamily(testKey, () => 0, 1, mockPlatform);
b69ab31386
b69ab31387 expect(readAtom(family('a'))).toEqual(0);
b69ab31388 expect(readAtom(family('b'))).toEqual(2);
b69ab31389 });
b69ab31390
b69ab31391 it('writes to persisted state', () => {
b69ab31392 const mockPlatform = setupTestPlatform<number>({});
b69ab31393 const family = localStorageBackedAtomFamily(testKey, (): number => 0, 1, mockPlatform);
b69ab31394
b69ab31395 expect(readAtom(family('a'))).toEqual(0);
b69ab31396 writeAtom(family('a'), 2);
b69ab31397 expect(readAtom(family('a'))).toEqual(2);
b69ab31398 expect(mockPlatform.setPersistedState).toHaveBeenCalledWith('test_a', {
b69ab31399 data: 2,
b69ab31400 date: expect.any(Number),
b69ab31401 });
b69ab31402 });
b69ab31403
b69ab31404 it("gets the latest data even after writing and then being gc'd", () => {
b69ab31405 const mockPlatform = setupTestPlatform({test_a: {data: 1, date: Date.now()}});
b69ab31406 const family = localStorageBackedAtomFamily(testKey, (): number => 0, 1, mockPlatform);
b69ab31407
b69ab31408 expect(readAtom(family('a'))).toEqual(1);
b69ab31409 writeAtom(family('a'), 2);
b69ab31410 expect(readAtom(family('a'))).toEqual(2);
b69ab31411 family.clear();
b69ab31412 expect(readAtom(family('a'))).toEqual(2);
b69ab31413 });
b69ab31414
b69ab31415 it('writing undefined clears from persisted storage', () => {
b69ab31416 const mockPlatform = setupTestPlatform({test_a: {data: 1, date: Date.now()}});
b69ab31417 const family = localStorageBackedAtomFamily(testKey, () => 0, 1, mockPlatform);
b69ab31418
b69ab31419 // type check that although we can SET to undefined, we can't READ undefined
b69ab31420 const returnTypeShouldBeNonNull: number = readAtom(family('a'));
b69ab31421 expect(returnTypeShouldBeNonNull).toEqual(1);
b69ab31422
b69ab31423 writeAtom(family('a'), undefined);
b69ab31424
b69ab31425 expect(mockPlatform.setPersistedState).toHaveBeenCalledWith('test_a', undefined);
b69ab31426 expect(mockPlatform.getPersistedState('test_a')).toBeUndefined(); // nothing is stored
b69ab31427 expect(readAtom(family('a'))).toEqual(0); // read still gives default value
b69ab31428 });
b69ab31429});