From 05b87b09dbd3a221d832943989e141fd77bfb201 Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Fri, 18 Feb 2022 15:08:34 +0000 Subject: [PATCH] Refactor useHydratedStore hook. Remove mst-persist - consolidate `useStore` and `useHydratedStore`, so that `useHydratedStore` calls `useStore` to create the new store. - add a `persist` helper to manage store snapshots. - remove `mst-persist`. Move Classifier hooks to @hooks Load hooks from `/src/hooks`, aliased to `@hooks`. Test the useHydratedStore hook Consolidate store hooks Replace the store hooks with a single `useHydratedStore` hook. Use synchronous storage. --- packages/lib-classifier/package.json | 1 - .../Classifier/ClassifierContainer.js | 11 +-- .../src/components/Classifier/hooks/Readme.md | 26 ----- .../Classifier/hooks/useHydratedStore.js | 33 ------- .../components/Classifier/hooks/useStore.js | 25 ----- .../asyncSessionStorage.js | 25 ----- .../src/helpers/asyncSessionStorage/index.js | 1 - packages/lib-classifier/src/helpers/index.js | 1 - packages/lib-classifier/src/hooks/Readme.md | 19 ++++ .../Classifier => }/hooks/index.js | 1 - .../src/hooks/useHydratedStore.js | 51 ++++++++++ .../src/hooks/useHydratedStore.spec.js | 98 +++++++++++++++++++ .../hooks/useWorkflowSnapshot.js | 0 packages/lib-classifier/webpack.dev.js | 1 + packages/lib-classifier/webpack.dist.js | 1 + yarn.lock | 5 - 16 files changed, 174 insertions(+), 125 deletions(-) delete mode 100644 packages/lib-classifier/src/components/Classifier/hooks/Readme.md delete mode 100644 packages/lib-classifier/src/components/Classifier/hooks/useHydratedStore.js delete mode 100644 packages/lib-classifier/src/components/Classifier/hooks/useStore.js delete mode 100644 packages/lib-classifier/src/helpers/asyncSessionStorage/asyncSessionStorage.js delete mode 100644 packages/lib-classifier/src/helpers/asyncSessionStorage/index.js create mode 100644 packages/lib-classifier/src/hooks/Readme.md rename packages/lib-classifier/src/{components/Classifier => }/hooks/index.js (73%) create mode 100644 packages/lib-classifier/src/hooks/useHydratedStore.js create mode 100644 packages/lib-classifier/src/hooks/useHydratedStore.spec.js rename packages/lib-classifier/src/{components/Classifier => }/hooks/useWorkflowSnapshot.js (100%) diff --git a/packages/lib-classifier/package.json b/packages/lib-classifier/package.json index 98fd10b74a1..0e8d20bb3ce 100644 --- a/packages/lib-classifier/package.json +++ b/packages/lib-classifier/package.json @@ -35,7 +35,6 @@ "i18next": "~21.6.3", "lodash": "~4.17.11", "mobx-utils": "~6.0.4", - "mst-persist": "~0.1.3", "react-i18next": "~11.15.1", "react-player": "~2.9.0", "react-resize-detector": "~7.0.0", diff --git a/packages/lib-classifier/src/components/Classifier/ClassifierContainer.js b/packages/lib-classifier/src/components/Classifier/ClassifierContainer.js index 9434e36e0ca..674874fa8ef 100644 --- a/packages/lib-classifier/src/components/Classifier/ClassifierContainer.js +++ b/packages/lib-classifier/src/components/Classifier/ClassifierContainer.js @@ -11,7 +11,7 @@ import { tutorials as tutorialsClient } from '@zooniverse/panoptes-js' -import { useHydratedStore, useStore, useWorkflowSnapshot } from './hooks' +import { useHydratedStore, useWorkflowSnapshot } from '@hooks' import { unregisterWorkers } from '../../workers' import Classifier from './Classifier' @@ -62,15 +62,12 @@ export default function ClassifierContainer({ workflowID }) { - const classifierStore = useStore({ - authClient, - client, - initialState: {} - }) + const storeOptions = { authClient, client } const workflowSnapshot = useWorkflowSnapshot(workflowID) - const loaded = useHydratedStore(classifierStore, cachePanoptesData, `fem-classifier-${project.id}`) + const classifierStore = useHydratedStore(storeOptions, cachePanoptesData, `fem-classifier-${project.id}`) + const loaded = !!classifierStore useEffect(function onMount() { console.log('resetting stale user data') diff --git a/packages/lib-classifier/src/components/Classifier/hooks/Readme.md b/packages/lib-classifier/src/components/Classifier/hooks/Readme.md deleted file mode 100644 index 4becd0c5441..00000000000 --- a/packages/lib-classifier/src/components/Classifier/hooks/Readme.md +++ /dev/null @@ -1,26 +0,0 @@ -# Classifier hooks - -## useHydratedStore - -Hydrate a `mobx-state-tree` store from a stored [snapshot](https://mobx-state-tree.js.org/concepts/snapshots). Wraps the store in [`mst-persist`](https://github.com/agilgur5/mst-persist), which runs `applySnapshot(store, snapshot)` to hydrate the store, and adds an `onSnapshot` handler to keep the stored snapshot updated when the store changes. - -Runs asynchronously and returns `true` when hydration is complete. Snapshots are stored in session storage, so that they don't persist across tabs or windows. - -```js -const loaded = useHydratedStore(store, enableStorage = false, storageKey) -``` - -## useStore - -Create a `mobx-state-tree` store, using the Panoptes API clients and an optional snapshot. Adapted from [the NextJS example](https://github.com/vercel/next.js/blob/5201cdbaeaa72b54badc8f929ddc73c09f414dc4/examples/with-mobx-state-tree/store.js#L49-L52), which is also used in `app-project`. `initialState` must be a valid store snapshot. - -```js - const classifierStore = useStore({ authClient, client, initialState }) -```` -## useWorkflowSnapshot - -A wrapper for [`useSWR`](https://swr.vercel.app/), which fetches a workflow by ID, using the default SWR options. The workflow will refresh on visibility change (eg. waking from sleep), or when the classifier receives focus. - -```js -const workflowSnapshot = useWorkflowSnapshot(workflowID) -``` \ No newline at end of file diff --git a/packages/lib-classifier/src/components/Classifier/hooks/useHydratedStore.js b/packages/lib-classifier/src/components/Classifier/hooks/useHydratedStore.js deleted file mode 100644 index 5a777eea009..00000000000 --- a/packages/lib-classifier/src/components/Classifier/hooks/useHydratedStore.js +++ /dev/null @@ -1,33 +0,0 @@ -import { persist } from 'mst-persist' -import { useEffect, useState } from 'react' - -import { asyncSessionStorage } from '@helpers' - -async function hydrateStore(storageKey, classifierStore) { - try { - await persist(storageKey, classifierStore, { - storage: asyncSessionStorage, - whitelist: ['fieldGuide', 'projects', 'subjects', 'subjectSets', 'tutorials', 'workflows', 'workflowSteps'] - }) - console.log('store hydrated from local storage') - } catch (error) { - console.log('store snapshot error.') - console.error(error) - } -} - -export default function useHydratedStore(classifierStore, cachePanoptesData = false, storageKey) { - const [loaded, setLoaded] = useState(false) - - async function onMount() { - if (cachePanoptesData) { - await hydrateStore(storageKey, classifierStore) - } - setLoaded(true) - } - - useEffect(() => { - onMount() - }, []) - return loaded -} \ No newline at end of file diff --git a/packages/lib-classifier/src/components/Classifier/hooks/useStore.js b/packages/lib-classifier/src/components/Classifier/hooks/useStore.js deleted file mode 100644 index 9e745e50154..00000000000 --- a/packages/lib-classifier/src/components/Classifier/hooks/useStore.js +++ /dev/null @@ -1,25 +0,0 @@ -import makeInspectable from 'mobx-devtools-mst' -import { useMemo } from 'react' - -import RootStore from '@store' - -let store - -function initStore({ authClient, client, initialState }) { - if (!store) { - store = RootStore.create(initialState, { - authClient, - client - }) - makeInspectable(store) - } - return store -} -/** - useStore hook adapted from - https://github.com/vercel/next.js/blob/5201cdbaeaa72b54badc8f929ddc73c09f414dc4/examples/with-mobx-state-tree/store.js#L49-L52 -*/ -export default function useStore({ authClient, client, initialState }) { - const _store = useMemo(() => initStore({ authClient, client, initialState }), [authClient, initialState]) - return _store -} \ No newline at end of file diff --git a/packages/lib-classifier/src/helpers/asyncSessionStorage/asyncSessionStorage.js b/packages/lib-classifier/src/helpers/asyncSessionStorage/asyncSessionStorage.js deleted file mode 100644 index 743fba8b064..00000000000 --- a/packages/lib-classifier/src/helpers/asyncSessionStorage/asyncSessionStorage.js +++ /dev/null @@ -1,25 +0,0 @@ -function callWithPromise(func, ...args) { - try { - return Promise.resolve(func(...args)) - } catch (err) { - return Promise.reject(err) - } -} - -const asyncSessionStorage = { - // must use wrapper functions when passing localStorage functions (https://github.com/agilgur5/mst-persist/issues/18) - clear() { - return callWithPromise(() => window.sessionStorage.clear()) - }, - getItem(key) { - return callWithPromise(() => window.sessionStorage.getItem(key)) - }, - removeItem(key) { - return callWithPromise(() => window.sessionStorage.removeItem(key)) - }, - setItem (key, value) { - return callWithPromise(() => window.sessionStorage.setItem(key, value)) - } -} - -export default asyncSessionStorage diff --git a/packages/lib-classifier/src/helpers/asyncSessionStorage/index.js b/packages/lib-classifier/src/helpers/asyncSessionStorage/index.js deleted file mode 100644 index 5342eb4a869..00000000000 --- a/packages/lib-classifier/src/helpers/asyncSessionStorage/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './asyncSessionStorage' diff --git a/packages/lib-classifier/src/helpers/index.js b/packages/lib-classifier/src/helpers/index.js index 5199c426b96..a61f6076d3a 100644 --- a/packages/lib-classifier/src/helpers/index.js +++ b/packages/lib-classifier/src/helpers/index.js @@ -1,5 +1,4 @@ import * as featureDetection from './featureDetection' -export { default as asyncSessionStorage } from './asyncSessionStorage' export { default as createLocationCounts } from './createLocationCounts' export { featureDetection } export { default as findLocationsByMediaType } from './findLocationsByMediaType' diff --git a/packages/lib-classifier/src/hooks/Readme.md b/packages/lib-classifier/src/hooks/Readme.md new file mode 100644 index 00000000000..57f3574a04a --- /dev/null +++ b/packages/lib-classifier/src/hooks/Readme.md @@ -0,0 +1,19 @@ +# Classifier hooks + +## useHydratedStore + +Create a `mobx-state-tree` store from an optional stored [snapshot](https://mobx-state-tree.js.org/concepts/snapshots). Adds an `onSnapshot` handler to keep the stored snapshot updated when the store changes. + +Returns the new store when hydration is complete. Snapshots are stored in session storage, so that they don't persist across tabs or windows. + +```js +const classifierStore = useHydratedStore({ authClient, client }, cachePanoptesData = false, storageKey) +``` + +## useWorkflowSnapshot + +A wrapper for [`useSWR`](https://swr.vercel.app/), which fetches a workflow by ID, using the default SWR options. The workflow will refresh on visibility change (eg. waking from sleep), or when the classifier receives focus. + +```js +const workflowSnapshot = useWorkflowSnapshot(workflowID) +``` \ No newline at end of file diff --git a/packages/lib-classifier/src/components/Classifier/hooks/index.js b/packages/lib-classifier/src/hooks/index.js similarity index 73% rename from packages/lib-classifier/src/components/Classifier/hooks/index.js rename to packages/lib-classifier/src/hooks/index.js index 1bb1894dd08..6732a9fc0c8 100644 --- a/packages/lib-classifier/src/components/Classifier/hooks/index.js +++ b/packages/lib-classifier/src/hooks/index.js @@ -1,3 +1,2 @@ export { default as useHydratedStore } from './useHydratedStore' -export { default as useStore } from './useStore' export { default as useWorkflowSnapshot } from './useWorkflowSnapshot' diff --git a/packages/lib-classifier/src/hooks/useHydratedStore.js b/packages/lib-classifier/src/hooks/useHydratedStore.js new file mode 100644 index 00000000000..a305937beff --- /dev/null +++ b/packages/lib-classifier/src/hooks/useHydratedStore.js @@ -0,0 +1,51 @@ +import makeInspectable from 'mobx-devtools-mst' +import { addDisposer, onSnapshot } from 'mobx-state-tree' +import { useMemo } from 'react' + +import RootStore from '@store' + +function loadSnapshot(storageKey, storage) { + const data = storage.getItem(storageKey) + return JSON.parse(data) || {} +} + +function persist(storageKey, _store, storage) { + function _saveSnapshot(snapshot) { + const data = JSON.stringify(snapshot) + storage.setItem(storageKey, data) + } + const snapshotDisposer = onSnapshot(_store, _saveSnapshot) + return addDisposer(_store, snapshotDisposer) +} + +let store = null + +function initStore({ authClient, cachePanoptesData, client, storageKey }) { + if (store === null) { + let initialState = {} + + if (cachePanoptesData) { + initialState = loadSnapshot(storageKey, window.sessionStorage) + } + + store = RootStore.create(initialState, { + authClient, + client + }) + + if (cachePanoptesData) { + persist(storageKey, store, window.sessionStorage) + } + makeInspectable(store) + } + return store +} + +export function cleanStore() { + store = null +} + +export default function useHydratedStore({ authClient, client }, cachePanoptesData = false, storageKey) { + const _store = useMemo(() => initStore({ authClient, cachePanoptesData, client, storageKey }), [authClient, cachePanoptesData, client, storageKey]) + return _store +} \ No newline at end of file diff --git a/packages/lib-classifier/src/hooks/useHydratedStore.spec.js b/packages/lib-classifier/src/hooks/useHydratedStore.spec.js new file mode 100644 index 00000000000..39ed9e8d51d --- /dev/null +++ b/packages/lib-classifier/src/hooks/useHydratedStore.spec.js @@ -0,0 +1,98 @@ +import { applySnapshot, getSnapshot } from 'mobx-state-tree' +import React from 'react' +import { renderHook } from '@testing-library/react-hooks/pure' + +import mockStore from '@test/mockStore' +import RootStore from '@store/RootStore' +import { cleanStore } from './useHydratedStore' +import { useHydratedStore } from '.' + +describe('Hooks > useHydratedStore', function () { + describe('without an existing store', function () { + let store + + beforeEach(function () { + const { authClient, client } = mockStore() + const { result } = renderHook(() => useHydratedStore({ authClient, client }, false, 'test-key')) + store = result.current + }) + + afterEach(function () { + cleanStore() + }) + + it('should create a new store', function () { + const mockStore = getSnapshot(RootStore.create({})) + const snapshot = getSnapshot(store) + expect(snapshot).to.deep.equal(mockStore) + }) + }) + + describe('with an existing store', function () { + let store + let newStore + + beforeEach(function () { + const { authClient, client } = mockStore() + const { result: firstRun } = renderHook(() => useHydratedStore({ authClient, client }, false, 'test-key')) + store = firstRun.current + const { result: secondRun } = renderHook(() => useHydratedStore({ authClient, client }, false, 'test-key')) + newStore = secondRun.current + }) + + afterEach(function () { + cleanStore() + }) + + it('should use the existing store', function () { + expect(newStore).to.deep.equal(store) + }) + }) + + describe('with session storage enabled', function () { + let store + + beforeEach(function () { + const expectedStore = mockStore() + const { authClient, client } = expectedStore + const { result } = renderHook(() => useHydratedStore({ authClient, client }, true, 'test-key')) + store = result.current + const mockSnapshot = getSnapshot(expectedStore) + applySnapshot(store.projects, mockSnapshot.projects) + }) + + afterEach(function () { + cleanStore() + }) + + it('should save snapshots in session storage', function () { + const mockSnapshot = window.sessionStorage.getItem('test-key') + const snapshot = getSnapshot(store) + expect(JSON.stringify(snapshot)).to.equal(mockSnapshot) + }) + }) + + describe('with a saved snapshot', function () { + let store + let mockSnapshot + + beforeEach(function () { + const expectedStore = mockStore() + mockSnapshot = getSnapshot(expectedStore) + window.sessionStorage.setItem('test-key', JSON.stringify(mockSnapshot)) + + const { authClient, client } = expectedStore + const { result } = renderHook(() => useHydratedStore({ authClient, client }, true, 'test-key')) + store = result.current + }) + + afterEach(function () { + cleanStore() + }) + + it('should load the snapshot into the store', function () { + const snapshot = getSnapshot(store) + expect(snapshot.projects).to.deep.equal(mockSnapshot.projects) + }) + }) +}) \ No newline at end of file diff --git a/packages/lib-classifier/src/components/Classifier/hooks/useWorkflowSnapshot.js b/packages/lib-classifier/src/hooks/useWorkflowSnapshot.js similarity index 100% rename from packages/lib-classifier/src/components/Classifier/hooks/useWorkflowSnapshot.js rename to packages/lib-classifier/src/hooks/useWorkflowSnapshot.js diff --git a/packages/lib-classifier/webpack.dev.js b/packages/lib-classifier/webpack.dev.js index 59cad6bc3db..8175b37c05a 100644 --- a/packages/lib-classifier/webpack.dev.js +++ b/packages/lib-classifier/webpack.dev.js @@ -44,6 +44,7 @@ module.exports = { resolve: { alias: { '@components': path.resolve(__dirname, 'src/components'), + '@hooks': path.resolve(__dirname, 'src/hooks'), '@helpers': path.resolve(__dirname, 'src/helpers'), '@plugins': path.resolve(__dirname, 'src/plugins'), '@store': path.resolve(__dirname, 'src/store'), diff --git a/packages/lib-classifier/webpack.dist.js b/packages/lib-classifier/webpack.dist.js index 6cdc9f06f5f..e15be110b22 100644 --- a/packages/lib-classifier/webpack.dist.js +++ b/packages/lib-classifier/webpack.dist.js @@ -37,6 +37,7 @@ module.exports = { resolve: { alias: { '@components': path.resolve(__dirname, 'src/components'), + '@hooks': path.resolve(__dirname, 'src/hooks'), '@helpers': path.resolve(__dirname, 'src/helpers'), '@plugins': path.resolve(__dirname, 'src/plugins'), '@store': path.resolve(__dirname, 'src/store'), diff --git a/yarn.lock b/yarn.lock index 00e8386607e..386e192ed73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12044,11 +12044,6 @@ mst-middlewares@~5.1.0: resolved "https://registry.yarnpkg.com/mst-middlewares/-/mst-middlewares-5.1.3.tgz#059a2cf4e44cdb44ae92eb4a4e8ce47f6c117c66" integrity sha512-zVPxLO6kQFMTU0S7xYoyiSml8nUP5Ekto3CY4Ra7cGwbuLOwIDBgCyNIEEtLpq95KFbhAEl9oIchg4RW13iCZg== -mst-persist@~0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/mst-persist/-/mst-persist-0.1.3.tgz#ac7203f3ab36085106c4293d4a9cd67dd5613708" - integrity sha512-G2IzmTvi2E8zOTTCiMENkli+KmANhpPNlkguOEJp0dioqfdETDQjLO6QKy5hXhDTRIQhu48IlDJeNdyeZ8NIaw== - multicast-dns-service-types@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901"