diff --git a/desktop/app/src/chrome/mainsidebar/MainSidebar2.tsx b/desktop/app/src/chrome/mainsidebar/MainSidebar2.tsx index cddb2c0a3..2d598812d 100644 --- a/desktop/app/src/chrome/mainsidebar/MainSidebar2.tsx +++ b/desktop/app/src/chrome/mainsidebar/MainSidebar2.tsx @@ -35,6 +35,7 @@ import React, { useCallback, useState, useEffect, + useRef, } from 'react'; import NotificationScreen from '../NotificationScreen'; import { @@ -60,6 +61,7 @@ import { getColorByApp, getFavoritePlugins, } from './sidebarUtils'; +import {useLocalStorage} from '../../utils/useLocalStorage'; type FlipperPlugins = typeof FlipperPlugin[]; type PluginsByCategory = [string, FlipperPlugins][]; @@ -122,15 +124,21 @@ const SidebarSection: React.FC<{ title: string | React.ReactNode | ((collapsed: boolean) => React.ReactNode); level: SectionLevel; color?: string; -}> = ({children, title, level, color, defaultCollapsed}) => { - const [collapsed, setCollapsed] = useState(!!defaultCollapsed); + storageKey: string; +}> = ({children, title, level, color, defaultCollapsed, storageKey}) => { + const hasMounted = useRef(false); + const [collapsed, setCollapsed] = useLocalStorage( + storageKey, + !!defaultCollapsed, + ); color = color || colors.macOSTitleBarIconActive; useEffect(() => { - // if default collapsed changed to false, propagate that - if (!defaultCollapsed && collapsed) { + // if default collapsed changed to false after mounting, propagate that + if (hasMounted.current && !defaultCollapsed && collapsed) { setCollapsed(!collapsed); } + hasMounted.current = true; }, [defaultCollapsed]); return ( @@ -259,6 +267,7 @@ class MainSidebar2 extends PureComponent { {this.showArchivedDeviceDetails(device)} @@ -266,6 +275,7 @@ class MainSidebar2 extends PureComponent { {devicePluginsItems} @@ -292,10 +302,15 @@ class MainSidebar2 extends PureComponent { renderUnitializedClients() { const {uninitializedClients} = this.props; return uninitializedClients.length > 0 ? ( - + {uninitializedClients.map((entry) => ( @@ -502,6 +517,7 @@ const PluginList = memo(function PluginList({ level={2} key={client.id} title={client.query.app} + storageKey={`${device.serial}:${client.query.app}`} color={getColorByApp(client.query.app)}> {favoritePlugins.length === 0 ? ( @@ -523,6 +539,7 @@ const PluginList = memo(function PluginList({ 0 && !selectedNonFavoritePlugin } diff --git a/desktop/app/src/index.tsx b/desktop/app/src/index.tsx index 05c29d0dc..c03c6b74b 100644 --- a/desktop/app/src/index.tsx +++ b/desktop/app/src/index.tsx @@ -188,3 +188,4 @@ export {getFlipperMediaCDN} from './fb-stubs/user'; export {Rect} from './utils/geometry'; export {Logger} from './fb-interfaces/Logger'; export {callVSCode, getVSCodeUrl} from './utils/vscodeUtils'; +export {useLocalStorage} from './utils/useLocalStorage'; diff --git a/desktop/app/src/utils/__tests__/useLocalStorage.node.tsx b/desktop/app/src/utils/__tests__/useLocalStorage.node.tsx new file mode 100644 index 000000000..79de77e2e --- /dev/null +++ b/desktop/app/src/utils/__tests__/useLocalStorage.node.tsx @@ -0,0 +1,107 @@ +/** + * 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 * as React from 'react'; +import {render, fireEvent, act} from '@testing-library/react'; + +import {useLocalStorage} from '../useLocalStorage'; + +function TestComponent({ + storageKey, + value, +}: { + storageKey: string; + value: number; +}) { + const [current, setCurrent] = useLocalStorage(storageKey, value); + + return ( +
+
{current}
+ +
+ ); +} + +let getSpy: jest.SpyInstance; +let setSpy: jest.SpyInstance; +let storage: Record = {}; + +beforeEach(() => { + storage = {}; + getSpy = jest + .spyOn(Storage.prototype, 'getItem') // https://github.com/facebook/jest/issues/6798#issuecomment-412871616 + .mockImplementation((key: string) => { + return storage[key]; + }); + setSpy = jest + .spyOn(Storage.prototype, 'setItem') + .mockImplementation((key: string, value: any) => { + storage[key] = value; + }); +}); + +afterEach(() => { + getSpy.mockRestore(); + setSpy.mockRestore(); +}); + +test('it can store values', async () => { + const res = render(); + expect((await res.findByTestId('value')).textContent).toEqual('1'); + + await act(async () => { + fireEvent.click(await res.findByTestId('inc')); + }); + + expect((await res.findByTestId('value')).textContent).toEqual('2'); + expect(storage).toMatchInlineSnapshot(` + Object { + "[useLocalStorage]x": "2", + } + `); +}); + +test('it can read default from storage', async () => { + storage['[useLocalStorage]x'] = '3'; + const res = render(); + expect((await res.findByTestId('value')).textContent).toEqual('3'); + + await act(async () => { + fireEvent.click(await res.findByTestId('inc')); + }); + + expect((await res.findByTestId('value')).textContent).toEqual('4'); + expect(storage).toMatchInlineSnapshot(` + Object { + "[useLocalStorage]x": "4", + } + `); +}); + +test('it does not allow changing key', async () => { + const res = render(); + + expect(() => { + const orig = console.error; + try { + // supress error in console + console.error = jest.fn(); + res.rerender(); + } finally { + console.error = orig; + } + }).toThrowErrorMatchingInlineSnapshot( + `"The key passed to useLocalStorage should not be changed, 'x' -> 'y'"`, + ); +}); diff --git a/desktop/app/src/utils/useLocalStorage.tsx b/desktop/app/src/utils/useLocalStorage.tsx new file mode 100644 index 000000000..45259c41a --- /dev/null +++ b/desktop/app/src/utils/useLocalStorage.tsx @@ -0,0 +1,62 @@ +/** + * 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 {useState, useCallback} from 'react'; + +export function useLocalStorage( + key: string, + initialValue: (() => T) | T, +): [T, (newState: T | ((current: T) => T)) => void] { + const [storedKey] = useState(key); + if (storedKey !== key) { + throw new Error( + `The key passed to useLocalStorage should not be changed, '${storedKey}' -> '${key}'`, + ); + } + // Based on https://usehooks.com/useLocalStorage/ (with minor adaptions) + + // State to store our value + // Pass initial state function to useState so logic is only executed once + const [storedValue, setStoredValue] = useState(() => { + try { + // Get from local storage by key + const item = window.localStorage.getItem('[useLocalStorage]' + key); + // Parse stored json or if none return initialValue + return item + ? JSON.parse(item) + : typeof initialValue === 'function' + ? (initialValue as any)() + : initialValue; + } catch (error) { + // If error also return initialValue + console.log(error); + return initialValue; + } + }); + + // Return a wrapped version of useState's setter function that ... + // ... persists the new value to localStorage. + const setValue = useCallback( + (value) => { + setStoredValue((storedValue) => { + const nextValue = + typeof value === 'function' ? value(storedValue) : value; + // Save to local storage + window.localStorage.setItem( + '[useLocalStorage]' + key, + JSON.stringify(nextValue), + ); + return nextValue; + }); + }, + [key], + ); + + return [storedValue, setValue]; +}