From a5ba0748c2196be2922fafc900d1fd73525bb7d6 Mon Sep 17 00:00:00 2001 From: Douglas Armstrong Date: Fri, 17 Sep 2021 21:29:43 -0700 Subject: [PATCH] Initial Recoil Sync implementation (#1225) Summary: Pull Request resolved: https://github.com/facebookexperimental/Recoil/pull/1225 Initial implementation of `recoil-sync` with `useRecoilSync()` hook and `syncEffect()` atom effect. This just includes the basic functionality of registering atoms and reading/writing from storage. Lots more diffs to come to complete the full support. Current RFC API (*in flux based on ongoing feedback*): ``` type AtomDiff = Map>; // null entry means reset useRecoilSync({ syncKey?: SyncKey, // key to match with syncEffect() write?: ({diff: ItemDiff, items: ItemSnapshot}) => void, read?: ItemKey => ?Loadable, listen?: (AtomDiff => void) => (() => void), }) => void; ``` ``` function syncEffect(params: { syncKey?: SyncKey, // key to match with useRecoilSync() key?: ItemKey, // defaults to Atom key restore: mixed => ?Loadable, // For advanced use-cases read?: ({read: ItemKey => ?Loadable}) => Loadable, write?: (Loadable, {read: ItemKey => ?Loadable}) => ItemDiff, }): AtomEffect ``` Differential Revision: D30467990 fbshipit-source-id: 49beab1f484c0d8b95335721b98c8b28984dafaf --- .../recoil-sync/__tests__/recoil-sync-test.js | 256 ++++++++++++++++++ src/contrib/recoil-sync/recoil-sync.js | 158 +++++++++++ 2 files changed, 414 insertions(+) create mode 100644 src/contrib/recoil-sync/__tests__/recoil-sync-test.js create mode 100644 src/contrib/recoil-sync/recoil-sync.js diff --git a/src/contrib/recoil-sync/__tests__/recoil-sync-test.js b/src/contrib/recoil-sync/__tests__/recoil-sync-test.js new file mode 100644 index 0000000000..fa820fc1f6 --- /dev/null +++ b/src/contrib/recoil-sync/__tests__/recoil-sync-test.js @@ -0,0 +1,256 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. Confidential and proprietary. + * + * @emails oncall+recoil + * @flow strict-local + * @format + */ +'use strict'; + +// TODO UPDATE IMPORTS TO USE PUBLIC INTERFACE +// TODO PUBLIC LOADABLE INTERFACE + +const {act} = require('ReactTestUtils'); + +const { + loadableWithError, + loadableWithPromise, + loadableWithValue, +} = require('../../../adt/Recoil_Loadable'); +const atom = require('../../../recoil_values/Recoil_atom'); +const selectorFamily = require('../../../recoil_values/Recoil_selectorFamily'); +const { + ReadsAtom, + componentThatReadsAndWritesAtom, + flushPromisesAndTimers, + renderElements, +} = require('../../../testing/Recoil_TestingUtils'); +const {syncEffect, useRecoilSync} = require('../recoil-sync'); +const React = require('react'); + +test('Write to storage', async () => { + const atomA = atom({ + key: 'recoil-sync write A', + default: 'DEFAULT', + effects_UNSTABLE: [syncEffect({validate: x => loadableWithValue(x)})], + }); + const atomB = atom({ + key: 'recoil-sync write B', + default: 'DEFAULT', + effects_UNSTABLE: [syncEffect({validate: x => loadableWithValue(x)})], + }); + const ignoreAtom = atom({ + key: 'recol-sync write ignore', + default: 'DEFAULT', + }); + + const storage = new Map(); + + function write({diff}) { + for (const [key, loadable] of diff.entries()) { + loadable != null ? storage.set(key, loadable) : storage.delete(key); + } + } + function RecoilSync() { + useRecoilSync({write}); + return null; + } + + const [AtomA, setA, resetA] = componentThatReadsAndWritesAtom(atomA); + const [AtomB, setB] = componentThatReadsAndWritesAtom(atomB); + const [IgnoreAtom, setIgnore] = componentThatReadsAndWritesAtom(ignoreAtom); + const container = renderElements( + <> + + + + + , + ); + + expect(storage.size).toBe(0); + expect(container.textContent).toBe('"DEFAULT""DEFAULT""DEFAULT"'); + + act(() => setA('A')); + act(() => setB('B')); + act(() => setIgnore('IGNORE')); + expect(container.textContent).toBe('"A""B""IGNORE"'); + expect(storage.size).toBe(2); + expect(storage.get('recoil-sync write A')?.getValue()).toBe('A'); + expect(storage.get('recoil-sync write B')?.getValue()).toBe('B'); + + act(() => resetA()); + act(() => setB('BB')); + expect(container.textContent).toBe('"DEFAULT""BB""IGNORE"'); + expect(storage.size).toBe(1); + expect(storage.has('recoil-sync write A')).toBe(false); + expect(storage.get('recoil-sync write B')?.getValue()).toBe('BB'); +}); + +test('Write to multiple storages', async () => { + const atomA = atom({ + key: 'recoil-sync multiple storage A', + default: 'DEFAULT', + effects_UNSTABLE: [ + syncEffect({syncKey: 'A', validate: x => loadableWithValue(x)}), + ], + }); + const atomB = atom({ + key: 'recoil-sync multiple storage B', + default: 'DEFAULT', + effects_UNSTABLE: [ + syncEffect({syncKey: 'B', validate: x => loadableWithValue(x)}), + ], + }); + + const storageA = new Map(); + const storageB = new Map(); + + const write = storage => ({diff}) => { + for (const [key, loadable] of diff.entries()) { + loadable != null ? storage.set(key, loadable) : storage.delete(key); + } + }; + function RecoilSync({syncKey, storage}) { + useRecoilSync({syncKey, write: write(storage)}); + return null; + } + + const [AtomA, setA] = componentThatReadsAndWritesAtom(atomA); + const [AtomB, setB] = componentThatReadsAndWritesAtom(atomB); + renderElements( + <> + + + + + , + ); + + expect(storageA.size).toBe(0); + expect(storageB.size).toBe(0); + + act(() => setA('A')); + act(() => setB('B')); + expect(storageA.size).toBe(1); + expect(storageB.size).toBe(1); + expect(storageA.get('recoil-sync multiple storage A')?.getValue()).toBe('A'); + expect(storageB.get('recoil-sync multiple storage B')?.getValue()).toBe('B'); +}); + +test('Read from storage', async () => { + const atomA = atom({ + key: 'recoil-sync read A', + default: 'DEFAULT', + effects_UNSTABLE: [syncEffect({validate: x => loadableWithValue(x)})], + }); + const atomB = atom({ + key: 'recoil-sync read B', + default: 'DEFAULT', + effects_UNSTABLE: [syncEffect({validate: x => loadableWithValue(x)})], + }); + const atomC = atom({ + key: 'recoil-sync read C', + default: 'DEFAULT', + effects_UNSTABLE: [syncEffect({validate: x => loadableWithValue(x)})], + }); + + const storage = { + 'recoil-sync read A': loadableWithValue('A'), + 'recoil-sync read B': loadableWithValue('B'), + }; + + function RecoilSync() { + useRecoilSync({read: itemKey => storage[itemKey]}); + return null; + } + + const container = renderElements( + <> + + + + + , + ); + + expect(container.textContent).toBe('"A""B""DEFAULT"'); +}); + +test('Read from storage async', async () => { + const atomA = atom({ + key: 'recoil-sync read async', + default: 'DEFAULT', + effects_UNSTABLE: [syncEffect({validate: x => loadableWithValue(x)})], + }); + + const storage = { + 'recoil-sync read async': loadableWithPromise( + Promise.resolve({__value: 'A'}), + ), + }; + + function RecoilSync() { + useRecoilSync({read: itemKey => storage[itemKey]}); + return null; + } + + const container = renderElements( + <> + + + , + ); + + expect(container.textContent).toBe('loading'); + await flushPromisesAndTimers(); + expect(container.textContent).toBe('"A"'); +}); + +test('Read from storage error', async () => { + const atomA = atom({ + key: 'recoil-sync read error A', + default: 'DEFAULT', + effects_UNSTABLE: [syncEffect({validate: x => loadableWithValue(x)})], + }); + const atomB = atom({ + key: 'recoil-sync read error B', + default: 'DEFAULT', + effects_UNSTABLE: [syncEffect({validate: x => loadableWithValue(x)})], + }); + const mySelector = selectorFamily({ + key: 'recoil-sync read error selector', + get: ({myAtom}) => ({get}) => { + try { + return get(myAtom); + } catch (e) { + return e.message; + } + }, + }); + + const storage = { + 'recoil-sync read error A': loadableWithError(new Error('ERROR A')), + }; + function RecoilSync() { + useRecoilSync({ + read: itemKey => { + if (storage[itemKey] != null) { + return storage[itemKey]; + } + throw new Error('ERROR MISSING'); + }, + }); + return null; + } + + const container = renderElements( + <> + + + + , + ); + + expect(container.textContent).toBe('"ERROR A""ERROR MISSING"'); +}); diff --git a/src/contrib/recoil-sync/recoil-sync.js b/src/contrib/recoil-sync/recoil-sync.js new file mode 100644 index 0000000000..712f9ccc65 --- /dev/null +++ b/src/contrib/recoil-sync/recoil-sync.js @@ -0,0 +1,158 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+recoil + * @flow strict-local + * @format + */ +'use strict'; + +// TODO UPDATE IMPORTS TO USE PUBLIC INTERFACE + +import type {Loadable} from '../../adt/Recoil_Loadable'; +import type {RecoilState} from '../../core/Recoil_RecoilValue'; +import type {AtomEffect} from '../../recoil_values/Recoil_atom'; + +const {useRecoilSnapshot} = require('../../hooks/Recoil_Hooks'); +const {useEffect} = require('react'); + +type NodeKey = string; +export type ItemKey = string; +export type SyncKey = string | void; + +export type AtomDiff = Map>; +type Store = Map>; +type ReadItem = ItemKey => ?Loadable; + +type AtomRegistration = { + atom: RecoilState, // flowlint-line unclear-type:off + itemKey: ItemKey, + options: mixed, +}; + +class Registries { + registries: Map> = new Map(); + + getAtomRegistry(syncKey: SyncKey): Map { + const registry = this.registries.get(syncKey); + if (registry != null) { + return registry; + } + const newRegistry = new Map(); + this.registries.set(syncKey, newRegistry); + return newRegistry; + } +} +const registries = new Registries(); + +type Storage = { + read?: ReadItem, +}; +const storages: Map = new Map(); + +function useRecoilSync({ + syncKey, + write, + read, + listen, +}: { + syncKey?: SyncKey, + write?: ({diff: AtomDiff, store: Store}) => void, + read?: ReadItem, + listen?: ((AtomDiff) => void) => () => void, +}): void { + // Subscribe to Recoil state changes + const snapshot = useRecoilSnapshot(); + useEffect(() => { + if (write != null) { + const diff: AtomDiff = new Map(); + const atomRegistry = registries.getAtomRegistry(syncKey); + const modifiedAtoms = snapshot.getNodes_UNSTABLE({isModified: true}); + for (const atom of modifiedAtoms) { + const registration = atomRegistry.get(atom.key); + if (registration != null) { + const atomInfo = snapshot.getInfo_UNSTABLE(registration.atom); + // TODO syncEffect()'s write() + diff.set( + registration.itemKey, + atomInfo.isSet ? atomInfo.loadable : null, + ); + } + } + // TODO store + write({diff, store: diff}); + } + }, [snapshot, syncKey, write]); + + // Subscribe to Sync storage changes + // function handleListen(diff: AtomDiff) {} + // useEffect(() => listen?.(handleListen)); + + // Register Storage + // Save before effects so that we can initialize atoms for initial render + storages.set(syncKey, {read}); + useEffect(() => { + return () => void storages.delete(syncKey); + }, [syncKey]); +} + +function syncEffect({ + syncKey, + key, + options, +}: { + syncKey?: SyncKey, + key?: ItemKey, + options?: SyncItemOptions, + + validate: mixed => ?Loadable, + upgrade?: V => T, + + read?: ({read: ReadItem}) => mixed, + write?: (Loadable, {read: ReadItem}) => AtomDiff, +}): AtomEffect { + return ({node, setSelf}) => { + const itemKey = key ?? node.key; + + // Register Atom + const atomRegistry = registries.getAtomRegistry(syncKey); + atomRegistry.set(node.key, {atom: node, itemKey, options}); + + // Initialize Atom value + const readFromStorage = storages.get(syncKey)?.read; + if (readFromStorage != null) { + const loadable = readFromStorage(itemKey); + if (loadable != null) { + if (loadable.state == null) { + throw new Error('Sync read must provide a Loadable'); + } + if (loadable.state === 'hasError') { + throw loadable.contents; + } + + switch (loadable.state) { + case 'hasValue': + // $FlowFixMe TODO with validation + setSelf(loadable.contents); + break; + + case 'hasError': + throw loadable.contents; + + case 'loading': + // $FlowFixMe TODO with validation + setSelf(loadable.toPromise()); + break; + } + } + } + }; +} + +module.exports = { + useRecoilSync, + syncEffect, +};