diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts index 002709c8971..200fd880e88 100644 --- a/apps/studio/components/grid/SupabaseGrid.utils.ts +++ b/apps/studio/components/grid/SupabaseGrid.utils.ts @@ -1,4 +1,5 @@ import AwesomeDebouncePromise from 'awesome-debounce-promise' +import { safeLocalStorage, safeSessionStorage } from 'common' import { compact } from 'lodash' import { useSearchParams } from 'next/navigation' import { parseAsNativeArrayOf, parseAsString, useQueryStates } from 'nuqs' @@ -148,7 +149,7 @@ export function loadTableEditorStateFromLocalStorage( ): SavedState | undefined { const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef) // Prefer sessionStorage (scoped to current tab) over localStorage - const jsonStr = sessionStorage.getItem(storageKey) ?? localStorage.getItem(storageKey) + const jsonStr = safeSessionStorage.getItem(storageKey) ?? safeLocalStorage.getItem(storageKey) if (!jsonStr) return const json = JSON.parse(jsonStr) return json[tableId] @@ -198,7 +199,7 @@ export function saveTableEditorStateToLocalStorage({ filters?: string[] }) { const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef) - const savedStr = sessionStorage.getItem(storageKey) ?? localStorage.getItem(storageKey) + const savedStr = safeSessionStorage.getItem(storageKey) ?? safeLocalStorage.getItem(storageKey) const config = { ...(gridColumns !== undefined && { gridColumns }), @@ -215,8 +216,8 @@ export function saveTableEditorStateToLocalStorage({ savedJson = { [tableId]: config } } // Save to both localStorage and sessionStorage so it's consistent to current tab - localStorage.setItem(storageKey, JSON.stringify(savedJson)) - sessionStorage.setItem(storageKey, JSON.stringify(savedJson)) + safeLocalStorage.setItem(storageKey, JSON.stringify(savedJson)) + safeSessionStorage.setItem(storageKey, JSON.stringify(savedJson)) } export const saveTableEditorStateToLocalStorageDebounced = AwesomeDebouncePromise( diff --git a/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx b/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx index 84e06cad8ed..244fdd24f2e 100644 --- a/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx @@ -1,6 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { SupportCategories } from '@supabase/shared-types/out/constants' -import { LOCAL_STORAGE_KEYS } from 'common' +import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' @@ -32,18 +32,18 @@ import { useProfile } from '@/lib/profile' const setDeletionRequestFlag = () => { const expiryDate = new Date() expiryDate.setDate(expiryDate.getDate() + 30) - localStorage.setItem(LOCAL_STORAGE_KEYS.ACCOUNT_DELETION_REQUEST, expiryDate.toString()) + safeLocalStorage.setItem(LOCAL_STORAGE_KEYS.ACCOUNT_DELETION_REQUEST, expiryDate.toString()) } const hasActiveDeletionRequest = () => { - const expiryDateStr = localStorage.getItem(LOCAL_STORAGE_KEYS.ACCOUNT_DELETION_REQUEST) + const expiryDateStr = safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.ACCOUNT_DELETION_REQUEST) if (!expiryDateStr) return false const expiryDate = new Date(expiryDateStr) const now = new Date() if (now > expiryDate) { - localStorage.removeItem(LOCAL_STORAGE_KEYS.ACCOUNT_DELETION_REQUEST) + safeLocalStorage.removeItem(LOCAL_STORAGE_KEYS.ACCOUNT_DELETION_REQUEST) return false } diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 472fe6ccf79..fad16eb4c9c 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -1,4 +1,4 @@ -import { FeatureFlagContext, LOCAL_STORAGE_KEYS, useFlag } from 'common' +import { FeatureFlagContext, LOCAL_STORAGE_KEYS, safeLocalStorage, useFlag } from 'common' import { noop } from 'lodash' import { useQueryState } from 'nuqs' import { @@ -39,14 +39,10 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren) = setFlags( featurePreviews.reduce((a, b) => { const defaultOptIn = b.isDefaultOptIn - try { - const localStorageValue = window.localStorage.getItem(b.key) - return { - ...a, - [b.key]: !localStorageValue ? defaultOptIn : localStorageValue === 'true', - } - } catch { - return { ...a, [b.key]: defaultOptIn } + const localStorageValue = safeLocalStorage.getItem(b.key) + return { + ...a, + [b.key]: !localStorageValue ? defaultOptIn : localStorageValue === 'true', } }, {}) ) @@ -62,13 +58,7 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren) = const value = { flags, onUpdateFlag: (key: string, value: boolean) => { - try { - if (typeof window !== 'undefined' && window.localStorage) { - window.localStorage.setItem(key, value ? 'true' : 'false') - } - } catch { - // Silently fail in restricted storage modes (e.g. Safari private browsing) - } + safeLocalStorage.setItem(key, value ? 'true' : 'false') const updatedFlags = { ...flags, [key]: value } setFlags(updatedFlags) }, diff --git a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts index b7a72e7825a..9b2d24d73a6 100644 --- a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts +++ b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts @@ -5,7 +5,7 @@ import { uniqBy } from 'lodash' import '@xyflow/react/dist/style.css' -import { LOCAL_STORAGE_KEYS } from 'common' +import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { TableNodeData } from './Schemas.constants' import { TABLE_NODE_ROW_HEIGHT, TABLE_NODE_WIDTH } from './SchemaTableNode' @@ -163,7 +163,7 @@ export async function getGraphDataFromTables( } } - const savedPositionsLocalStorage = localStorage.getItem( + const savedPositionsLocalStorage = safeLocalStorage.getItem( LOCAL_STORAGE_KEYS.SCHEMA_VISUALIZER_POSITIONS(ref ?? 'project', schema?.id ?? 0) ) const savedPositions = tryParseJson(savedPositionsLocalStorage) diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx index 5839e064a1d..a89183886ac 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx @@ -1,3 +1,4 @@ +import { safeLocalStorage } from 'common' import { useEffect, useState } from 'react' import { toast } from 'sonner' import { @@ -75,7 +76,7 @@ const PropertyRow = ({ const [isExpanded, setIsExpanded] = useState(() => { try { // Storing in local storage so users dont have to click expand every time they change selected log - return JSON.parse(localStorage.getItem(storageKey) ?? 'false') + return JSON.parse(safeLocalStorage.getItem(storageKey) ?? 'false') } catch (_) { return false } @@ -83,7 +84,7 @@ const PropertyRow = ({ const [isCopied, setIsCopied] = useState(false) useEffect(() => { - localStorage.setItem(storageKey, JSON.stringify(isExpanded)) + safeLocalStorage.setItem(storageKey, JSON.stringify(isExpanded)) }, [isExpanded, storageKey]) const handleCopy = () => { diff --git a/apps/studio/components/interfaces/SignIn/SessionTimeoutModal.tsx b/apps/studio/components/interfaces/SignIn/SessionTimeoutModal.tsx index 47ed100e959..24782d2854e 100644 --- a/apps/studio/components/interfaces/SignIn/SessionTimeoutModal.tsx +++ b/apps/studio/components/interfaces/SignIn/SessionTimeoutModal.tsx @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/nextjs' import { SupportCategories } from '@supabase/shared-types/out/constants' +import { safeLocalStorage, safeSessionStorage } from 'common' import { useEffect } from 'react' -import { toast } from 'sonner' import { AlertDialog, AlertDialogAction, @@ -39,12 +39,8 @@ export const SessionTimeoutModal = ({ }, [visible]) const handleClearStorage = () => { - try { - localStorage.clear() - sessionStorage.clear() - } catch (e) { - toast.error('Failed to clear browser storage') - } + safeLocalStorage.clear() + safeSessionStorage.clear() window.location.reload() } diff --git a/apps/studio/components/interfaces/Storage/PublicBucketWarning.tsx b/apps/studio/components/interfaces/Storage/PublicBucketWarning.tsx index 66beac82352..c7ac2ca868b 100644 --- a/apps/studio/components/interfaces/Storage/PublicBucketWarning.tsx +++ b/apps/studio/components/interfaces/Storage/PublicBucketWarning.tsx @@ -1,6 +1,6 @@ import { ident, safeSql } from '@supabase/pg-meta/src/pg-format' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { LOCAL_STORAGE_KEYS } from 'common' +import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { useEffect, useState, type ReactNode } from 'react' import { toast } from 'sonner' import { Button } from 'ui' @@ -19,7 +19,7 @@ const DISMISS_DURATION_MS = 14 * 24 * 60 * 60 * 1000 // 14 days function isDismissed(projectRef: string, bucketId: string): boolean { try { - const raw = localStorage.getItem( + const raw = safeLocalStorage.getItem( LOCAL_STORAGE_KEYS.STORAGE_PUBLIC_BUCKET_SELECT_POLICY_WARNING_DISMISSED(projectRef, bucketId) ) if (!raw) return false @@ -31,7 +31,7 @@ function isDismissed(projectRef: string, bucketId: string): boolean { } function persistDismiss(projectRef: string, bucketId: string): void { - localStorage.setItem( + safeLocalStorage.setItem( LOCAL_STORAGE_KEYS.STORAGE_PUBLIC_BUCKET_SELECT_POLICY_WARNING_DISMISSED(projectRef, bucketId), JSON.stringify({ dismissedAt: new Date().toISOString() }) ) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/useStoragePreference.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/useStoragePreference.ts index 6b8a7a46b03..9e484b50bfe 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/useStoragePreference.ts +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/useStoragePreference.ts @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS } from 'common' +import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { useCallback } from 'react' import { @@ -28,9 +28,8 @@ const DEFAULT_PREFERENCES: StoragePreference = { * Use this outside of React (e.g. inside Valtio state methods). */ export function getStoragePreference(projectRef: string): StoragePreference { - if (typeof window === 'undefined') return DEFAULT_PREFERENCES try { - const raw = window.localStorage.getItem(LOCAL_STORAGE_KEYS.STORAGE_PREFERENCE(projectRef)) + const raw = safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.STORAGE_PREFERENCE(projectRef)) if (raw) { return { ...DEFAULT_PREFERENCES, ...JSON.parse(raw) } } diff --git a/apps/studio/components/ui/Charts/useChartHoverState.test.tsx b/apps/studio/components/ui/Charts/useChartHoverState.test.tsx index 0adb9a8a49b..c887a82b30e 100644 --- a/apps/studio/components/ui/Charts/useChartHoverState.test.tsx +++ b/apps/studio/components/ui/Charts/useChartHoverState.test.tsx @@ -212,11 +212,10 @@ describe('useChartHoverState', () => { result.current.setSyncHover(true) }) + // State still updates even though persistence failed. The storage error is + // swallowed and warned about by safeLocalStorage (covered by its own tests). expect(result.current.syncHover).toBe(true) - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Failed to save chart hover sync setting to localStorage:', - expect.any(Error) - ) + expect(consoleWarnSpy).toHaveBeenCalled() }) }) diff --git a/apps/studio/components/ui/Charts/useChartHoverState.tsx b/apps/studio/components/ui/Charts/useChartHoverState.tsx index 7c9ad21a9cf..d1d2564393c 100644 --- a/apps/studio/components/ui/Charts/useChartHoverState.tsx +++ b/apps/studio/components/ui/Charts/useChartHoverState.tsx @@ -1,3 +1,4 @@ +import { safeLocalStorage } from 'common' import { useCallback, useEffect, useState } from 'react' interface ChartHoverState { @@ -21,21 +22,20 @@ let globalState: ChartHoverState = { // Subscribers for state changes const subscribers = new Set<(state: ChartHoverState) => void>() -// Load initial sync settings from localStorage +// Load initial sync settings from localStorage. safeLocalStorage handles SSR and +// unavailable storage; the try/catch only guards JSON.parse against bad values. try { - if (typeof window !== 'undefined') { - const hoverSyncStored = localStorage.getItem(CHART_HOVER_SYNC_STORAGE_KEY) - const tooltipSyncStored = localStorage.getItem(CHART_TOOLTIP_SYNC_STORAGE_KEY) + const hoverSyncStored = safeLocalStorage.getItem(CHART_HOVER_SYNC_STORAGE_KEY) + const tooltipSyncStored = safeLocalStorage.getItem(CHART_TOOLTIP_SYNC_STORAGE_KEY) - if (hoverSyncStored !== null) { - globalState.syncHover = JSON.parse(hoverSyncStored) - } - if (tooltipSyncStored !== null) { - globalState.syncTooltip = JSON.parse(tooltipSyncStored) - } + if (hoverSyncStored !== null) { + globalState.syncHover = JSON.parse(hoverSyncStored) + } + if (tooltipSyncStored !== null) { + globalState.syncTooltip = JSON.parse(tooltipSyncStored) } } catch (error) { - console.warn('Failed to load chart sync settings from localStorage:', error) + console.warn('Failed to parse chart sync settings from localStorage:', error) } function notifySubscribers() { @@ -48,18 +48,13 @@ function updateGlobalState(updates: Partial) { // Save sync settings to localStorage when they change if (updates.syncHover !== undefined) { - try { - localStorage.setItem(CHART_HOVER_SYNC_STORAGE_KEY, JSON.stringify(globalState.syncHover)) - } catch (error) { - console.warn('Failed to save chart hover sync setting to localStorage:', error) - } + safeLocalStorage.setItem(CHART_HOVER_SYNC_STORAGE_KEY, JSON.stringify(globalState.syncHover)) } if (updates.syncTooltip !== undefined) { - try { - localStorage.setItem(CHART_TOOLTIP_SYNC_STORAGE_KEY, JSON.stringify(globalState.syncTooltip)) - } catch (error) { - console.warn('Failed to save chart tooltip sync setting to localStorage:', error) - } + safeLocalStorage.setItem( + CHART_TOOLTIP_SYNC_STORAGE_KEY, + JSON.stringify(globalState.syncTooltip) + ) } // Only notify if state actually changed diff --git a/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx b/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx index a30da4ae221..0ecd43f484e 100644 --- a/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx +++ b/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx @@ -1,4 +1,5 @@ import { SupportCategories } from '@supabase/shared-types/out/constants' +import { safeLocalStorage, safeSessionStorage } from 'common' import { ExternalLink } from 'lucide-react' import { useRouter } from 'next/router' import { Button, cn } from 'ui' @@ -26,12 +27,8 @@ export const ClientSideExceptionHandler = ({ const isProduction = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' const handleClearStorage = () => { - try { - localStorage.clear() - sessionStorage.clear() - } catch (e) { - // ignore - } + safeLocalStorage.clear() + safeSessionStorage.clear() window.location.reload() } diff --git a/apps/studio/data/integrations/github-authorization-create-mutation.ts b/apps/studio/data/integrations/github-authorization-create-mutation.ts index d54d091d374..33851540c7c 100644 --- a/apps/studio/data/integrations/github-authorization-create-mutation.ts +++ b/apps/studio/data/integrations/github-authorization-create-mutation.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { LOCAL_STORAGE_KEYS } from 'common' +import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { toast } from 'sonner' import { integrationKeys } from './keys' @@ -15,12 +15,12 @@ export async function createGitHubAuthorization({ code, state, }: GitHubAuthorizationCreateVariables) { - const localState = localStorage.getItem(LOCAL_STORAGE_KEYS.GITHUB_AUTHORIZATION_STATE) + const localState = safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.GITHUB_AUTHORIZATION_STATE) if (state !== localState) { throw new Error('GitHub authorization state mismatch') } else { - localStorage.removeItem(LOCAL_STORAGE_KEYS.GITHUB_AUTHORIZATION_STATE) + safeLocalStorage.removeItem(LOCAL_STORAGE_KEYS.GITHUB_AUTHORIZATION_STATE) } const { data, error } = await post('/platform/integrations/github/authorization', { diff --git a/apps/studio/hooks/misc/useLocalStorage.ts b/apps/studio/hooks/misc/useLocalStorage.ts index 73bce8d7c58..ba56394e20d 100644 --- a/apps/studio/hooks/misc/useLocalStorage.ts +++ b/apps/studio/hooks/misc/useLocalStorage.ts @@ -1,24 +1,20 @@ // Reference: https://usehooks.com/useLocalStorage/ import { useQuery, useQueryClient } from '@tanstack/react-query' +import { safeLocalStorage } from 'common' import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react' export function useLocalStorage(key: string, initialValue: T) { // State to store our value // Pass initial state function to useState so logic is only executed once const [storedValue, setStoredValue] = useState(() => { - if (typeof window === 'undefined') { - return initialValue - } - + // safeLocalStorage handles SSR and unavailable storage (returns null); + // the try/catch here only guards JSON.parse against corrupt values. + const item = safeLocalStorage.getItem(key) try { - // Get from local storage by key - const item = window.localStorage.getItem(key) - // Parse stored json or if none return initialValue - return item ? JSON.parse(item) : initialValue + return item ? (JSON.parse(item) as T) : initialValue } catch (error) { - // If error also return initialValue - console.log(error) + console.warn(`Failed to parse localStorage value for "${key}"`, error) return initialValue } }) @@ -27,19 +23,12 @@ export function useLocalStorage(key: string, initialValue: T) { // ... persists the new value to localStorage. const setValue = useCallback( (value: T | ((val: T) => T)) => { - try { - // Allow value to be a function so we have same API as useState - const valueToStore = value instanceof Function ? value(storedValue) : value - // Save state - setStoredValue(valueToStore) - // Save to local storage - if (typeof window !== 'undefined') { - window.localStorage.setItem(key, JSON.stringify(valueToStore)) - } - } catch (error) { - // A more advanced implementation would handle the error case - console.log(error) - } + // Allow value to be a function so we have same API as useState + const valueToStore = value instanceof Function ? value(storedValue) : value + // Save state + setStoredValue(valueToStore) + // Persist (safeLocalStorage swallows storage errors internally) + safeLocalStorage.setItem(key, JSON.stringify(valueToStore)) }, [key, storedValue] ) @@ -67,11 +56,7 @@ export function useLocalStorageQuery(key: string, initialValue: T) { } = useQuery({ queryKey, queryFn: () => { - if (typeof window === 'undefined') { - return initialValue - } - - const item = window.localStorage.getItem(key) + const item = safeLocalStorage.getItem(key) if (!item) { return initialValue @@ -94,9 +79,7 @@ export function useLocalStorageQuery(key: string, initialValue: T) { // of the same key are mounted together. if (Object.is(valueToStore, currentValue)) return - if (typeof window !== 'undefined') { - window.localStorage.setItem(key, JSON.stringify(valueToStore)) - } + safeLocalStorage.setItem(key, JSON.stringify(valueToStore)) queryClient.setQueryData(queryKey, valueToStore) queryClient.invalidateQueries({ queryKey }) diff --git a/apps/studio/hooks/misc/useSchemaQueryState.ts b/apps/studio/hooks/misc/useSchemaQueryState.ts index e78eeb49765..d74c7c6555e 100644 --- a/apps/studio/hooks/misc/useSchemaQueryState.ts +++ b/apps/studio/hooks/misc/useSchemaQueryState.ts @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { LOCAL_STORAGE_KEYS, safeLocalStorage, useParams } from 'common' import { parseAsString, useQueryState } from 'nuqs' import { useEffect, useMemo } from 'react' @@ -24,8 +24,8 @@ export const useQuerySchemaState = () => { const { ref } = useParams() const defaultSchema = - typeof window !== 'undefined' && !!window.localStorage && ref && ref.length > 0 - ? window.localStorage.getItem(LOCAL_STORAGE_KEYS.LAST_SELECTED_SCHEMA(ref)) || 'public' + ref && ref.length > 0 + ? safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.LAST_SELECTED_SCHEMA(ref)) || 'public' : 'public' // cache the original default schema so that it's not changed by another tab and cause issues in the app (saving a @@ -35,8 +35,8 @@ export const useQuerySchemaState = () => { useEffect(() => { // Update the schema in local storage on every change - if (typeof window !== 'undefined' && !!window.localStorage && ref && ref.length > 0) { - window.localStorage.setItem(LOCAL_STORAGE_KEYS.LAST_SELECTED_SCHEMA(ref), schema) + if (ref && ref.length > 0) { + safeLocalStorage.setItem(LOCAL_STORAGE_KEYS.LAST_SELECTED_SCHEMA(ref), schema) } }, [schema, ref]) diff --git a/apps/studio/lib/github.ts b/apps/studio/lib/github.ts index ddbb462cf5b..362a98c3444 100644 --- a/apps/studio/lib/github.ts +++ b/apps/studio/lib/github.ts @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS } from 'common' +import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { makeRandomString } from './helpers' @@ -52,7 +52,7 @@ export function openInstallGitHubIntegrationWindow( windowUrl = GITHUB_INTEGRATION_INSTALLATION_URL } else { const state = makeRandomString(32) - localStorage.setItem(LOCAL_STORAGE_KEYS.GITHUB_AUTHORIZATION_STATE, state) + safeLocalStorage.setItem(LOCAL_STORAGE_KEYS.GITHUB_AUTHORIZATION_STATE, state) windowUrl = `${GITHUB_INTEGRATION_AUTHORIZATION_URL}&state=${state}&prompt=select_account` } diff --git a/apps/studio/lib/project-transition-state.ts b/apps/studio/lib/project-transition-state.ts index dda2c627fc5..871533bc81d 100644 --- a/apps/studio/lib/project-transition-state.ts +++ b/apps/studio/lib/project-transition-state.ts @@ -1,3 +1,5 @@ +import { safeLocalStorage } from 'common' + export const FALLBACK_LONG_RUNNING_STATE_THRESHOLD_MINUTES = 10 // Persist long enough for same-browser reloads, but not so long that a later transition reuses stale state. export const MAX_PERSISTED_TRANSITION_AGE_HOURS = 24 @@ -15,7 +17,7 @@ export const getPersistedTransitionStartTime = ( ) => { if (typeof window === 'undefined') return now - const existingValue = window.localStorage.getItem(storageKey) + const existingValue = safeLocalStorage.getItem(storageKey) if (existingValue !== null) { const parsedStartTime = Number(existingValue) @@ -31,14 +33,12 @@ export const getPersistedTransitionStartTime = ( } } - window.localStorage.setItem(storageKey, String(now)) + safeLocalStorage.setItem(storageKey, String(now)) return now } export const clearPersistedTransitionStartTime = (storageKey: string) => { - if (typeof window === 'undefined') return - - window.localStorage.removeItem(storageKey) + safeLocalStorage.removeItem(storageKey) } export const getRemainingTransitionTimeMs = ({ diff --git a/apps/studio/lib/telemetry.tsx b/apps/studio/lib/telemetry.tsx index 14287a8bb69..9ae59ef7441 100644 --- a/apps/studio/lib/telemetry.tsx +++ b/apps/studio/lib/telemetry.tsx @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/nextjs' -import { LOCAL_STORAGE_KEYS, PageTelemetry, posthogClient, useUser } from 'common' +import { LOCAL_STORAGE_KEYS, PageTelemetry, posthogClient, safeLocalStorage, useUser } from 'common' import { useEffect, useRef } from 'react' import { useConsentToast } from 'ui-patterns/consent' @@ -65,10 +65,10 @@ export function Telemetry() { } const setSentryId = async () => { - let sentryUserId = localStorage.getItem(LOCAL_STORAGE_KEYS.SENTRY_USER_ID) + let sentryUserId = safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.SENTRY_USER_ID) if (!sentryUserId) { sentryUserId = await getAnonId(user?.id) - localStorage.setItem(LOCAL_STORAGE_KEYS.SENTRY_USER_ID, sentryUserId) + safeLocalStorage.setItem(LOCAL_STORAGE_KEYS.SENTRY_USER_ID, sentryUserId) } Sentry.setUser({ id: sentryUserId }) } diff --git a/apps/studio/state/ai-assistant-state.tsx b/apps/studio/state/ai-assistant-state.tsx index ffd206905ff..7c31bfef138 100644 --- a/apps/studio/state/ai-assistant-state.tsx +++ b/apps/studio/state/ai-assistant-state.tsx @@ -1,6 +1,6 @@ import { Chat, type UIMessage as MessageType } from '@ai-sdk/react' import { DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai' -import { LOCAL_STORAGE_KEYS } from 'common' +import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { DBSchema, IDBPDatabase, openDB } from 'idb' import { debounce } from 'lodash' import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' @@ -161,7 +161,7 @@ async function loadFromIndexedDB(projectRef: string): Promise { - const stored = localStorage.getItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) + const stored = safeLocalStorage.getItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) if (!stored) { return null } @@ -185,18 +185,18 @@ async function tryMigrateFromLocalStorage( } else { console.warn('Data in localStorage is not in the expected format, ignoring.') // Clean up invalid data - localStorage.removeItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) + safeLocalStorage.removeItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) } } catch (error) { console.error('Failed to parse state from localStorage:', error) // Clear potentially corrupted data - localStorage.removeItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) + safeLocalStorage.removeItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) } if (migratedState) { try { await saveAiState(migratedState) - localStorage.removeItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) + safeLocalStorage.removeItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) return migratedState } catch (saveError) { console.error('Failed to save migrated state to IndexedDB:', saveError) diff --git a/apps/studio/state/app-state.ts b/apps/studio/state/app-state.ts index 54d2016c782..a6a9aee1ad2 100644 --- a/apps/studio/state/app-state.ts +++ b/apps/studio/state/app-state.ts @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS as COMMON_LOCAL_STORAGE_KEYS } from 'common' +import { LOCAL_STORAGE_KEYS as COMMON_LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common' import { type ConnectSheetSource } from 'common/telemetry-constants' import { proxy, snapshot, useSnapshot } from 'valtio' @@ -36,8 +36,8 @@ export const appState = proxy({ isOptedInTelemetry: false, setIsOptedInTelemetry: (value: boolean | null) => { appState.isOptedInTelemetry = value === null ? false : value - if (typeof window !== 'undefined' && value !== null) { - localStorage.setItem(COMMON_LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT, value.toString()) + if (value !== null) { + safeLocalStorage.setItem(COMMON_LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT, value.toString()) } }, diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx index edfaec99503..8bff2877976 100644 --- a/apps/studio/state/tabs.tsx +++ b/apps/studio/state/tabs.tsx @@ -1,4 +1,4 @@ -import { useParams } from 'common' +import { safeLocalStorage, useParams } from 'common' import { partition } from 'lodash' import { type NextRouter } from 'next/router' import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' @@ -62,9 +62,9 @@ const RECENT_ITEMS_STORAGE_KEY = 'supabase_recent_items' const getRecentItemsStorageKey = (ref: string) => `${RECENT_ITEMS_STORAGE_KEY}_${ref}` function getSavedRecentItems(ref: string): RecentItem[] { - if (typeof window === 'undefined' || !ref) return [] + if (!ref) return [] - const stored = localStorage.getItem(getRecentItemsStorageKey(ref)) + const stored = safeLocalStorage.getItem(getRecentItemsStorageKey(ref)) try { return JSON.parse(stored ?? '{"items": []}').items @@ -84,9 +84,9 @@ const TABS_STORAGE_KEY = 'supabase_studio_tabs' const getTabsStorageKey = (ref: string) => `${TABS_STORAGE_KEY}_${ref}` function getSavedTabs(ref: string) { - if (typeof window === 'undefined' || !ref) return DEFAULT_TABS_STATE + if (!ref) return DEFAULT_TABS_STATE - const stored = localStorage.getItem(getTabsStorageKey(ref)) + const stored = safeLocalStorage.getItem(getTabsStorageKey(ref)) if (!stored) return DEFAULT_TABS_STATE @@ -451,7 +451,7 @@ export const TabsStateContextProvider = ({ children }: PropsWithChildren) => { useEffect(() => { if (typeof window !== 'undefined' && projectRef) { return subscribe(state, () => { - localStorage.setItem( + safeLocalStorage.setItem( getTabsStorageKey(projectRef), JSON.stringify({ activeTab: state.activeTab, @@ -460,7 +460,7 @@ export const TabsStateContextProvider = ({ children }: PropsWithChildren) => { previewTabId: state.previewTabId, }) ) - localStorage.setItem( + safeLocalStorage.setItem( getRecentItemsStorageKey(projectRef), JSON.stringify({ items: state.recentItems, diff --git a/apps/ui-library/components/use-local-storage.tsx b/apps/ui-library/components/use-local-storage.tsx index e08258b4aff..ea99435f8e2 100644 --- a/apps/ui-library/components/use-local-storage.tsx +++ b/apps/ui-library/components/use-local-storage.tsx @@ -1,22 +1,18 @@ // Reference: https://usehooks.com/useLocalStorage/ +import { safeLocalStorage } from 'common' import { useCallback, useState } from 'react' export function useLocalStorage(key: string, initialValue: T) { // State to store our value // Pass initial state function to useState so logic is only executed once const [storedValue, setStoredValue] = useState(() => { - if (typeof window === 'undefined') { - return initialValue - } - + // safeLocalStorage handles SSR and unavailable storage (returns null); + // the try/catch here only guards JSON.parse against corrupt values. + const item = safeLocalStorage.getItem(key) try { - // Get from local storage by key - const item = window.localStorage.getItem(key) - // Parse stored json or if none return initialValue - return item ? JSON.parse(item) : initialValue + return item ? (JSON.parse(item) as T) : initialValue } catch (error) { - // If error also return initialValue - console.log(error) + console.warn(`Failed to parse localStorage value for "${key}"`, error) return initialValue } }) @@ -25,19 +21,12 @@ export function useLocalStorage(key: string, initialValue: T) { // ... persists the new value to localStorage. const setValue = useCallback( (value: T | ((val: T) => T)) => { - try { - // Allow value to be a function so we have same API as useState - const valueToStore = value instanceof Function ? value(storedValue) : value - // Save state - setStoredValue(valueToStore) - // Save to local storage - if (typeof window !== 'undefined') { - window.localStorage.setItem(key, JSON.stringify(valueToStore)) - } - } catch (error) { - // A more advanced implementation would handle the error case - console.log(error) - } + // Allow value to be a function so we have same API as useState + const valueToStore = value instanceof Function ? value(storedValue) : value + // Save state + setStoredValue(valueToStore) + // Persist (safeLocalStorage swallows storage errors internally) + safeLocalStorage.setItem(key, JSON.stringify(valueToStore)) }, [key, storedValue] ) diff --git a/apps/ui-library/context/framework-context.tsx b/apps/ui-library/context/framework-context.tsx index b1b34fca59a..8fa1a040674 100644 --- a/apps/ui-library/context/framework-context.tsx +++ b/apps/ui-library/context/framework-context.tsx @@ -1,5 +1,6 @@ 'use client' +import { safeLocalStorage } from 'common' import { createContext, useContext, useEffect, useState } from 'react' import { frameworkTitles } from '@/config/docs' @@ -17,7 +18,7 @@ export function FrameworkProvider({ children }: { children: React.ReactNode }) { // Initialize from localStorage on mount (client-side only) useEffect(() => { - const storedFramework = localStorage.getItem('preferredFramework') + const storedFramework = safeLocalStorage.getItem('preferredFramework') if (storedFramework && Object.keys(frameworkTitles).includes(storedFramework)) { setFrameworkState(storedFramework as Framework) } @@ -26,7 +27,7 @@ export function FrameworkProvider({ children }: { children: React.ReactNode }) { // Update localStorage when framework changes const setFramework = (newFramework: Framework) => { setFrameworkState(newFramework) - localStorage.setItem('preferredFramework', newFramework) + safeLocalStorage.setItem('preferredFramework', newFramework) } return ( diff --git a/apps/ui-library/lib/storage.ts b/apps/ui-library/lib/storage.ts index 526eca9ee28..6d80fd5173a 100644 --- a/apps/ui-library/lib/storage.ts +++ b/apps/ui-library/lib/storage.ts @@ -1,33 +1,22 @@ -import { LOCAL_STORAGE_KEYS } from 'common' +import { LOCAL_STORAGE_KEYS, safeLocalStorage, safeSessionStorage } from 'common' type LocalStorageKey = (typeof LOCAL_STORAGE_KEYS)[keyof typeof LOCAL_STORAGE_KEYS] type StorageType = 'local' | 'session' function getStorage(storageType: StorageType) { - return storageType === 'local' ? window.localStorage : window.sessionStorage + return storageType === 'local' ? safeLocalStorage : safeSessionStorage } export function store(storageType: StorageType, key: LocalStorageKey, value: string) { - if (typeof window === 'undefined') return - const storage = getStorage(storageType) - - try { - storage.setItem(key as string, value) - } catch { - console.error(`Failed to set storage item with key "${key}"`) - } + getStorage(storageType).setItem(key as string, value) } export function retrieve(storageType: StorageType, key: LocalStorageKey): string | null { - if (typeof window === 'undefined') return null - const storage = getStorage(storageType) - return storage.getItem(key as string) + return getStorage(storageType).getItem(key as string) } export function remove(storageType: StorageType, key: LocalStorageKey) { - if (typeof window === 'undefined') return - const storage = getStorage(storageType) - return storage.removeItem(key as string) + getStorage(storageType).removeItem(key as string) } export function storeOrRemoveNull( @@ -35,7 +24,6 @@ export function storeOrRemoveNull( key: LocalStorageKey, value: string | null | undefined ) { - if (typeof window === 'undefined') return if (value === null || value === undefined) { remove(storageType, key) } else { diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 52c6e93fffe..415dcf936d8 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -1,3 +1,5 @@ +import { safeLocalStorage } from '../safe-storage' + export const LOCAL_STORAGE_KEYS = { /** * STUDIO @@ -164,9 +166,9 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [ ] export function clearLocalStorage() { - for (const key in localStorage) { + for (const key of safeLocalStorage.keys()) { if (!LOCAL_STORAGE_KEYS_ALLOWLIST.includes(key)) { - localStorage.removeItem(key) + safeLocalStorage.removeItem(key) } } } diff --git a/packages/common/hooks/useThemeSandbox.tsx b/packages/common/hooks/useThemeSandbox.tsx index 878dbf1e2f0..cc9c27bc164 100644 --- a/packages/common/hooks/useThemeSandbox.tsx +++ b/packages/common/hooks/useThemeSandbox.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { IS_PROD } from '../constants' +import { safeLocalStorage } from '../safe-storage' const defaultDark: { [name: string]: string } = { '--brand-accent': '160deg 100% 50%', @@ -51,7 +52,7 @@ export const useThemeSandbox = (): any => { const hash = window.location.hash const defaultConfig = defaultDark // use dark default tokens // const defaultConfig = defaultLight // use light default tokens - const localPreset = localStorage.getItem('theme-sandbox') + const localPreset = safeLocalStorage.getItem('theme-sandbox') const isSandbox = hash.includes('#theme-sandbox') || localPreset !== null const [themeConfig, setThemeConfig] = useState( localPreset ? JSON.parse(localPreset) : defaultConfig @@ -65,7 +66,7 @@ export const useThemeSandbox = (): any => { const updateCSSVariables = () => { Object.entries(themeConfig).map(([key, value]) => styles.style.setProperty(key, value)) - localStorage.setItem('theme-sandbox', JSON.stringify(themeConfig)) + safeLocalStorage.setItem('theme-sandbox', JSON.stringify(themeConfig)) } const init = async () => { @@ -76,7 +77,7 @@ export const useThemeSandbox = (): any => { gui.width = 500 Object.entries(defaultConfig).map(([key, _value]) => { - if (!themeConfig[key]) return localStorage.removeItem('theme-sandbox') + if (!themeConfig[key]) return safeLocalStorage.removeItem('theme-sandbox') const folderName = key.split('-')[2] const folder = gui.__folders[folderName] ?? gui.addFolder(folderName) @@ -96,7 +97,7 @@ export const useThemeSandbox = (): any => { gui.destroy() }, 'Reset localStorage': function () { - localStorage.removeItem('theme-sandbox') + safeLocalStorage.removeItem('theme-sandbox') setThemeConfig(defaultConfig) }, } diff --git a/packages/common/index.tsx b/packages/common/index.tsx index ee8ebe13307..13233f82400 100644 --- a/packages/common/index.tsx +++ b/packages/common/index.tsx @@ -11,5 +11,6 @@ export * from './hooks' export * from './MetaFavicons/pages-router' export * from './Providers' export * from './first-referrer-cookie' +export * from './safe-storage' export * from './telemetry' export * from './telemetry-utils' diff --git a/packages/common/posthog-client.ts b/packages/common/posthog-client.ts index 20d03887f49..9374535025c 100644 --- a/packages/common/posthog-client.ts +++ b/packages/common/posthog-client.ts @@ -1,5 +1,7 @@ import posthog, { PostHogConfig } from 'posthog-js' +import { safeSessionStorage } from './safe-storage' + // Limit the max number of queued events // (e.g. if a user navigates around a lot before accepting consent) const MAX_PENDING_EVENTS = 20 @@ -373,11 +375,11 @@ class PostHogClient { const storageKey = `ph_exposed:${experimentId}` try { - if (sessionStorage.getItem(storageKey) === sessionId) return + if (safeSessionStorage.getItem(storageKey) === sessionId) return const eventName = `${experimentId}_experiment_exposed` posthog.capture(eventName, { experiment_id: experimentId, ...properties }) - sessionStorage.setItem(storageKey, sessionId) + safeSessionStorage.setItem(storageKey, sessionId) } catch (error) { console.error('PostHog experiment exposure capture failed:', error) } diff --git a/packages/common/safe-storage.test.ts b/packages/common/safe-storage.test.ts new file mode 100644 index 00000000000..6d9d49f551f --- /dev/null +++ b/packages/common/safe-storage.test.ts @@ -0,0 +1,183 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { safeLocalStorage, safeSessionStorage } from './safe-storage' + +type StorageName = 'localStorage' | 'sessionStorage' + +function createMemoryStorage(): Storage { + const data = new Map() + const methods = { + getItem: (key: string) => (data.has(key) ? data.get(key)! : null), + setItem: (key: string, value: string) => void data.set(key, String(value)), + removeItem: (key: string) => void data.delete(key), + clear: () => data.clear(), + key: (index: number) => Array.from(data.keys())[index] ?? null, + get length() { + return data.size + }, + } + + return new Proxy(methods as unknown as Storage, { + ownKeys: () => Array.from(data.keys()), + getOwnPropertyDescriptor: (_target, prop) => + data.has(prop as string) + ? { enumerable: true, configurable: true, value: data.get(prop as string) } + : undefined, + get: (_target, prop) => (prop in methods ? (methods as any)[prop] : data.get(prop as string)), + }) +} + +function throwingStorage(): Storage { + return new Proxy({} as Storage, { + get() { + throw new DOMException('storage blocked', 'SecurityError') + }, + ownKeys() { + throw new DOMException('storage blocked', 'SecurityError') + }, + }) +} + +function installStorage(name: StorageName, value: Storage) { + Object.defineProperty(window, name, { value, configurable: true, writable: true }) +} + +// Make even reading `window.localStorage` throw (sandboxed iframe, disabled storage, etc) +function installUnavailableStorage(name: StorageName) { + Object.defineProperty(window, name, { + configurable: true, + get() { + throw new Error('storage access denied') + }, + }) +} + +beforeEach(() => { + installStorage('localStorage', createMemoryStorage()) + installStorage('sessionStorage', createMemoryStorage()) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('safeLocalStorage', () => { + describe('happy path', () => { + it('stores and retrieves a value', () => { + safeLocalStorage.setItem('greeting', 'hello') + expect(safeLocalStorage.getItem('greeting')).toBe('hello') + }) + + it('returns null for a missing key', () => { + expect(safeLocalStorage.getItem('does-not-exist')).toBeNull() + }) + + it('removes a value', () => { + safeLocalStorage.setItem('temp', 'value') + safeLocalStorage.removeItem('temp') + expect(safeLocalStorage.getItem('temp')).toBeNull() + }) + + it('lists all keys', () => { + safeLocalStorage.setItem('a', '1') + safeLocalStorage.setItem('b', '2') + const keys = safeLocalStorage.keys() + expect(keys).toHaveLength(2) + expect(keys).toEqual(expect.arrayContaining(['a', 'b'])) + }) + + it('clears all keys', () => { + safeLocalStorage.setItem('a', '1') + safeLocalStorage.setItem('b', '2') + safeLocalStorage.clear() + expect(safeLocalStorage.keys()).toHaveLength(0) + }) + }) + + describe('return types match the native Storage API', () => { + it('write methods return undefined (void)', () => { + expect(safeLocalStorage.setItem('k', 'v')).toBeUndefined() + expect(safeLocalStorage.removeItem('k')).toBeUndefined() + expect(safeLocalStorage.clear()).toBeUndefined() + }) + }) + + describe('when storage methods throw', () => { + beforeEach(() => { + installStorage('localStorage', throwingStorage()) + }) + + it('getItem returns null and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(safeLocalStorage.getItem('fail-get')).toBeNull() + expect(warn).toHaveBeenCalledOnce() + }) + + it('setItem swallows the error and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(() => safeLocalStorage.setItem('fail-set', 'v')).not.toThrow() + expect(warn).toHaveBeenCalledOnce() + }) + + it('removeItem swallows the error and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(() => safeLocalStorage.removeItem('fail-remove')).not.toThrow() + expect(warn).toHaveBeenCalledOnce() + }) + + it('keys returns an empty array and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(safeLocalStorage.keys()).toEqual([]) + expect(warn).toHaveBeenCalledOnce() + }) + + it('clear swallows the error and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(() => safeLocalStorage.clear()).not.toThrow() + expect(warn).toHaveBeenCalledOnce() + }) + + it('warns only once per key+action (dedupes repeated failures)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeLocalStorage.setItem('dedupe-key', 'a') + safeLocalStorage.setItem('dedupe-key', 'b') + safeLocalStorage.setItem('dedupe-key', 'c') + expect(warn).toHaveBeenCalledOnce() + }) + }) + + describe('when storage is entirely unavailable', () => { + beforeEach(() => { + installUnavailableStorage('localStorage') + }) + + it('returns safe defaults without warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(safeLocalStorage.getItem('x')).toBeNull() + expect(safeLocalStorage.keys()).toEqual([]) + expect(() => safeLocalStorage.setItem('x', 'y')).not.toThrow() + expect(() => safeLocalStorage.removeItem('x')).not.toThrow() + expect(() => safeLocalStorage.clear()).not.toThrow() + // Unavailable storage is an expected condition, not a failure to report. + expect(warn).not.toHaveBeenCalled() + }) + }) +}) + +describe('safeSessionStorage', () => { + it('reads and writes independently from localStorage', () => { + safeSessionStorage.setItem('session-key', 'session-value') + expect(safeSessionStorage.getItem('session-key')).toBe('session-value') + // Not visible to localStorage. + expect(safeLocalStorage.getItem('session-key')).toBeNull() + }) + + it('swallows errors when session storage methods throw', () => { + installStorage('sessionStorage', throwingStorage()) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(safeSessionStorage.getItem('session-fail')).toBeNull() + expect(() => safeSessionStorage.setItem('session-fail', 'v')).not.toThrow() + expect(warn).toHaveBeenCalled() + }) +}) diff --git a/packages/common/safe-storage.ts b/packages/common/safe-storage.ts new file mode 100644 index 00000000000..650bbf3c1d1 --- /dev/null +++ b/packages/common/safe-storage.ts @@ -0,0 +1,84 @@ +export type StorageKind = 'local' | 'session' + +// [Ali] Dedupe warnings so a fully-blocked environment doesn't flood the console with +// the same message on every read/write. Keyed by kind + action + storage key. +const warnedKeys = new Set() + +function reportFailure(kind: StorageKind, action: string, key: string, error: unknown) { + const dedupeKey = `${kind}:${action}:${key}` + if (warnedKeys.has(dedupeKey)) return + warnedKeys.add(dedupeKey) + + console.warn( + `[safe-storage] ${kind}Storage.${action}("${key}") failed; continuing without persistence.`, + error + ) +} + +function getBackingStore(kind: StorageKind): Storage | null { + if (typeof window === 'undefined') return null + try { + return kind === 'local' ? window.localStorage : window.sessionStorage + } catch { + return null + } +} + +function createSafeStorage(kind: StorageKind) { + return { + getItem(key: string): string | null { + const store = getBackingStore(kind) + if (store === null) return null + try { + return store.getItem(key) + } catch (error) { + reportFailure(kind, 'getItem', key, error) + return null + } + }, + + setItem(key: string, value: string): void { + const store = getBackingStore(kind) + if (store === null) return + try { + store.setItem(key, value) + } catch (error) { + reportFailure(kind, 'setItem', key, error) + } + }, + + removeItem(key: string): void { + const store = getBackingStore(kind) + if (store === null) return + try { + store.removeItem(key) + } catch (error) { + reportFailure(kind, 'removeItem', key, error) + } + }, + + keys(): string[] { + const store = getBackingStore(kind) + if (store === null) return [] + try { + return Object.keys(store) + } catch (error) { + reportFailure(kind, 'keys', '*', error) + return [] + } + }, + + clear(): void { + const store = getBackingStore(kind) + if (store === null) return + try { + store.clear() + } catch (error) { + reportFailure(kind, 'clear', '*', error) + } + }, + } +} + +export const safeLocalStorage = createSafeStorage('local') +export const safeSessionStorage = createSafeStorage('session') diff --git a/packages/ui/src/components/shadcn/ui/resizable.tsx b/packages/ui/src/components/shadcn/ui/resizable.tsx index 27a5a5eb772..71f25d039da 100644 --- a/packages/ui/src/components/shadcn/ui/resizable.tsx +++ b/packages/ui/src/components/shadcn/ui/resizable.tsx @@ -9,16 +9,25 @@ import { cn } from '../../../lib/utils/cn' const transformLayoutKey = (key: string) => key.replace('react-resizable-panels:', 'react-resizable-panels-v4:') +// Reading/writing localStorage can throw or be unavailable (SSR, Safari private +// browsing, sandboxed iframes, storage disabled). Persistence is best-effort, so +// swallow failures rather than crashing the panel group. const serverCompatibleLocalStorage = { getItem: (k: string) => { if (typeof window === 'undefined') return null - const key = transformLayoutKey(k) - return localStorage.getItem(key) + try { + return localStorage.getItem(transformLayoutKey(k)) + } catch { + return null + } }, setItem: (k: string, value: string) => { if (typeof window === 'undefined') return - const key = transformLayoutKey(k) - localStorage.setItem(key, value) + try { + localStorage.setItem(transformLayoutKey(k), value) + } catch { + // Silently ignore — layout persistence is non-critical. + } }, }