diff --git a/app/components/mask.tsx b/app/components/mask.tsx index 62503c37a85..9bb430d45bc 100644 --- a/app/components/mask.tsx +++ b/app/components/mask.tsx @@ -48,7 +48,7 @@ import { Updater } from "../typing"; import { ModelConfigList } from "./model-config"; import { FileName, Path } from "../constant"; import { BUILTIN_MASK_STORE } from "../masks"; -import { nanoid } from "nanoid"; +import { useLocalStorage } from "../utils"; import { DragDropContext, Droppable, @@ -426,16 +426,11 @@ export function MaskPage() { const maskStore = useMaskStore(); const chatStore = useChatStore(); - const [filterLang, setFilterLang] = useState( - () => localStorage.getItem("Mask-language") as Lang | undefined, + const [filterLang, setFilterLang] = useLocalStorage( + "Mask-language", + null, + { raw: true }, ); - useEffect(() => { - if (filterLang) { - localStorage.setItem("Mask-language", filterLang); - } else { - localStorage.removeItem("Mask-language"); - } - }, [filterLang]); const allMasks = maskStore .getAll() @@ -542,7 +537,7 @@ export function MaskPage() { onChange={(e) => { const value = e.currentTarget.value; if (value === Locale.Settings.Lang.All) { - setFilterLang(undefined); + setFilterLang(null); } else { setFilterLang(value as Lang); } diff --git a/app/locales/index.ts b/app/locales/index.ts index acdb3e878a1..24aa24435b6 100644 --- a/app/locales/index.ts +++ b/app/locales/index.ts @@ -82,6 +82,9 @@ merge(fallbackLang, targetLang); export default fallbackLang as LocaleType; function getItem(key: string) { + if (typeof window === "undefined") { + return null; + } try { return localStorage.getItem(key); } catch { @@ -90,6 +93,9 @@ function getItem(key: string) { } function setItem(key: string, value: string) { + if (typeof window === "undefined") { + return null; + } try { localStorage.setItem(key, value); } catch {} diff --git a/app/utils.ts b/app/utils.ts index 60041ba060f..e08f5ad96aa 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -1,4 +1,11 @@ -import { useEffect, useState } from "react"; +import { + useEffect, + useLayoutEffect, + useState, + useSyncExternalStore, + useCallback, + useMemo, +} from "react"; import { showToast } from "./components/ui-lib"; import Locale from "./locales"; import { RequestMessage } from "./client/api"; @@ -318,3 +325,299 @@ export function adapter(config: Record) { : path; return fetch(fetchUrl as string, { ...rest, responseType: "text" }); } + +/** + * Copyright 2024 Sukka (https://skk.moe) and the contributors of foxact (https://foxact.skk.moe) + * Licensed under the MIT License + */ + +const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; + +const noop = () => {}; + +const stlProp = Object.getOwnPropertyDescriptor(Error, "stackTraceLimit"); +const hasSTL = stlProp?.writable && typeof stlProp.value === "number"; +const noSSRError = ( + errorMessage?: string | undefined, + nextjsDigest = "BAILOUT_TO_CLIENT_SIDE_RENDERING", +) => { + const originalStackTraceLimit = Error.stackTraceLimit; + + /** + * This is *only* safe to do when we know that nothing at any point in the + * stack relies on the `Error.stack` property of the noSSRError. By removing + * the strack trace of the error, we can improve the performance of object + * creation by a lot. + */ + if (hasSTL) { + Error.stackTraceLimit = 0; + } + + const error = new Error(errorMessage); + + /** + * Restore the stack trace limit to its original value after the error has + * been created. + */ + if (hasSTL) { + Error.stackTraceLimit = originalStackTraceLimit; + } + + // Next.js marks errors with `NEXT_DYNAMIC_NO_SSR_CODE` digest as recoverable: + // https://github.com/vercel/next.js/blob/bef716ad031591bdf94058aaf4b8d842e75900b5/packages/next/src/shared/lib/lazy-dynamic/bailout-to-csr.ts#L2 + (error as any).digest = nextjsDigest; + + (error as any).recoverableError = "NO_SSR"; + + return error; +}; + +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-use-local-storage": CustomStorageEvent; + "foxact-use-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 +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", + ); +}; + +function createStorage(type: StorageType) { + const FOXACT_LOCAL_STORAGE_EVENT_KEY = + type === "localStorage" + ? "foxact-use-local-storage" + : "foxact-use-session-storage"; + + const foxactHookName = + type === "localStorage" + ? "foxact/use-local-storage" + : "foxact/use-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( + `[${foxactHookName}] 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( + `[${foxactHookName}] 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( + `[${foxactHookName}] Failed to get value from ${type}, it might be blocked`, + ); + return null; + } + }; + + const useSetStorage = (key: string, serializer: Serializer) => + useCallback( + (v: T | null) => { + try { + if (v === null) { + removeStorageItem(key); + } else { + setStorageItem(key, serializer(v)); + } + } catch (e) { + console.warn(e); + } + }, + [key, serializer], + ); + + // ssr compatible + function useStorage( + key: string, + serverValue: NotUndefined, + options?: UseStorageRawOption | UseStorageParserOption, + ): readonly [T, React.Dispatch>]; + // client-render only + function useStorage( + key: string, + serverValue?: undefined, + options?: UseStorageRawOption | UseStorageParserOption, + ): readonly [T | null, React.Dispatch>]; + function useStorage( + key: string, + serverValue?: NotUndefined | undefined, + options: UseStorageRawOption | UseStorageParserOption = { + serializer: JSON.stringify, + deserializer: JSON.parse, + }, + ): + | readonly [T | null, React.Dispatch>] + | readonly [T, React.Dispatch>] { + 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< + React.Dispatch> + >( + (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], + ); + + useLayoutEffect(() => { + if (getStorageItem(key) === null && serverValue !== undefined) { + setStorageItem(key, serializer(serverValue)); + } + }, [deserializer, key, serializer, serverValue]); + + const finalValue: T | null = + deserialized === null + ? // storage doesn't have value + serverValue === undefined + ? // no default value provided + null + : (serverValue satisfies NotUndefined) + : // storage has value + (deserialized satisfies T); + + return [finalValue, setState] as const; + } + + return { + useStorage, + useSetStorage, + }; +} + +export const useLocalStorage = createStorage("localStorage").useStorage; diff --git a/app/utils/indexedDB-storage.ts b/app/utils/indexedDB-storage.ts index da309455013..f47adf05a4b 100644 --- a/app/utils/indexedDB-storage.ts +++ b/app/utils/indexedDB-storage.ts @@ -3,6 +3,9 @@ import { get, set, del, clear } from "idb-keyval"; class IndexedDBStorage implements StateStorage { public async getItem(name: string): Promise { + if (typeof window === "undefined") { + return null; + } try { const value = (await get(name)) || localStorage.getItem(name); return value; @@ -12,6 +15,9 @@ class IndexedDBStorage implements StateStorage { } public async setItem(name: string, value: string): Promise { + if (typeof window === "undefined") { + return; + } try { const _value = JSON.parse(value); if (!_value?.state?._hasHydrated) { @@ -25,6 +31,9 @@ class IndexedDBStorage implements StateStorage { } public async removeItem(name: string): Promise { + if (typeof window === "undefined") { + return; + } try { await del(name); } catch (error) { @@ -33,6 +42,9 @@ class IndexedDBStorage implements StateStorage { } public async clear(): Promise { + if (typeof window === "undefined") { + return; + } try { await clear(); } catch (error) {