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,
+};