diff --git a/rollup.config.ts b/rollup.config.ts index 4e88b3f5..70f8fc18 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -37,15 +37,21 @@ export default async function () { output: [{ dir: 'dist', format: 'commonjs', - entryFileNames: '[name]/index.cjs' + entryFileNames: '[name]/index.cjs', + chunkFileNames: 'chunks/[name].[hash].cjs', + compact: true }, { dir: 'dist', format: 'commonjs', - entryFileNames: '[name]/index.js' + entryFileNames: '[name]/index.js', + chunkFileNames: 'chunks/[name].[hash].js', + compact: true }, { dir: 'dist', format: 'esm', - entryFileNames: '[name]/index.mjs' + entryFileNames: '[name]/index.mjs', + chunkFileNames: 'chunks/[name].[hash].mjs', + compact: true }], plugins: [ swc({ diff --git a/src/create-storage-hook/index.ts b/src/create-storage-hook/index.ts new file mode 100644 index 00000000..6a1d7b81 --- /dev/null +++ b/src/create-storage-hook/index.ts @@ -0,0 +1,171 @@ +import 'client-only'; +import { useSyncExternalStore, useCallback, useMemo } from 'react'; +import { noop } from '../noop'; +import { useIsomorphicLayoutEffect } from '../use-isomorphic-layout-effect'; +import { noSSRError } from '../no-ssr'; + +type StorageType = 'localStorage' | 'sessionStorage'; +type NotUndefined = T extends undefined ? never : T; + +// StorageEvent is deliberately not fired on the same document, we do not want to change that +type CustomStorageEvent = CustomEvent; +declare global { + interface WindowEventMap { + 'foxact-local-storage': CustomStorageEvent, + 'foxact-session-storage': CustomStorageEvent + } +} + +export type Serializer = (value: T) => string; +export type Deserializer = (value: string) => T; + +// This type utility is only used for workaround https://github.com/microsoft/TypeScript/issues/37663 +// eslint-disable-next-line @typescript-eslint/ban-types -- workaround TypeScript bug +const isFunction = (x: unknown): x is Function => typeof x === 'function'; + +const identity = (x: any) => x; + +export interface UseStorageRawOption { + raw: true +} + +export interface UseStorageParserOption { + raw?: false, + serializer: Serializer, + deserializer: Deserializer +} + +const getServerSnapshotWithoutServerValue = () => { + throw noSSRError('useLocalStorage cannot be used on the server without a serverValue'); +}; + +export function createStorage(type: StorageType) { + const FOXACT_LOCAL_STORAGE_EVENT_KEY = type === 'localStorage' ? 'foxact-local-storage' : 'foxact-session-storage'; + + const dispatchStorageEvent = typeof window !== 'undefined' + ? (key: string) => { + window.dispatchEvent(new CustomEvent(FOXACT_LOCAL_STORAGE_EVENT_KEY, { detail: key })); + } + : noop; + + const setStorageItem = typeof window !== 'undefined' + ? (key: string, value: string) => { + try { + window[type].setItem(key, value); + } catch { + console.warn(`[foxact] Failed to set value to ${type}, it might be blocked`); + } finally { + dispatchStorageEvent(key); + } + } + : noop; + + const removeStorageItem = typeof window !== 'undefined' + ? (key: string) => { + try { + window[type].removeItem(key); + } catch { + console.warn(`[foxact] Failed to remove value from ${type}, it might be blocked`); + } finally { + dispatchStorageEvent(key); + } + } + : noop; + + const getStorageItem = (key: string) => { + if (typeof window === 'undefined') { + return null; + } + try { + return window[type].getItem(key); + } catch { + console.warn(`[foxact] Failed to get value from ${type}, it might be blocked`); + return null; + } + }; + + return function useStorage( + key: string, + serverValue?: NotUndefined | undefined, + options: UseStorageRawOption | UseStorageParserOption = { + serializer: JSON.stringify, + deserializer: JSON.parse + } + ) { + const subscribeToSpecificKeyOfLocalStorage = useCallback((callback: () => void) => { + if (typeof window === 'undefined') { + return noop; + } + + const handleStorageEvent = (e: StorageEvent) => { + if ( + (!('key' in e)) // Some browsers' strange quirk where StorageEvent is missing key + || e.key === key + ) { + callback(); + } + }; + const handleCustomStorageEvent = (e: CustomStorageEvent) => { + if (e.detail === key) { + callback(); + } + }; + + window.addEventListener('storage', handleStorageEvent); + window.addEventListener(FOXACT_LOCAL_STORAGE_EVENT_KEY, handleCustomStorageEvent); + return () => { + window.removeEventListener('storage', handleStorageEvent); + window.removeEventListener(FOXACT_LOCAL_STORAGE_EVENT_KEY, handleCustomStorageEvent); + }; + }, [key]); + + const serializer: Serializer = options.raw ? identity : options.serializer; + const deserializer: Deserializer = options.raw ? identity : options.deserializer; + + const getClientSnapshot = () => getStorageItem(key); + + // If the serverValue is provided, we pass it to useSES' getServerSnapshot, which will be used during SSR + // If the serverValue is not provided, we don't pass it to useSES, which will cause useSES to opt-in client-side rendering + const getServerSnapshot = serverValue !== undefined + ? () => serializer(serverValue) + : getServerSnapshotWithoutServerValue; + + const store = useSyncExternalStore( + subscribeToSpecificKeyOfLocalStorage, + getClientSnapshot, + getServerSnapshot + ); + + const deserialized = useMemo(() => (store === null ? null : deserializer(store)), [store, deserializer]); + + const setState = useCallback>>( + (v) => { + try { + const nextState = isFunction(v) + ? v(deserialized ?? null) + : v; + + if (nextState === null) { + removeStorageItem(key); + } else { + setStorageItem(key, serializer(nextState)); + } + } catch (e) { + console.warn(e); + } + }, + [key, serializer, deserialized] + ); + + useIsomorphicLayoutEffect(() => { + if ( + getStorageItem(key) === null + && serverValue !== undefined + ) { + setStorageItem(key, serializer(serverValue)); + } + }, [deserializer, key, serializer, serverValue]); + + return [deserialized ?? serverValue ?? null, setState] as const; + }; +} diff --git a/src/use-local-storage/index.ts b/src/use-local-storage/index.ts index 6ca6d3a2..ea3c050b 100644 --- a/src/use-local-storage/index.ts +++ b/src/use-local-storage/index.ts @@ -1,168 +1,13 @@ import 'client-only'; -import { useSyncExternalStore, useCallback, useMemo } from 'react'; -import { noop } from '../noop'; -import { useIsomorphicLayoutEffect } from '../use-isomorphic-layout-effect'; -import { noSSRError } from '../no-ssr'; +import { createStorage } from '../create-storage-hook'; -// StorageEvent is deliberately not fired on the same document, we do not want to change that -const FOXACT_LOCAL_STORAGE_EVENT_KEY = 'foxact-local-storage'; -type CustomStorageEvent = CustomEvent; -declare global { - interface WindowEventMap { - [FOXACT_LOCAL_STORAGE_EVENT_KEY]: CustomStorageEvent - } -} +export type { + Serializer, Deserializer, + UseStorageRawOption as UseLocalStorageRawOption, + UseStorageParserOption as UseLocalStorageParserOption +} from '../create-storage-hook'; -const dispatchStorageEvent = (key: string) => { - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent(FOXACT_LOCAL_STORAGE_EVENT_KEY, { detail: key })); - } -}; - -export type Serializer = (value: T) => string; -export type Deserializer = (value: string) => T; - -const setLocalStorageItem = (key: string, value: string) => { - if (typeof window !== 'undefined') { - try { - window.localStorage.setItem(key, value); - } catch (e) { - console.error(e); - } finally { - dispatchStorageEvent(key); - } - } -}; - -const removeLocalStorageItem = (key: string) => { - if (typeof window !== 'undefined') { - try { - // Some environments will disallow localStorage access - window.localStorage.removeItem(key); - } catch (e) { - console.error(e); - } finally { - dispatchStorageEvent(key); - } - } -}; - -const getLocalStorageItem = (key: string) => { - if (typeof window === 'undefined') { - return null; - } - try { - return window.localStorage.getItem(key); - } catch (e) { - console.warn(e); - return null; - } -}; - -const getServerSnapshotWithoutServerValue = () => { - throw noSSRError('useLocalStorage cannot be used on the server without a serverValue'); -}; - -// This type utility is only used for workaround https://github.com/microsoft/TypeScript/issues/37663 -// eslint-disable-next-line @typescript-eslint/ban-types -- workaround TypeScript bug -const isFunction = (x: unknown): x is Function => typeof x === 'function'; - -const identity = (x: any) => x; - -export interface UseLocalStorageRawOption { - raw: true -} - -export interface UseLocalStorageParserOption { - raw?: false, - serializer: Serializer, - deserializer: Deserializer -} - -type NotUndefined = T extends undefined ? never : T; +const useLocalStorage = createStorage('localStorage'); /** @see https://foxact.skk.moe/use-local-storage */ -export function useLocalStorage( - key: string, - serverValue?: NotUndefined | undefined, - options: UseLocalStorageRawOption | UseLocalStorageParserOption = { - serializer: JSON.stringify, - deserializer: JSON.parse - } -) { - const subscribeToSpecificKeyOfLocalStorage = useCallback((callback: () => void) => { - if (typeof window === 'undefined') { - return noop; - } - - const handleStorageEvent = (e: StorageEvent) => { - if ( - (!('key' in e)) // Some browsers' strange quirk where StorageEvent is missing key - || e.key === key - ) { - callback(); - } - }; - const handleCustomStorageEvent = (e: CustomStorageEvent) => { - if (e.detail === key) { - callback(); - } - }; - - window.addEventListener('storage', handleStorageEvent); - window.addEventListener(FOXACT_LOCAL_STORAGE_EVENT_KEY, handleCustomStorageEvent); - return () => { - window.removeEventListener('storage', handleStorageEvent); - window.removeEventListener(FOXACT_LOCAL_STORAGE_EVENT_KEY, handleCustomStorageEvent); - }; - }, [key]); - - const serializer: Serializer = options.raw ? identity : options.serializer; - const deserializer: Deserializer = options.raw ? identity : options.deserializer; - - const getClientSnapshot = () => getLocalStorageItem(key); - - // If the serverValue is provided, we pass it to useSES' getServerSnapshot, which will be used during SSR - // If the serverValue is not provided, we don't pass it to useSES, which will cause useSES to opt-in client-side rendering - const getServerSnapshot = serverValue !== undefined - ? () => serializer(serverValue) - : getServerSnapshotWithoutServerValue; - - const store = useSyncExternalStore( - subscribeToSpecificKeyOfLocalStorage, - getClientSnapshot, - getServerSnapshot - ); - - const deserialized = useMemo(() => (store === null ? null : deserializer(store)), [store, deserializer]); - - const setState = useCallback>>( - (v) => { - try { - const nextState = isFunction(v) - ? v(deserialized ?? null) - : v; - - if (nextState === null) { - removeLocalStorageItem(key); - } else { - setLocalStorageItem(key, serializer(nextState)); - } - } catch (e) { - console.warn(e); - } - }, - [key, serializer, deserialized] - ); - - useIsomorphicLayoutEffect(() => { - if ( - getLocalStorageItem(key) === null - && serverValue !== undefined - ) { - setLocalStorageItem(key, serializer(serverValue)); - } - }, [deserializer, key, serializer, serverValue]); - - return [deserialized ?? serverValue ?? null, setState] as const; -} +export { useLocalStorage }; diff --git a/src/use-session-storage/index.ts b/src/use-session-storage/index.ts new file mode 100644 index 00000000..f08024fa --- /dev/null +++ b/src/use-session-storage/index.ts @@ -0,0 +1,13 @@ +import 'client-only'; +import { createStorage } from '../create-storage-hook'; + +export type { + Serializer, Deserializer, + UseStorageRawOption as UseSessionStorageRawOption, + UseStorageParserOption as UseSessionStorageParserOption +} from '../create-storage-hook'; + +const useSessionStorage = createStorage('sessionStorage'); + +/** @see https://foxact.skk.moe/use-session-storage */ +export { useSessionStorage };