chore: inline useLocalStorage

This commit is contained in:
SukkaW 2024-09-07 22:33:26 +08:00
parent 4c84d63ff4
commit a0f11bff31
4 changed files with 306 additions and 17 deletions

View File

@ -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 { useLocalStorage } from "foxact/use-local-storage";
import { useLocalStorage } from "../utils";
import {
DragDropContext,
Droppable,

View File

@ -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<string, unknown>) {
: 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> = T extends undefined ? never : T;
// StorageEvent is deliberately not fired on the same document, we do not want to change that
type CustomStorageEvent = CustomEvent<string>;
declare global {
interface WindowEventMap {
"foxact-use-local-storage": CustomStorageEvent;
"foxact-use-session-storage": CustomStorageEvent;
}
}
export type Serializer<T> = (value: T) => string;
export type Deserializer<T> = (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<T> {
raw?: false;
serializer: Serializer<T>;
deserializer: Deserializer<T>;
}
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<string>(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 = <T>(key: string, serializer: Serializer<T>) =>
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<T>(
key: string,
serverValue: NotUndefined<T>,
options?: UseStorageRawOption | UseStorageParserOption<T>,
): readonly [T, React.Dispatch<React.SetStateAction<T | null>>];
// client-render only
function useStorage<T>(
key: string,
serverValue?: undefined,
options?: UseStorageRawOption | UseStorageParserOption<T>,
): readonly [T | null, React.Dispatch<React.SetStateAction<T | null>>];
function useStorage<T>(
key: string,
serverValue?: NotUndefined<T> | undefined,
options: UseStorageRawOption | UseStorageParserOption<T> = {
serializer: JSON.stringify,
deserializer: JSON.parse,
},
):
| readonly [T | null, React.Dispatch<React.SetStateAction<T | null>>]
| readonly [T, React.Dispatch<React.SetStateAction<T | null>>] {
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<T> = options.raw
? identity
: options.serializer;
const deserializer: Deserializer<T> = 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<React.SetStateAction<T | null>>
>(
(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<T>)
: // storage has value
(deserialized satisfies T);
return [finalValue, setState] as const;
}
return {
useStorage,
useSetStorage,
};
}
export const useLocalStorage = createStorage("localStorage").useStorage;

View File

@ -26,7 +26,6 @@
"@vercel/speed-insights": "^1.0.2",
"axios": "^1.7.5",
"emoji-picker-react": "^4.9.2",
"foxact": "^0.2.37",
"fuse.js": "^7.0.0",
"heic2any": "^0.0.4",
"html-to-image": "^1.11.11",

View File

@ -2378,7 +2378,7 @@ cli-truncate@^3.1.0:
slice-ansi "^5.0.0"
string-width "^5.0.0"
client-only@0.0.1, client-only@^0.0.1:
client-only@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
@ -3627,14 +3627,6 @@ formdata-polyfill@^4.0.10:
dependencies:
fetch-blob "^3.1.2"
foxact@^0.2.37:
version "0.2.37"
resolved "https://registry.yarnpkg.com/foxact/-/foxact-0.2.37.tgz#9af5c8b56c96bb70c84760e79c8e949a6fa28f04"
integrity sha512-nzK7n3JAnmCWO3GJXe4ry196s7wlOUnJVBn/RQ9Og1FJ/pEaRi94atLGFmzM0CVDgIbSFdMnH49PtkWjSqtMSw==
dependencies:
client-only "^0.0.1"
server-only "^0.0.1"
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@ -5786,11 +5778,6 @@ serialize-javascript@^6.0.1:
dependencies:
randombytes "^2.1.0"
server-only@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/server-only/-/server-only-0.0.1.tgz#0f366bb6afb618c37c9255a314535dc412cd1c9e"
integrity sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==
shebang-command@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"