87 lines
2.5 KiB
TypeScript
87 lines
2.5 KiB
TypeScript
'use client';
|
|
|
|
import {useCallback, useSyncExternalStore} from 'react';
|
|
|
|
/**
|
|
* A boolean persisted in localStorage, read without a hydration mismatch.
|
|
*
|
|
* useSyncExternalStore already solves the hydration problem on its own:
|
|
* React uses `getServerSnapshot` for the SSR render AND for the first client
|
|
* render, so the two markups match, then immediately re-reads `getSnapshot`
|
|
* and re-renders with the stored value. No `isHydrated` flag is needed — and
|
|
* adding one means a setState inside an effect, which React's lint rule
|
|
* rejects and which costs an extra render pass to do what the hook already
|
|
* does natively.
|
|
*
|
|
* Writes notify every subscriber, so two components reading the same key never
|
|
* disagree, and the `storage` event keeps other tabs in step.
|
|
*/
|
|
const listeners = new Set<() => void>();
|
|
|
|
function emit() {
|
|
listeners.forEach((l) => l());
|
|
}
|
|
|
|
function subscribe(onChange: () => void) {
|
|
listeners.add(onChange);
|
|
window.addEventListener('storage', onChange);
|
|
return () => {
|
|
listeners.delete(onChange);
|
|
window.removeEventListener('storage', onChange);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The same store, but "never set" stays distinguishable from "set to false".
|
|
*
|
|
* The sidebar needs that distinction: its default is per-device (a tablet
|
|
* starts collapsed, a desktop starts expanded), so a stored `false` has to
|
|
* mean "this merchant expanded it" and not "no preference yet". Collapsing
|
|
* the two would either strand tablets expanded or force desktops to re-expand
|
|
* on every first visit.
|
|
*/
|
|
export function usePersistentTriState(
|
|
key: string,
|
|
): [boolean | null, (next: boolean) => void] {
|
|
const getSnapshot = useCallback(() => {
|
|
try {
|
|
const raw = window.localStorage.getItem(key);
|
|
return raw === null ? null : raw === 'true';
|
|
} catch {
|
|
// Private mode / storage disabled — degrade to "no preference" rather
|
|
// than taking the whole shell down over a preference.
|
|
return null;
|
|
}
|
|
}, [key]);
|
|
|
|
const getServerSnapshot = useCallback(() => null, []);
|
|
|
|
const value = useSyncExternalStore(
|
|
subscribe,
|
|
getSnapshot,
|
|
getServerSnapshot,
|
|
);
|
|
|
|
const set = useCallback(
|
|
(next: boolean) => {
|
|
try {
|
|
window.localStorage.setItem(key, String(next));
|
|
} catch {
|
|
/* ignore — the notify below still updates this session */
|
|
}
|
|
emit();
|
|
},
|
|
[key],
|
|
);
|
|
|
|
return [value, set];
|
|
}
|
|
|
|
export function usePersistentFlag(
|
|
key: string,
|
|
fallback = false,
|
|
): [boolean, (next: boolean) => void] {
|
|
const [stored, set] = usePersistentTriState(key);
|
|
return [stored ?? fallback, set];
|
|
}
|