Skip to content

Commit

Permalink
Retain snapshots for async callbacks (facebookexperimental#1632)
Browse files Browse the repository at this point in the history
Summary:
Pull Request resolved: facebookexperimental#1632

Automatically retains snapshots from `useRecoilCallback()` for the duration of async callbacks until the promise is resolved.  This should help avoid potential user errors without needing to manually remember to `retain()` and release in a `finally` clause.

Before:
```
const callback = useRecoilCallback(({snapshot}) => async () => {
  const release = snapshot.retain();
  try {
    ... await
    ... use snapshot
  } finally {
    release();
  }
});
```

After:
```
const callback = useRecoilCallback(({snapshot}) => async () => {
  ... await
  ... use snapshot
});
```

Manually retaining is still required if trying to persist the the Snapshot beyond the lifetime of the async callback or if it is used in async callbacks not dependent to a returned promise.

Differential Revision: https://www.internalfb.com/diff/D34405832?entry_point=27

fbshipit-source-id: 7cb310815c669a62623f1819c4bf8841f9c5002a
  • Loading branch information
drarmstr authored and facebook-github-bot committed Mar 17, 2022
1 parent 82653ab commit 37ffac2
Show file tree
Hide file tree
Showing 4 changed files with 82 additions and 11 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

- The `default` value is now optional for `atom()` and `atomFamily()`. If not provided the atom will initialize to a pending state. (#1639)
- Significant optimization for selector evaluations. 2x improvement with 100 dependencies, 4x with 1,000, and 40x with 10,000. (#1515, #914)
- Automatically retain snapshots for the duration of async callbacks. (#1632)
- `shouldNotBeFrozen` now works in JS environment without `Window` interface. (#1571)
- Avoid spurious console errors from effects when calling `setSelf()` from `onSet()` handlers. (#1589)
- Better error reporting when selectors provide inconsistent results (#1696)
Expand Down
15 changes: 14 additions & 1 deletion packages/recoil/hooks/Recoil_useRecoilCallback.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const {gotoSnapshot} = require('./Recoil_SnapshotHooks');
const {useCallback} = require('react');
const err = require('recoil-shared/util/Recoil_err');
const invariant = require('recoil-shared/util/Recoil_invariant');
const isPromise = require('recoil-shared/util/Recoil_isPromise');
const lazyProxy = require('recoil-shared/util/Recoil_lazyProxy');

export type RecoilCallbackInterface = $ReadOnly<{
Expand All @@ -49,6 +50,7 @@ function recoilCallback<Args: $ReadOnlyArray<mixed>, Return, ExtraInterface>(
extraInterface?: ExtraInterface,
): Return {
let ret: Return | Sentinel = SENTINEL;
let releaseSnapshot;
batchUpdates(() => {
const errMsg =
'useRecoilCallback() expects a function that returns a function: ' +
Expand Down Expand Up @@ -79,7 +81,11 @@ function recoilCallback<Args: $ReadOnlyArray<mixed>, Return, ExtraInterface>(
transact_UNSTABLE: transaction => atomicUpdater(store)(transaction),
},
{
snapshot: () => cloneSnapshot(store),
snapshot: () => {
const snapshot = cloneSnapshot(store);
releaseSnapshot = snapshot.retain();
return snapshot;
},
},
);

Expand All @@ -93,6 +99,13 @@ function recoilCallback<Args: $ReadOnlyArray<mixed>, Return, ExtraInterface>(
!(ret instanceof Sentinel),
'batchUpdates should return immediately',
);
if (isPromise(ret)) {
ret.finally(() => {
releaseSnapshot?.();
});
} else {
releaseSnapshot?.();
}
return (ret: Return);
}

Expand Down
69 changes: 60 additions & 9 deletions packages/recoil/hooks/__tests__/Recoil_useRecoilCallback-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -630,12 +630,66 @@ describe('Selector Cache', () => {
});
});

describe('Snapshot cache', () => {
testRecoil('Snapshot is cached', () => {
const myAtom = atom({
key: 'useRecoilCallback snapshot cached',
default: 'DEFAULT',
describe('Snapshot', () => {
testRecoil('Snapshot is retained for async callbacks', async ({gks}) => {
let callback,
callbackSnapshot,
resolveSelector,
resolveSelector2,
resolveCallback;

const myAtom = stringAtom();
const mySelector1 = selector({
key: 'useRecoilCallback snapshot retain 1',
get: async ({get}) => {
await new Promise(resolve => {
resolveSelector = resolve;
});
return get(myAtom);
},
});
const mySelector2 = selector({
key: 'useRecoilCallback snapshot retain 2',
get: async ({get}) => {
await new Promise(resolve => {
resolveSelector2 = resolve;
});
return get(myAtom);
},
});

function Component() {
callback = useRecoilCallback(({snapshot}) => async () => {
callbackSnapshot = snapshot;
return new Promise(resolve => {
resolveCallback = resolve;
});
});
}

renderElements(<Component />);
callback?.();
const selector1Promise = callbackSnapshot?.getPromise(mySelector1);
const selector2Promise = callbackSnapshot?.getPromise(mySelector2);

// Wait to allow callback snapshot to auto-release after clock tick.
// It should still be retained for duration of callback, though.
await flushPromisesAndTimers();

// Selectors resolving before callback is resolved should not be canceled
act(() => resolveSelector());
await expect(selector1Promise).resolves.toBe('DEFAULT');

// Selectors resolving after callback is resolved should be canceled
if (gks.includes('recoil_memory_managament_2020')) {
act(() => resolveCallback());
act(() => resolveSelector2());
await expect(selector2Promise).rejects.toEqual({});
}
});

testRecoil('Snapshot is cached', () => {
const myAtom = stringAtom();

let getSnapshot;
let setMyAtom, resetMyAtom;
Expand Down Expand Up @@ -693,10 +747,7 @@ describe('Snapshot cache', () => {
});

testRecoil('cached snapshot is invalidated if not retained', async () => {
const myAtom = atom({
key: 'useRecoilCallback snapshot cache retained',
default: 'DEFAULT',
});
const myAtom = stringAtom();

let getSnapshot;
let setMyAtom;
Expand Down
8 changes: 7 additions & 1 deletion packages/shared/__test_utils__/Recoil_TestingUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,13 @@ const testGKs =
};

const WWW_GKS_TO_TEST = QUICK_TEST
? [['recoil_hamt_2020', 'recoil_sync_external_store']]
? [
[
'recoil_hamt_2020',
'recoil_sync_external_store',
'recoil_memory_managament_2020',
],
]
: [
// OSS for React <18:
['recoil_hamt_2020', 'recoil_suppress_rerender_in_callback'], // Also enables early rendering
Expand Down

0 comments on commit 37ffac2

Please sign in to comment.