From c5f99e7adaa26a877c91560887ec21e7836257c1 Mon Sep 17 00:00:00 2001 From: Douglas Armstrong Date: Fri, 17 Sep 2021 18:07:20 -0700 Subject: [PATCH] Add getLoadable() and getPromise() for Atom Effects (#1205) Summary: Pull Request resolved: https://github.com/facebookexperimental/Recoil/pull/1205 Add the ability for atom effects to get the value of other atoms. It can either get the values at the time of the atom initialization when the effect executes or it can get the values later during some async callback from the effect. ``` export type AtomEffect = ({ ... // Accessors to read other atoms/selectors getPromise: (RecoilValue) => Promise, getLoadable: (RecoilValue) => Loadable, }) => void | (() => void); ``` It is important to note that getting other atom values does not establish any subscription to re-evaluate anything if the upstream atoms may change. Alternative APIs to consider was exposing a transaction. But, we don't want to write to other atoms during initialization and it would be a stretch to create a read-only transaction type, especially as transactions don't currently have a return value. Another alternative would be offering to get a `Snapshot`. This would be a convenient API and nicely consistent and makes it very explicit that no subscription is established. However, retention for garbage collection is a concern. We could release the `Snapshot` after initialization, which would be straight-forward, but there isn't a clear please to automatically release or explicitly retain for async obtained snapshots. This proposed API mirrors the Snapshot interface for getting values to try to maximize consistency. Example use-cases for this functionality: * Use of other atom values to compute initial value. e.g. another atom may store some query mode to use. * Effects for data-loggers for multiple atoms * "Persist on set" - The ability to use an atom effect to initialize an atom based on other atom values and have the initial value persisted based on observing by other atom effects or the recoil sync library. This is in contrast to using a default selector for an atom for a dynamic default based on other atoms. That creates a wrapper selector, remains dynamic until set, and doesn't cause the atom to persist until it is explicitly set. Differential Revision: D30691374 fbshipit-source-id: 2a3d8cad60de215d90f654a73683c7d3a2d06596 --- CHANGELOG.md | 1 + src/recoil_values/Recoil_atom.js | 40 ++++++ .../__tests__/Recoil_atom-test.js | 133 ++++++++++++++++++ 3 files changed, 174 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 334820c1fb..848a9c07f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Ability to map Loadables with other Loadables - Allow class instances in family parameters for Flow +- Add `getLoadable()` and `getPromise()` to Atom Effects interface for reading other atoms. ### Pending - Memory management diff --git a/src/recoil_values/Recoil_atom.js b/src/recoil_values/Recoil_atom.js index 6fd753fb00..d490c963fe 100644 --- a/src/recoil_values/Recoil_atom.js +++ b/src/recoil_values/Recoil_atom.js @@ -88,6 +88,7 @@ const { } = require('../core/Recoil_Node'); const {isRecoilValue} = require('../core/Recoil_RecoilValue'); const { + getRecoilValueAsLoadable, markRecoilValueModified, setRecoilValue, setRecoilValueLoadable, @@ -129,6 +130,10 @@ export type AtomEffect = ({ // Subscribe callbacks to events. // Atom effect observers are called before global transaction observers onSet: ((newValue: T, oldValue: T | DefaultValue) => void) => void, + + // Accessors to read other atoms/selectors + getPromise: (RecoilValue) => Promise, + getLoadable: (RecoilValue) => Loadable, }) => void | (() => void); export type AtomOptions = $ReadOnly<{ @@ -241,6 +246,39 @@ function baseAtom(options: BaseAtomOptions): RecoilState { if (options.effects_UNSTABLE != null && !alreadyKnown) { let duringInit = true; + function getLoadable(recoilValue: RecoilValue): Loadable { + // Normally we can just get the current value of another atom. + // But for our own value we need to check if there is a pending + // initialized value or get the fallback default value. + if (duringInit && recoilValue.key === key) { + // Cast T to S + const retValue: NewValue = (initValue: any); // flowlint-line unclear-type:off + return retValue instanceof DefaultValue + ? (defaultLoadable: any) // flowlint-line unclear-type:off + : isPromise(retValue) + ? loadableWithPromise( + retValue.then( + (v: S | DefaultValue): ResolvedLoadablePromiseInfo => ({ + __key: key, + __value: + v instanceof DefaultValue + ? // TODO It's a little weird that this returns a Promise + // instead of T, but it seems to work. This can be cleaned + // up if we clean up how Loadable's wrap keys and values. + (defaultLoadable: any).toPromise() // flowlint-line unclear-type:off + : v, + }), + ), + ) + : loadableWithValue(retValue); + } + return getRecoilValueAsLoadable(store, recoilValue); + } + + function getPromise(recoilValue: RecoilValue): Promise { + return getLoadable(recoilValue).toPromise(); + } + const setSelf = (effect: AtomEffect) => ( valueOrUpdater: NewValueOrUpdater, ) => { @@ -339,6 +377,8 @@ function baseAtom(options: BaseAtomOptions): RecoilState { setSelf: setSelf(effect), resetSelf: resetSelf(effect), onSet: onSet(effect), + getPromise, + getLoadable, }); if (cleanup != null) { cleanupEffectsByStore.set(store, [ diff --git a/src/recoil_values/__tests__/Recoil_atom-test.js b/src/recoil_values/__tests__/Recoil_atom-test.js index a7f23294be..022c01ef64 100644 --- a/src/recoil_values/__tests__/Recoil_atom-test.js +++ b/src/recoil_values/__tests__/Recoil_atom-test.js @@ -71,6 +71,10 @@ function get(recoilValue) { return getRecoilValueAsLoadable(store, recoilValue).contents; } +function getLoadable(recoilValue) { + return getRecoilValueAsLoadable(store, recoilValue); +} + function set(recoilValue, value: mixed) { setRecoilValue(store, recoilValue, value); } @@ -1008,6 +1012,135 @@ describe('Effects', () => { expect(numTimesEffectInit).toBe(2); }, ); + + describe('Other Atoms', () => { + test('init from other atom', () => { + const myAtom = atom({ + key: 'atom effect - init from other atom', + default: 'DEFAULT', + effects_UNSTABLE: [ + ({setSelf, getLoadable}) => { + const otherValue = getLoadable(otherAtom).contents; + expect(otherValue).toEqual('OTHER'); + setSelf(otherValue); + }, + ], + }); + + const otherAtom = atom({ + key: 'atom effect - other atom', + default: 'OTHER', + }); + + expect(get(myAtom)).toEqual('OTHER'); + }); + + test('init from other atom async', async () => { + const myAtom = atom({ + key: 'atom effect - init from other atom async', + default: 'DEFAULT', + effects_UNSTABLE: [ + ({setSelf, getPromise}) => { + const otherValue = getPromise(otherAtom); + setSelf(otherValue); + }, + ], + }); + + const otherAtom = atom({ + key: 'atom effect - other atom async', + default: Promise.resolve('OTHER'), + }); + + await expect(getLoadable(myAtom).promiseOrThrow()).resolves.toEqual( + 'OTHER', + ); + }); + + test('async get other atoms', async () => { + let initTest1 = new Promise(() => {}); + let initTest2 = new Promise(() => {}); + let initTest3 = new Promise(() => {}); + let setTest = new Promise(() => {}); + + const myAtom = atom({ + key: 'atom effect - async get', + default: 'DEFAULT', + effects_UNSTABLE: [ + // Test we can get default values + ({node, getLoadable, getPromise}) => { + expect(getLoadable(node).contents).toEqual('DEFAULT'); + // eslint-disable-next-line jest/valid-expect + initTest1 = expect(getPromise(asyncAtom)).resolves.toEqual('ASYNC'); + }, + ({setSelf}) => { + setSelf('INIT'); + }, + // Test we can get value from previous initialization + ({node, getLoadable}) => { + expect(getLoadable(node).contents).toEqual('INIT'); + }, + // Test we can asynchronouse get "current" values of both self and other atoms + // This will be executed when myAtom is set, but checks both atoms. + ({onSet, getLoadable, getPromise}) => { + onSet(x => { + expect(x).toEqual('SET_ATOM'); + expect(getLoadable(myAtom).contents).toEqual(x); + // eslint-disable-next-line jest/valid-expect + setTest = expect(getPromise(asyncAtom)).resolves.toEqual( + 'SET_OTHER', + ); + }); + }, + ], + }); + + const asyncAtom = atom({ + key: 'atom effect - other atom async get', + default: Promise.resolve('ASYNC_DEFAULT'), + effects_UNSTABLE: [ + ({setSelf}) => void setSelf(Promise.resolve('ASYNC')), + ({getLoadable, getPromise}) => { + expect(getLoadable(myAtom).contents).toEqual('DEFAULT'); + // eslint-disable-next-line jest/valid-expect + initTest2 = expect(getPromise(asyncAtom)).resolves.toEqual('ASYNC'); + }, + + // Test that we can read default for an aborted initialization + ({setSelf}) => void setSelf(Promise.resolve(new DefaultValue())), + ({getPromise}) => { + // eslint-disable-next-line jest/valid-expect + initTest3 = expect(getPromise(asyncAtom)).resolves.toEqual( + 'ASYNC_DEFAULT', + ); + }, + + ({setSelf}) => void setSelf(Promise.resolve('ASYNC')), + ], + }); + + const [MyAtom, setMyAtom] = componentThatReadsAndWritesAtom(myAtom); + const [AsyncAtom, setAsyncAtom] = componentThatReadsAndWritesAtom( + asyncAtom, + ); + const c = renderElements( + <> + + + , + ); + + await flushPromisesAndTimers(); + expect(c.textContent).toBe('"INIT""ASYNC"'); + await initTest1; + await initTest2; + await initTest3; + + act(() => setAsyncAtom('SET_OTHER')); + act(() => setMyAtom('SET_ATOM')); + await setTest; + }); + }); }); testRecoil('object is frozen when stored in atom', () => {