mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore: wrap local storage into helper methods that are safer (#46628)
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? - Noticing our code we have many patterns of calling localstorage and handling those errors - We should add those in a single well tested file - Handle those errors in the singleton which makes it easier for us to debug customer issues. Logger is outputing local storage warnings for feature we expose - Side effect of this is random crashes on studio when local storage isn't available or handled correctly <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Improved browser storage handling across the app for more reliable persistence and graceful behavior in restricted or non-browser environments (settings, previews, charts, tabs, sign-in/session flows, integrations, and UI state). * **New Features** * Introduced a safe storage layer to standardize and harden local/session persistence. * **Tests** * Added comprehensive tests covering the new safe storage behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
6470ac9186
commit
1c2d28d5b3
30 files changed
+411
-190
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3
-2
@@ -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 = () => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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() })
|
||||
)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<ChartHoverState>) {
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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<T>(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<T>(() => {
|
||||
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<T>(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<T>(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<T>(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 })
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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<StoredAiAssistantS
|
||||
async function tryMigrateFromLocalStorage(
|
||||
projectRef: string
|
||||
): Promise<StoredAiAssistantState | null> {
|
||||
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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
// Reference: https://usehooks.com/useLocalStorage/
|
||||
import { safeLocalStorage } from 'common'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export function useLocalStorage<T>(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<T>(() => {
|
||||
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<T>(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]
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<string, string>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<string>()
|
||||
|
||||
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')
|
||||
@@ -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.
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user