From 25373a3089b1a2d8caa726268aae8cc198408fdc Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Thu, 1 Jul 2021 07:19:26 -0700 Subject: [PATCH] Introduce localStorage support to createState Summary: Per title. Feature will be used in several plugins in next diffs. Differential Revision: D29514456 fbshipit-source-id: c12427c2a7c53fa01cd1c7f429be8611be55496d --- .../__tests__/atom-local-storage.node.tsx | 85 +++++++++++++++++++ desktop/flipper-plugin/src/state/atom.tsx | 40 ++++++++- docs/extending/flipper-plugin.mdx | 9 +- 3 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 desktop/flipper-plugin/src/state/__tests__/atom-local-storage.node.tsx diff --git a/desktop/flipper-plugin/src/state/__tests__/atom-local-storage.node.tsx b/desktop/flipper-plugin/src/state/__tests__/atom-local-storage.node.tsx new file mode 100644 index 000000000..1ed46e42a --- /dev/null +++ b/desktop/flipper-plugin/src/state/__tests__/atom-local-storage.node.tsx @@ -0,0 +1,85 @@ +/** + * 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. + * + * @format + */ + +import {createState} from '../atom'; +import * as TestUtils from '../../test-utils/test-utils'; + +beforeEach(() => { + window.localStorage.clear(); +}); + +test('it can start a plugin and lifecycle events', () => { + window.localStorage.setItem('flipper:TestPlugin:atom:x', '{ "x": 2 }'); + + const testPlugin = { + plugin() { + const x = createState<{x: number}>( + {x: 1}, + { + persist: 'x', + persistToLocalStorage: true, + }, + ); + const y = createState(true, { + persist: 'y', + persistToLocalStorage: true, + }); + + return {x, y}; + }, + Component() { + return null; + }, + }; + + const {instance} = TestUtils.startPlugin(testPlugin); + expect(instance.x.get()).toEqual({x: 2}); + expect(instance.y.get()).toEqual(true); + expect(getStorageSnapshot()).toMatchInlineSnapshot(` + Object { + "flipper:TestPlugin:atom:x": "{ \\"x\\": 2 }", + } + `); + + instance.x.update((d) => { + d.x++; + }); + instance.y.set(false); + expect(getStorageSnapshot()).toMatchInlineSnapshot(` + Object { + "flipper:TestPlugin:atom:x": "{\\"x\\":3}", + "flipper:TestPlugin:atom:y": "false", + } + `); +}); + +function getStorageSnapshot() { + const res: Record = {}; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i)!; + res[key] = window.localStorage.getItem(key)!; + } + return res; +} + +test('localStorage requires persist key', () => { + expect(() => + createState(3, {persistToLocalStorage: true}), + ).toThrowErrorMatchingInlineSnapshot( + `"The 'persist' option should be set when 'persistToLocalStorage' is set"`, + ); +}); + +test('localStorage requires plugin context', () => { + expect(() => + createState(3, {persistToLocalStorage: true, persist: 'x'}), + ).toThrowErrorMatchingInlineSnapshot( + `"The 'persistToLocalStorage' option cannot be used outside a plugin definition"`, + ); +}); diff --git a/desktop/flipper-plugin/src/state/atom.tsx b/desktop/flipper-plugin/src/state/atom.tsx index afc66e0e1..969da81f8 100644 --- a/desktop/flipper-plugin/src/state/atom.tsx +++ b/desktop/flipper-plugin/src/state/atom.tsx @@ -9,7 +9,11 @@ import {produce, Draft, enableMapSet} from 'immer'; import {useState, useEffect} from 'react'; -import {Persistable, registerStorageAtom} from '../plugin/PluginBase'; +import { + getCurrentPluginInstance, + Persistable, + registerStorageAtom, +} from '../plugin/PluginBase'; import { deserializeShallowObject, makeShallowSerializable, @@ -85,6 +89,12 @@ type StateOptions = { * If set, the atom will be saved / loaded under the key provided */ persist?: string; + /** + * Store this state in local storage, instead of as part of the plugin import / export. + * State stored in local storage is shared between the same plugin + * across multiple clients/ devices, but not actively synced. + */ + persistToLocalStorage?: boolean; }; export function createState( @@ -97,10 +107,36 @@ export function createState( options: StateOptions = {}, ): Atom { const atom = new AtomValue(initialValue); - registerStorageAtom(options.persist, atom); + if (options?.persistToLocalStorage) { + syncAtomWithLocalStorage(options, atom); + } else { + registerStorageAtom(options.persist, atom); + } return atom; } +function syncAtomWithLocalStorage(options: StateOptions, atom: AtomValue) { + if (!options?.persist) { + throw new Error( + "The 'persist' option should be set when 'persistToLocalStorage' is set", + ); + } + const pluginInstance = getCurrentPluginInstance(); + if (!pluginInstance) { + throw new Error( + "The 'persistToLocalStorage' option cannot be used outside a plugin definition", + ); + } + const storageKey = `flipper:${pluginInstance.definition.id}:atom:${options.persist}`; + const storedValue = window.localStorage.getItem(storageKey); + if (storedValue != null) { + atom.deserialize(JSON.parse(storedValue)); + } + atom.subscribe(() => { + window.localStorage.setItem(storageKey, JSON.stringify(atom.serialize())); + }); +} + export function useValue(atom: ReadOnlyAtom): T; export function useValue( atom: ReadOnlyAtom | undefined, diff --git a/docs/extending/flipper-plugin.mdx b/docs/extending/flipper-plugin.mdx index dc4f59ea7..7cffd1ed1 100644 --- a/docs/extending/flipper-plugin.mdx +++ b/docs/extending/flipper-plugin.mdx @@ -472,6 +472,7 @@ Its value should be treated as immutable and is initialized by default using the Optionally, `options` can be provided when creating state. Supported options: * `persist: string`. If the `persist` value is set, this state container will be serialized when n Flipper snapshot export is being made. When a snapshot is imported into Flipper, and plugins are initialized, this state container will load its initial value from the snapshot, rather than using the `initialValue` parameter. The `persist` key should be unique within the plugin and only be set if the state stored in this container is serializable and won't become unreasonably large. See also `exportState` and `initialState` in the [`TestUtils`](#testutils) section. +* `persistToLocalStorage: boolean`. If this option is set in combination with the `persist` option. The atom will store its state in local storage instead of as part of the plugin import / export. State stored in local storage is shared between the same plugin across multiple clients/ devices, but not actively synced. Serializable is defined as: non-cyclic data, consisting purely of primitive values, plain objects and arrays. Precisely as the root, `Date`, `Set` or `Map` objects are allowed as well, but they shouldn't appear deeper in the tree. @@ -941,10 +942,10 @@ Utility to create a plugin that consists of a master table and details JSON view `createTablePlugin` creates a plugin that handles receiving data from the client and displaying it in a table. The table handles selection of items, sorting, filtering and rendering a sidebar where more detailed information can be presented about the selected row. -The plugin expects to be able to subscribe to the `method` argument and receive either single data objects. +The plugin expects to be able to subscribe to the `method` argument and receive either single data objects. Each data object represents a row in the table. -An optional `resetMethod` argument can be provided which will replace the current rows with the data provided. +An optional `resetMethod` argument can be provided which will replace the current rows with the data provided. This is useful when connecting to Flipper for this first time, or reconnecting to the client in an unknown state. @@ -956,8 +957,8 @@ Valid options are: * `resetMethod?: string`: An event name, that, when sent from the client, should clear the current table. * `columns: DataTableColumn`: A description of the columns to display. See the [DataTable columns](#datatable). * `key?: string`: If set, the specified field of the incoming data will be treated as unique identifier. Receiving new data for existing rows will replace the existing rows. Without this property the table will only be appended. -* `onCopyRows?: (rows) => string`: A function that can be used to customize how records are copied to the clipboard. By default they will be `JSON.stringify`-ed. -* `buildRow?: (rawData) => row`: A function that can be used to preprocess the incoming data before it is handed of to the table. +* `onCopyRows?: (rows) => string`: A function that can be used to customize how records are copied to the clipboard. By default they will be `JSON.stringify`-ed. +* `buildRow?: (rawData) => row`: A function that can be used to preprocess the incoming data before it is handed of to the table. * `renderSidebar?: (row) => React.Element`: A function that can be used to customize how the sidebar is rendered. ### batch