From 732d509b11d163e48095a81509df3ebb2ee0d8a2 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Tue, 10 Mar 2026 11:00:51 +1100 Subject: [PATCH] fix(sandbox): use simpler web storage hook (#794) --- .../sandbox/templates/utils/use-latest-ref.ts | 19 -- .../templates/utils/use-web-storage.ts | 169 ++---------------- 2 files changed, 12 insertions(+), 176 deletions(-) delete mode 100644 packages/sandbox/templates/utils/use-latest-ref.ts diff --git a/packages/sandbox/templates/utils/use-latest-ref.ts b/packages/sandbox/templates/utils/use-latest-ref.ts deleted file mode 100644 index 175e305c..00000000 --- a/packages/sandbox/templates/utils/use-latest-ref.ts +++ /dev/null @@ -1,19 +0,0 @@ -'use client'; - -import { useRef } from 'react'; - -/** - * Keep a ref that always points to the latest value. - * - * Useful for capturing callbacks or derived values inside closures - * that are created once (e.g. factory callbacks) without stale reads. - */ -export function useLatestRef(value: Value): Readonly<{ current: Value }> { - const ref = useRef(value); - ref.current = value; - return ref; -} - -export namespace useLatestRef { - export type Result = Readonly<{ current: Value }>; -} diff --git a/packages/sandbox/templates/utils/use-web-storage.ts b/packages/sandbox/templates/utils/use-web-storage.ts index 740af2dc..1505a251 100644 --- a/packages/sandbox/templates/utils/use-web-storage.ts +++ b/packages/sandbox/templates/utils/use-web-storage.ts @@ -1,162 +1,17 @@ -import type { SetStateAction } from 'react'; -import { useEffect, useEffectEvent, useMemo, useState } from 'react'; +import { useState } from 'react'; +import { + createWebStorageStore, + type WebStorageSerializableValue, + type WebStorageType, +} from './create-web-storage-store'; +import { useExternalStore } from './use-external-store'; -import { useLatestRef } from './use-latest-ref'; - -type FallbackStorageInterface = Pick; - -export class FallbackStorage implements FallbackStorageInterface { - private readonly state = new Map(); - - public getItem(key: string): string | null { - return this.state.get(key) ?? null; - } - - public setItem(key: string, value: string): void { - this.state.set(key, value); - } - - public removeItem(key: string): void { - this.state.delete(key); - } -} - -type SetUseWebStorageAction = S | ((prevValue: S) => S); -type Dispatch = (value?: A) => void; -export type WebStorageSerializableValue = - | string - | boolean - | number - | null - | Record - | WebStorageSerializableValue[]; - -export type WebStorageType = 'local' | 'session'; -export type WebStorage = Storage | FallbackStorage; - -export type UseWebStorageReturn = [ - T, - Dispatch>, - isSupported: boolean, -]; - -export type UseWebStorageOnChange = (value: T | undefined) => void; - -export type UseWebStorageOptions = { - onChange?: UseWebStorageOnChange; - parser?: (value: unknown) => T; -}; - -/** - * Use web storage with a similar API to React's `useState`. - * @param type 'local' for localStorage, 'session' for sessionStorage - * @param key string - * @param defaultValue T - * @param options UseWebStorageOptions - * @returns [getter, setter, isSupported: boolean] - */ export function useWebStorage( type: WebStorageType, key: string, - defaultValue: T, - options?: UseWebStorageOptions -): UseWebStorageReturn; -export function useWebStorage( - type: WebStorageType, - key: string, - defaultValue?: T, - options?: UseWebStorageOptions -): UseWebStorageReturn; -export function useWebStorage( - type: WebStorageType, - key: string, - defaultValue?: T, - options?: UseWebStorageOptions -): UseWebStorageReturn { - const { onChange, parser = (item: unknown) => item as T } = options ?? {}; - - const [storage] = useState(() => { - try { - const webStorage = type === 'local' ? localStorage : sessionStorage; - const testKey = '_____test_____'; - // Ensure we can set a value to test if storage is supported. - webStorage.setItem(testKey, new Date().toISOString()); - webStorage.removeItem(testKey); - return webStorage; - } catch { - return new FallbackStorage(); - } - }); - - // Keep a reference to the latest value in state and sync to local storage. - const [latestValue, _setLatestValue] = useState(() => { - try { - const item = storage.getItem(key); - return item ? parser(JSON.parse(item)) : defaultValue; - } catch { - return defaultValue; - } - }); - const setLatestValue = useLatestRef>>((value) => { - if (typeof value === 'function') { - _setLatestValue((prev) => value(prev) ?? defaultValue); - } else { - _setLatestValue(value ?? defaultValue); - } - }); - - const handleStorageChange = useEffectEvent((event: StorageEvent) => { - if (event.key !== key) return; - // Hopefully legacy logic - some entries will be the string 'undefined' because of a bug - // where we called setItem(key, undefined) - // That said, it can't hurt to be cautious - const newValue = - event.newValue && event.newValue !== 'undefined' ? parser(JSON.parse(event.newValue)) : defaultValue; - if (newValue !== latestValue) setStoredValue(newValue); - }); - - // Update state when local storage changes in another tab - useEffect(() => { - const abortController = new AbortController(); - window.addEventListener('storage', handleStorageChange, { signal: abortController.signal }); - return () => { - abortController.abort(); - }; - }, []); - - const setStoredValue = useMemo>>(() => { - const updateStorage = (value: T | undefined) => { - try { - // JSON.stringify returns undefined for values like () => {} or Symbol('') - // Also, if a toJSON() method returns undefined, JSON.stringify will too. - const jsonValue = JSON.stringify(value) as string | undefined; - if (jsonValue === undefined) { - storage.removeItem(key); - } else { - storage.setItem(key, jsonValue); - } - onChange?.(value); - } catch { - // Fail silently. - } - }; - - return (value) => { - if (typeof value === 'function') { - _setLatestValue((prev) => { - const newValue = value(prev); - updateStorage(newValue); - return newValue; - }); - return; - } - updateStorage(value); - setLatestValue.current(value); - }; - }, [key, onChange, setLatestValue, storage]); - - // We have to check the inverse as Storage is not available in SSR. - const isSupported = !(storage instanceof FallbackStorage); - - return [latestValue, setStoredValue, isSupported]; + defaultValue: T +): [T, (value: T) => void] { + const [store] = useState(() => createWebStorageStore(type, key, defaultValue)); + const value = useExternalStore(store); + return [value, store.setValue]; }