diff --git a/packages/lib-classifier/package.json b/packages/lib-classifier/package.json index 98fd10b74a..0e8d20bb3c 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 9434e36e0c..0a784ad711 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 storeEnvironment = { authClient, client } const workflowSnapshot = useWorkflowSnapshot(workflowID) - const loaded = useHydratedStore(classifierStore, cachePanoptesData, `fem-classifier-${project.id}`) + const classifierStore = useHydratedStore(storeEnvironment, 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 4becd0c544..0000000000 --- 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 5a777eea00..0000000000 --- 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 9e745e5015..0000000000 --- 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 743fba8b06..0000000000 --- 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 5342eb4a86..0000000000 --- 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 5199c426b9..a61f6076d3 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 0000000000..57f3574a04 --- /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 1bb1894dd0..6732a9fc0c 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 0000000000..a185d26a13 --- /dev/null +++ b/packages/lib-classifier/src/hooks/useHydratedStore.js @@ -0,0 +1,50 @@ +import makeInspectable from 'mobx-devtools-mst' +import { addDisposer, destroy, onSnapshot } from 'mobx-state-tree' +import { useMemo } from 'react' + +import RootStore from '@store' + +let store = null +const storage = window.sessionStorage + +function loadSnapshot(storageKey) { + const data = storage.getItem(storageKey) + return JSON.parse(data) || {} +} + +function persist(storageKey, _store) { + function _saveSnapshot(snapshot) { + const data = JSON.stringify(snapshot) + storage.setItem(storageKey, data) + } + const snapshotDisposer = onSnapshot(_store, _saveSnapshot) + return addDisposer(_store, snapshotDisposer) +} + +function initStore({ cachePanoptesData, storageKey, storeEnv }) { + if (store === null) { + let initialState = {} + + if (cachePanoptesData) { + initialState = loadSnapshot(storageKey) + } + + store = RootStore.create(initialState, storeEnv) + + if (cachePanoptesData) { + persist(storageKey, store) + } + makeInspectable(store) + } + return store +} + +export function cleanStore() { + destroy(store) + store = null +} + +export default function useHydratedStore(storeEnv = {}, cachePanoptesData = false, storageKey) { + const _store = useMemo(() => initStore({ cachePanoptesData, storageKey, storeEnv }), [cachePanoptesData, storageKey, storeEnv]) + 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 0000000000..39ed9e8d51 --- /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 59cad6bc3d..8175b37c05 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 6cdc9f06f5..e15be110b2 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 00e8386607..386e192ed7 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"