diff --git a/src/components/MangaGrid.tsx b/src/components/MangaGrid.tsx index 91f501d8..b21de5be 100644 --- a/src/components/MangaGrid.tsx +++ b/src/components/MangaGrid.tsx @@ -10,15 +10,16 @@ import React, { ForwardedRef, forwardRef, useEffect, useLayoutEffect, useMemo, u import Grid, { GridTypeMap } from '@mui/material/Grid'; import { Box, Typography } from '@mui/material'; import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { EmptyView } from '@/components/util/EmptyView'; import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder'; import { MangaCard, MangaCardProps } from '@/components/MangaCard'; import { GridLayout } from '@/components/context/LibraryOptionsContext'; -import { useLocalStorage } from '@/util/useLocalStorage'; +import { useLocalStorage, useSessionStorage } from '@/util/useStorage.tsx'; import { TManga, TPartialManga } from '@/typings.ts'; import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts'; import { DEFAULT_FULL_FAB_HEIGHT } from '@/components/util/StyledFab.tsx'; +import { AppStorage } from '@/util/AppStorage.ts'; const GridContainer = React.forwardRef(({ children, ...props }, ref) => ( @@ -144,8 +145,9 @@ const VerticalGrid = forwardRef( ref: ForwardedRef, ) => { const location = useLocation<{ snapshot?: GridStateSnapshot }>(); - const navigate = useNavigate(); - const { snapshot } = location.state ?? {}; + + const snapshotSessionKey = `MangaGrid-snapshot-location-${location.key}`; + const [snapshot] = useSessionStorage(snapshotSessionKey, undefined); const persistGridStateTimeout = useRef(); const persistGridState = (gridState: GridStateSnapshot) => { @@ -158,10 +160,7 @@ const VerticalGrid = forwardRef( return; } - navigate( - { pathname: '', search: location.search }, - { replace: true, state: { ...location.state, snapshot: gridState } }, - ); + AppStorage.session.setItem(snapshotSessionKey, gridState); }, 250); }; useEffect(() => clearTimeout(persistGridStateTimeout.current), [location.key, persistGridStateTimeout.current]); diff --git a/src/components/chapter/util.tsx b/src/components/chapter/util.tsx index 70f43400..d883baf9 100644 --- a/src/components/chapter/util.tsx +++ b/src/components/chapter/util.tsx @@ -15,7 +15,7 @@ import { TChapter, TranslationKey, } from '@/typings.ts'; -import { useReducerLocalStorage } from '@/util/useLocalStorage.tsx'; +import { useReducerLocalStorage } from '@/util/useStorage.tsx'; const defaultChapterOptions: ChapterListOptions = { active: false, diff --git a/src/components/context/AppContext.tsx b/src/components/context/AppContext.tsx index 0a713223..9e596618 100644 --- a/src/components/context/AppContext.tsx +++ b/src/components/context/AppContext.tsx @@ -12,7 +12,7 @@ import { BrowserRouter as Router } from 'react-router-dom'; import { QueryParamProvider } from 'use-query-params'; import { ReactRouter6Adapter } from 'use-query-params/adapters/react-router-6'; import { createTheme } from '@/theme'; -import { useLocalStorage } from '@/util/useLocalStorage'; +import { useLocalStorage } from '@/util/useStorage.tsx'; import { DarkTheme } from '@/components/context/DarkTheme'; import { NavBarContextProvider } from '@/components/navbar/NavBarContextProvider'; import { LibraryOptionsContextProvider } from '@/components/library/LibraryOptionsProvider'; diff --git a/src/components/library/LibraryOptionsProvider.tsx b/src/components/library/LibraryOptionsProvider.tsx index a3da1567..37dd4208 100644 --- a/src/components/library/LibraryOptionsProvider.tsx +++ b/src/components/library/LibraryOptionsProvider.tsx @@ -8,7 +8,7 @@ import React, { useMemo } from 'react'; import { LibraryOptions } from '@/typings'; -import { useLocalStorage } from '@/util/useLocalStorage'; +import { useLocalStorage } from '@/util/useStorage.tsx'; import { LibraryOptionsContext, DefaultLibraryOptions } from '@/components/context/LibraryOptionsContext'; interface IProps { diff --git a/src/components/settings/ServerUpdateChecker.tsx b/src/components/settings/ServerUpdateChecker.tsx index e5b78c48..359f57ab 100644 --- a/src/components/settings/ServerUpdateChecker.tsx +++ b/src/components/settings/ServerUpdateChecker.tsx @@ -16,7 +16,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { getVersion } from '@/screens/settings/About.tsx'; -import { useLocalStorage } from '@/util/useLocalStorage.tsx'; +import { useLocalStorage } from '@/util/useStorage.tsx'; import { defaultPromiseErrorHandler } from '@/util/defaultPromiseErrorHandler.ts'; const UPDATE_CHECK_INTERVAL = 1000 * 60 * 60 * 24; // 1 day diff --git a/src/components/util/AppbarSearch.tsx b/src/components/util/AppbarSearch.tsx index 60d9e4ad..dca875d6 100644 --- a/src/components/util/AppbarSearch.tsx +++ b/src/components/util/AppbarSearch.tsx @@ -11,35 +11,44 @@ import SearchIcon from '@mui/icons-material/Search'; import { IconButton, Tooltip } from '@mui/material'; import { useQueryParam, StringParam } from 'use-query-params'; import { useTranslation } from 'react-i18next'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { SearchTextField } from '@/components/atoms/SearchTextField.tsx'; +import { useSessionStorage } from '@/util/useStorage.tsx'; interface IProps { - autoOpen?: boolean; + isClosable?: boolean; } -const defaultProps = { - autoOpen: false, -}; - export const AppbarSearch: React.FunctionComponent = (props) => { - const { autoOpen } = props; + const { isClosable = true } = props; const { t } = useTranslation(); - const { pathname, search: locationSearch, state: fullLocationState } = useLocation<{ wasSearchOpen?: boolean }>(); - const { wasSearchOpen, ...locationState } = fullLocationState ?? {}; - - const navigate = useNavigate(); + const [prevLocationKey, setPrevLocationKey] = useState(); + const location = useLocation(); const [query, setQuery] = useQueryParam('query', StringParam); - const [searchOpen, setSearchOpen] = useState(!!query); + const [isSearchOpen, setIsSearchOpen] = useState(!isClosable || !!query); const inputRef = React.useRef(); const [searchString, setSearchString] = useState(query ?? ''); + const [locationQuery, setLocationQuery] = useSessionStorage(`appbarsearch-location-${location.key}`); + if (prevLocationKey !== location.key) { + setPrevLocationKey(location.key); + setLocationQuery(query); + setSearchString(locationQuery ?? ''); + setIsSearchOpen(!isClosable || !!locationQuery); + } + + const isOpen = isSearchOpen || !!locationQuery; + const updateSearchOpenState = (open: boolean) => { - setSearchOpen(open); + if (!isClosable) { + return; + } + + setIsSearchOpen(open); // try to focus input component since in case of navigating to the previous/next page in the browser history // the "openSearch" state might not change and thus, won't trigger a focus @@ -54,6 +63,7 @@ export const AppbarSearch: React.FunctionComponent = (props) => { } setQuery(newQuery); + updateSearchOpenState(false); } const cancelSearch = () => { @@ -72,46 +82,6 @@ export const AppbarSearch: React.FunctionComponent = (props) => { } }; - useEffect(() => { - if ((autoOpen && wasSearchOpen === undefined) || (wasSearchOpen && query)) { - updateSearchOpenState(true); - return; - } - - updateSearchOpenState(false); - }, [autoOpen, pathname]); - - useEffect(() => { - if (!searchOpen || !inputRef.current) { - return; - } - - inputRef.current.focus(); - }, [searchOpen, inputRef.current]); - - useEffect(() => { - if (wasSearchOpen === searchOpen) { - return; - } - - navigate( - { pathname, search: locationSearch }, - { replace: true, state: { ...locationState, wasSearchOpen: searchOpen } }, - ); - }, [searchOpen]); - - useEffect(() => { - if (query === undefined && searchString !== undefined) { - setSearchString(''); - return; - } - - if (query && searchString !== query) { - setSearchString(query); - updateSearchOpenState(true); - } - }, [query]); - useEffect(() => { window.addEventListener('keydown', handleKeyboardEvent); @@ -120,9 +90,10 @@ export const AppbarSearch: React.FunctionComponent = (props) => { }; }, [handleKeyboardEvent]); - if (searchOpen) { + if (isOpen) { return ( = (props) => { ); }; - -AppbarSearch.defaultProps = defaultProps; diff --git a/src/lib/requests/client/BaseClient.ts b/src/lib/requests/client/BaseClient.ts index 35f63df4..3f25667e 100644 --- a/src/lib/requests/client/BaseClient.ts +++ b/src/lib/requests/client/BaseClient.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import * as storage from '@/util/localStorage.tsx'; +import { AppStorage } from '@/util/AppStorage.ts'; export abstract class BaseClient { protected client!: Client; @@ -22,7 +22,7 @@ export abstract class BaseClient { // if port is 3000 it's probably running from webpack development server const inferredPort = port === '3000' ? '4567' : port; - return storage.getItem('serverBaseURL', `${protocol}//${hostname}:${inferredPort}`); + return AppStorage.local.getItemParsed('serverBaseURL', `${protocol}//${hostname}:${inferredPort}`); } protected abstract createClient(): void; diff --git a/src/screens/Extensions.tsx b/src/screens/Extensions.tsx index 1bca77f3..2e211ca6 100644 --- a/src/screens/Extensions.tsx +++ b/src/screens/Extensions.tsx @@ -17,7 +17,7 @@ import { useTheme } from '@mui/material/styles'; import { Link } from 'react-router-dom'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { extensionDefaultLangs, DefaultLanguage, langSortCmp } from '@/util/language'; -import { useLocalStorage } from '@/util/useLocalStorage'; +import { useLocalStorage } from '@/util/useStorage.tsx'; import { ExtensionState, GroupedExtensions, diff --git a/src/screens/Migrate.tsx b/src/screens/Migrate.tsx index 957ad757..cc6f857e 100644 --- a/src/screens/Migrate.tsx +++ b/src/screens/Migrate.tsx @@ -18,7 +18,7 @@ import { EmptyView } from '@/components/util/EmptyView.tsx'; import { MangaGrid } from '@/components/MangaGrid.tsx'; import { TPartialManga } from '@/typings.ts'; import { GridLayouts } from '@/components/source/GridLayouts.tsx'; -import { useLocalStorage } from '@/util/useLocalStorage.tsx'; +import { useLocalStorage } from '@/util/useStorage.tsx'; import { GridLayout } from '@/components/context/LibraryOptionsContext.tsx'; export const Migrate = () => { diff --git a/src/screens/SearchAll.tsx b/src/screens/SearchAll.tsx index 56b9079c..32d705eb 100644 --- a/src/screens/SearchAll.tsx +++ b/src/screens/SearchAll.tsx @@ -13,7 +13,7 @@ import { StringParam, useQueryParam } from 'use-query-params'; import { useTranslation } from 'react-i18next'; import { ISource } from '@/typings'; import { requestManager } from '@/lib/requests/RequestManager.ts'; -import { useLocalStorage } from '@/util/useLocalStorage'; +import { useLocalStorage } from '@/util/useStorage.tsx'; import { langSortCmp, sourceDefualtLangs, sourceForcedDefaultLangs } from '@/util/language'; import { translateExtensionLanguage } from '@/screens/util/Extensions'; import { AppbarSearch } from '@/components/util/AppbarSearch'; @@ -214,7 +214,7 @@ export const SearchAll: React.FC = () => { setTitle(t(isMigrateMode ? 'migrate.search.title' : 'search.title.global_search', { title: mangaTitle })); setAction( <> - + ({ display: 'flex', @@ -215,17 +215,12 @@ export function SourceMangas() { const { sourceId } = useParams<{ sourceId: string }>(); const navigate = useNavigate(); - const { search } = useLocation(); - const { - contentType: currentLocationContentType = SourceContentType.POPULAR, - filtersToApply: currentLocationFiltersToApply = [], - clearCache = false, - } = useLocation<{ - contentType: SourceContentType; - filtersToApply: IPos[]; - clearCache: boolean; - search: string; - }>().state ?? {}; + const { key: locationKey, state: locationState } = useLocation(); + const { contentType: initialContentType = SourceContentType.POPULAR, clearCache = false } = + useLocation<{ + contentType: SourceContentType; + clearCache: boolean; + }>().state ?? {}; useSetDefaultBackTo('sources'); @@ -241,13 +236,19 @@ export function SourceMangas() { const { options } = useLibraryOptionsContext(); const [query] = useQueryParam('query', StringParam); - const [dialogFiltersToApply, setDialogFiltersToApply] = useState(currentLocationFiltersToApply); - const [filtersToApply, setFiltersToApply] = useState(currentLocationFiltersToApply); - const searchTerm = useDebounce(query, 1000); + const [filtersToApply, setFiltersToApply] = useSessionStorage( + `source-mangas-location-${locationKey}-${sourceId}-filters`, + [], + ); + const [dialogFiltersToApply, setDialogFiltersToApply] = useState(filtersToApply); const [resetScrollPosition, setResetScrollPosition] = useState(false); - const [contentType, setContentType] = useState(currentLocationContentType); + const [contentType, setContentType] = useSessionStorage( + `source-mangas-location-${locationKey}-${sourceId}-content-type`, + query ? SourceContentType.SEARCH : initialContentType, + ); + const [loadPage, { data, isLoading: loading, size: lastPageNum, abortRequest, filteredOutAllItemsOfFetchedPage }] = - useSourceManga(sourceId, contentType, searchTerm, filtersToApply, 1, hideLibraryEntries); + useSourceManga(sourceId, contentType, query, filtersToApply, 1, hideLibraryEntries); const isLoading = loading || filteredOutAllItemsOfFetchedPage; const mangas = data?.fetchSourceManga.mangas ?? []; const hasNextPage = data?.fetchSourceManga.hasNextPage ?? false; @@ -268,50 +269,36 @@ export function SourceMangas() { ) : undefined; const updateContentType = useCallback( - ( - newContentType: SourceContentType, - { updateLocationState = true, search: newSearch }: { updateLocationState?: boolean; search?: string } = {}, - ) => { - if (updateLocationState) { + (newContentType: SourceContentType, newSearch?: string | null) => { + setContentType(newContentType); + setResetScrollPosition(true); + + if (query && !newSearch) { navigate( - { pathname: '', search: newSearch }, { - replace: true, - state: { - contentType: newContentType, - }, + pathname: '', + }, + { + state: { ...locationState, contentType: newContentType }, }, ); } - - setContentType(newContentType); - setResetScrollPosition(true); }, - [setContentType], + [setContentType, query], ); const updateLocationFilters = useCallback( (updatedFilters: IPos[]) => { if (contentType === SourceContentType.SEARCH) { - navigate( - { pathname: '', search }, - { - replace: true, - state: { - contentType, - filtersToApply: updatedFilters, - }, - }, - ); + setFiltersToApply(updatedFilters); } }, - [contentType, search], + [contentType, query], ); - const isSearchTermAvailable = searchTerm && query?.length; - const setSearchContentType = isSearchTermAvailable && contentType !== SourceContentType.SEARCH; + const setSearchContentType = !!query && contentType !== SourceContentType.SEARCH; if (setSearchContentType) { - updateContentType(SourceContentType.SEARCH, { search }); + updateContentType(SourceContentType.SEARCH, query); } const loadMore = useCallback(() => { @@ -346,10 +333,6 @@ export function SourceMangas() { } requestManager.clearBrowseCacheFor(sourceId); - navigate('', { - replace: true, - state: { contentType: currentLocationContentType, filters: currentLocationFiltersToApply }, - }); }, [clearCache]); useEffect( @@ -363,7 +346,7 @@ export function SourceMangas() { abortRequest(new Error(`SourceMangas(${sourceId}): search string changed`)); setResetScrollPosition(true); }, - [searchTerm], + [query], ); useEffect(() => { @@ -426,7 +409,7 @@ export function SourceMangas() { } - onClick={() => updateContentType(SourceContentType.SEARCH)} + onClick={() => updateContentType(SourceContentType.SEARCH, query)} > {t('global.button.filter')} diff --git a/src/screens/Sources.tsx b/src/screens/Sources.tsx index 5ac22b13..e515596e 100644 --- a/src/screens/Sources.tsx +++ b/src/screens/Sources.tsx @@ -13,7 +13,7 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { ISource, TPartialSource } from '@/typings'; import { requestManager } from '@/lib/requests/RequestManager.ts'; -import { useLocalStorage } from '@/util/useLocalStorage'; +import { useLocalStorage } from '@/util/useStorage.tsx'; import { sourceDefualtLangs, sourceForcedDefaultLangs, langSortCmp } from '@/util/language'; import { translateExtensionLanguage } from '@/screens/util/Extensions'; import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder'; diff --git a/src/screens/settings/BrowseSettings.tsx b/src/screens/settings/BrowseSettings.tsx index bcbbb7da..95b0cdd9 100644 --- a/src/screens/settings/BrowseSettings.tsx +++ b/src/screens/settings/BrowseSettings.tsx @@ -18,7 +18,7 @@ import { NumberSetting } from '@/components/settings/NumberSetting.tsx'; import { MutableListSetting } from '@/components/settings/MutableListSetting.tsx'; import { MetadataBrowseSettings, ServerSettings as GqlServerSettings } from '@/typings.ts'; import { TextSetting } from '@/components/settings/text/TextSetting.tsx'; -import { useLocalStorage } from '@/util/useLocalStorage.tsx'; +import { useLocalStorage } from '@/util/useStorage.tsx'; import { createUpdateMetadataServerSettings, useMetadataServerSettings, diff --git a/src/screens/settings/ServerSettings.tsx b/src/screens/settings/ServerSettings.tsx index dd0a4e21..19e8ee9f 100644 --- a/src/screens/settings/ServerSettings.tsx +++ b/src/screens/settings/ServerSettings.tsx @@ -12,7 +12,7 @@ import { Link, List, ListItem, ListItemText, Switch } from '@mui/material'; import ListSubheader from '@mui/material/ListSubheader'; import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx'; import { requestManager } from '@/lib/requests/RequestManager.ts'; -import { useLocalStorage } from '@/util/useLocalStorage.tsx'; +import { useLocalStorage } from '@/util/useStorage.tsx'; import { TextSetting } from '@/components/settings/text/TextSetting.tsx'; import { ServerSettings as GqlServerSettings } from '@/typings.ts'; import { NumberSetting } from '@/components/settings/NumberSetting.tsx'; diff --git a/src/util/AppStorage.ts b/src/util/AppStorage.ts new file mode 100644 index 00000000..e5088201 --- /dev/null +++ b/src/util/AppStorage.ts @@ -0,0 +1,51 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +// eslint-disable-next-line max-classes-per-file +export class Storage { + constructor(private readonly storage: typeof window.localStorage) {} + + parseValue(value: string | null, defaultValue: T): T { + if (value === null) { + return defaultValue; + } + + return JSON.parse(value); + } + + getItem(key: string): string | null { + return this.storage.getItem(key); + } + + getItemParsed(key: string, defaultValue: T): T { + return this.parseValue(this.getItem(key), defaultValue); + } + + setItem(key: string, value: unknown): void { + if (value === undefined) { + return; + } + + const valueToStore = JSON.stringify(value); + + this.storage.setItem(key, valueToStore); + window.dispatchEvent( + new StorageEvent('storage', { + key, + oldValue: this.getItem(key), + newValue: valueToStore, + }), + ); + } +} + +export class AppStorage { + static readonly local: Storage = new Storage(window.localStorage); + + static readonly session: Storage = new Storage(window.sessionStorage); +} diff --git a/src/util/localStorage.tsx b/src/util/localStorage.tsx deleted file mode 100644 index 89e9a3dc..00000000 --- a/src/util/localStorage.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (C) Contributors to the Suwayomi project - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -export function getItem(key: string, defaultValue: T): T { - const item = window.localStorage.getItem(key); - - if (item !== null) { - return JSON.parse(item); - } - - window.localStorage.setItem(key, JSON.stringify(defaultValue)); - return defaultValue; -} - -export function setItem(key: string, value: T): void { - window.localStorage.setItem(key, JSON.stringify(value)); -} diff --git a/src/util/useLocalStorage.tsx b/src/util/useLocalStorage.tsx deleted file mode 100644 index 348fc9da..00000000 --- a/src/util/useLocalStorage.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (C) Contributors to the Suwayomi project - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -import { useState, Dispatch, SetStateAction, useReducer, Reducer, useCallback } from 'react'; -import * as storage from '@/util/localStorage'; - -export function useLocalStorage(key: string, defaultValue: T | (() => T)): [T, Dispatch>] { - const initialState = defaultValue instanceof Function ? defaultValue() : defaultValue; - const [storedValue, setStoredValue] = useState(storage.getItem(key, initialState)); - - const setValue = useCallback>>( - (value) => { - setStoredValue((prevValue) => { - // Allow value to be a function so we have same API as useState - const valueToStore = value instanceof Function ? value(prevValue) : value; - storage.setItem(key, valueToStore); - return valueToStore; - }); - }, - [key], - ); - - return [storedValue, setValue]; -} - -export function useReducerLocalStorage(reducer: Reducer, key: string, defaultState: S | (() => S)) { - const [storedValue, setValue] = useLocalStorage(key, defaultState); - return useReducer((state: S, action: A): S => { - const newState = reducer(state, action); - setValue(newState); - return newState; - }, storedValue); -} diff --git a/src/util/usePersistedValue.tsx b/src/util/usePersistedValue.tsx index 9d876278..4c96eb01 100644 --- a/src/util/usePersistedValue.tsx +++ b/src/util/usePersistedValue.tsx @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { useLocalStorage } from '@/util/useLocalStorage.tsx'; +import { useLocalStorage } from '@/util/useStorage.tsx'; export const getPersistedServerSetting = (serverValue: T | undefined, lastValue: T): T => { const isDisabled = serverValue === 0; diff --git a/src/util/useStorage.tsx b/src/util/useStorage.tsx new file mode 100644 index 00000000..3dae3944 --- /dev/null +++ b/src/util/useStorage.tsx @@ -0,0 +1,88 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { Dispatch, Reducer, SetStateAction, useCallback, useMemo, useReducer, useSyncExternalStore } from 'react'; +import { AppStorage, Storage } from '@/util/AppStorage.ts'; + +const subscribeToStorageUpdates = (callback: () => void) => { + window.addEventListener('storage', callback); + return () => window.removeEventListener('storage', callback); +}; + +function useStorage(storage: Storage, key: string, defaultValue: T | (() => T)): [T, Dispatch>]; +function useStorage( + storage: Storage, + key: string, +): [T | undefined, Dispatch>]; + +function useStorage( + storage: Storage, + key: string, + defaultValue?: T | (() => T) | undefined, +): [T | undefined, Dispatch>] { + const initialState = defaultValue instanceof Function ? defaultValue() : defaultValue; + const storedValueRaw = useSyncExternalStore(subscribeToStorageUpdates, () => storage.getItem(key)); + + const setValue = useCallback>>( + (value) => { + // Allow value to be a function so we have same API as useState + const valueToStore = value instanceof Function ? value(storage.getItemParsed(key, initialState)) : value; + storage.setItem(key, valueToStore); + }, + [key], + ); + + const storedValue = useMemo( + () => (storedValueRaw !== null ? JSON.parse(storedValueRaw) : initialState), + [storedValueRaw, key], + ); + + return [storedValue, setValue]; +} + +const useReducerStorage = ( + storage: Storage, + reducer: Reducer, + key: string, + defaultState: S | (() => S), +) => { + const [storedValue, setValue] = useStorage(storage, key, defaultState); + return useReducer((state: S, action: A): S => { + const newState = reducer(state, action); + setValue(newState); + return newState; + }, storedValue); +}; + +export function useLocalStorage(key: string, defaultValue: T | (() => T)): [T, Dispatch>]; +export function useLocalStorage(key: string): [T | undefined, Dispatch>]; + +export function useLocalStorage( + key: string, + defaultValue?: T | undefined | (() => T | undefined), +): [T | undefined, Dispatch>] { + return useStorage(AppStorage.local, key, defaultValue); +} + +export function useReducerLocalStorage(reducer: Reducer, key: string, defaultState: S | (() => S)) { + return useReducerStorage(AppStorage.local, reducer, key, defaultState); +} + +export function useSessionStorage(key: string, defaultValue: T | (() => T)): [T, Dispatch>]; +export function useSessionStorage(key: string): [T | undefined, Dispatch>]; + +export function useSessionStorage( + key: string, + defaultValue?: T | (() => T), +): [T | undefined, Dispatch>] { + return useStorage(AppStorage.session, key, defaultValue); +} + +export function useReducerSessionStorage(reducer: Reducer, key: string, defaultState: S | (() => S)) { + return useReducerStorage(AppStorage.session, reducer, key, defaultState); +}