Skip to content

Commit

Permalink
Add getLoadable() and getPromise() for Atom Effects (#1205)
Browse files Browse the repository at this point in the history
Summary:
Pull Request resolved: #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<T> = ({
  ...

  // Accessors to read other atoms/selectors
  getPromise: <T>(RecoilValue<T>) => Promise<T>,
  getLoadable: <T>(RecoilValue<T>) => Loadable<T>,
}) => 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: b3a4a547fbd4a39f8842d515891c48e5b74a0041
  • Loading branch information
drarmstr authored and facebook-github-bot committed Sep 17, 2021
1 parent cb1bc9d commit 4162fec
Show file tree
Hide file tree
Showing 3 changed files with 174 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/recoil_values/Recoil_atom.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const {
} = require('../core/Recoil_Node');
const {isRecoilValue} = require('../core/Recoil_RecoilValue');
const {
getRecoilValueAsLoadable,
markRecoilValueModified,
setRecoilValue,
setRecoilValueLoadable,
Expand Down Expand Up @@ -129,6 +130,10 @@ export type AtomEffect<T> = ({
// 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: <S>(RecoilValue<S>) => Promise<S>,
getLoadable: <S>(RecoilValue<S>) => Loadable<S>,
}) => void | (() => void);

export type AtomOptions<T> = $ReadOnly<{
Expand Down Expand Up @@ -241,6 +246,39 @@ function baseAtom<T>(options: BaseAtomOptions<T>): RecoilState<T> {
if (options.effects_UNSTABLE != null && !alreadyKnown) {
let duringInit = true;

function getLoadable<S>(recoilValue: RecoilValue<S>): Loadable<S> {
// 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<S> = (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<S> => ({
__key: key,
__value:
v instanceof DefaultValue
? // TODO It's a little weird that this returns a Promise<T>
// 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<S>(recoilValue: RecoilValue<S>): Promise<S> {
return getLoadable(recoilValue).toPromise();
}

const setSelf = (effect: AtomEffect<T>) => (
valueOrUpdater: NewValueOrUpdater<T>,
) => {
Expand Down Expand Up @@ -339,6 +377,8 @@ function baseAtom<T>(options: BaseAtomOptions<T>): RecoilState<T> {
setSelf: setSelf(effect),
resetSelf: resetSelf(effect),
onSet: onSet(effect),
getPromise,
getLoadable,
});
if (cleanup != null) {
cleanupEffectsByStore.set(store, [
Expand Down
133 changes: 133 additions & 0 deletions src/recoil_values/__tests__/Recoil_atom-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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(
<>
<MyAtom />
<AsyncAtom />
</>,
);

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', () => {
Expand Down

0 comments on commit 4162fec

Please sign in to comment.