Skip to content

Commit

Permalink
feat(use-session-storage): bring up
Browse files Browse the repository at this point in the history
  • Loading branch information
SukkaW committed Nov 28, 2023
1 parent def2b22 commit 0a86ef3
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 166 deletions.
12 changes: 9 additions & 3 deletions rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
171 changes: 171 additions & 0 deletions src/create-storage-hook/index.ts
Original file line number Diff line number Diff line change
@@ -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> = 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-local-storage': CustomStorageEvent,
'foxact-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
// 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<T> {
raw?: false,
serializer: Serializer<T>,
deserializer: Deserializer<T>
}

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<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(`[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<T>(
key: string,
serverValue?: NotUndefined<T> | undefined,
options: UseStorageRawOption | UseStorageParserOption<T> = {
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<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]
);

useIsomorphicLayoutEffect(() => {
if (
getStorageItem(key) === null
&& serverValue !== undefined
) {
setStorageItem(key, serializer(serverValue));
}
}, [deserializer, key, serializer, serverValue]);

return [deserialized ?? serverValue ?? null, setState] as const;
};
}
171 changes: 8 additions & 163 deletions src/use-local-storage/index.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
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<string>(FOXACT_LOCAL_STORAGE_EVENT_KEY, { detail: key }));
}
};

export type Serializer<T> = (value: T) => string;
export type Deserializer<T> = (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<T> {
raw?: false,
serializer: Serializer<T>,
deserializer: Deserializer<T>
}

type NotUndefined<T> = T extends undefined ? never : T;
const useLocalStorage = createStorage('localStorage');

/** @see https://foxact.skk.moe/use-local-storage */
export function useLocalStorage<T>(
key: string,
serverValue?: NotUndefined<T> | undefined,
options: UseLocalStorageRawOption | UseLocalStorageParserOption<T> = {
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<T> = options.raw ? identity : options.serializer;
const deserializer: Deserializer<T> = 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<React.Dispatch<React.SetStateAction<T | null>>>(
(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 };
13 changes: 13 additions & 0 deletions src/use-session-storage/index.ts
Original file line number Diff line number Diff line change
@@ -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 };

0 comments on commit 0a86ef3

Please sign in to comment.