) => (
,
h5: (props) => ,
h6: (props) => ,
- p: Paragraph,
- a: Anchor,
code: Code,
img: Img,
- ul: UnorderedList,
- ol: OrderedList,
- li: ListItem,
blockquote: Blockquote,
- hr: Hr,
pre: DefaultPre,
- table: Table,
- tr: Tr,
- th: Th,
- td: Td,
}
interface MarkdownProps extends Omit {
@@ -83,7 +57,7 @@ export function Markdown({
}
return (
-
+
& {
actions?: React.ReactNode
containerClassName?: string
+ slotClassName?: string
}
/**
@@ -47,10 +48,11 @@ const PageBreadcrumbs = ({
className,
children,
containerClassName,
+ slotClassName,
...props
}: PageBreadcrumbsProps) => {
return (
-
+
=3.0.1'
@@ -31417,7 +31421,7 @@ snapshots:
dependencies:
js-yaml-loader: 1.2.2
- next-router-mock@0.9.13(next@16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react@19.2.6):
+ next-router-mock@0.9.13(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4))(react@19.2.6):
dependencies:
next: 16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4)
react: 19.2.6
From 1c2d28d5b3b949e270e7212aa0fbed85de3157dd Mon Sep 17 00:00:00 2001
From: Ali Waseem
Date: Thu, 4 Jun 2026 07:41:28 -0600
Subject: [PATCH 3/4] 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
## 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.
---
.../components/grid/SupabaseGrid.utils.ts | 9 +-
.../Preferences/DeleteAccountButton.tsx | 8 +-
.../FeaturePreview/FeaturePreviewContext.tsx | 22 +--
.../Database/Schemas/Schemas.utils.ts | 4 +-
.../DefaultPreviewSelectionRenderer.tsx | 5 +-
.../interfaces/SignIn/SessionTimeoutModal.tsx | 10 +-
.../Storage/PublicBucketWarning.tsx | 6 +-
.../StorageExplorer/useStoragePreference.ts | 5 +-
.../ui/Charts/useChartHoverState.test.tsx | 7 +-
.../ui/Charts/useChartHoverState.tsx | 37 ++--
.../ClientSideExceptionHandler.tsx | 9 +-
.../github-authorization-create-mutation.ts | 6 +-
apps/studio/hooks/misc/useLocalStorage.ts | 45 ++---
apps/studio/hooks/misc/useSchemaQueryState.ts | 10 +-
apps/studio/lib/github.ts | 4 +-
apps/studio/lib/project-transition-state.ts | 10 +-
apps/studio/lib/telemetry.tsx | 6 +-
apps/studio/state/ai-assistant-state.tsx | 10 +-
apps/studio/state/app-state.ts | 6 +-
apps/studio/state/tabs.tsx | 14 +-
.../components/use-local-storage.tsx | 35 ++--
apps/ui-library/context/framework-context.tsx | 5 +-
apps/ui-library/lib/storage.ts | 22 +--
packages/common/constants/local-storage.ts | 6 +-
packages/common/hooks/useThemeSandbox.tsx | 9 +-
packages/common/index.tsx | 1 +
packages/common/posthog-client.ts | 6 +-
packages/common/safe-storage.test.ts | 183 ++++++++++++++++++
packages/common/safe-storage.ts | 84 ++++++++
.../ui/src/components/shadcn/ui/resizable.tsx | 17 +-
30 files changed, 411 insertions(+), 190 deletions(-)
create mode 100644 packages/common/safe-storage.test.ts
create mode 100644 packages/common/safe-storage.ts
diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts
index 002709c89711b..200fd880e88c8 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 84e06cad8ed6a..244fdd24f2e94 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 472fe6ccf7973..fad16eb4c9ccd 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 b7a72e7825a60..9b2d24d73a63d 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 5839e064a1d42..a89183886acc5 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 47ed100e9598c..24782d2854e10 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 66beac82352ac..c7ac2ca868b8b 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 6b8a7a46b0315..9e484b50bfe43 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 0adb9a8a49bc5..c887a82b30e30 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 7c9ad21a9cf02..d1d2564393cd0 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 a30da4ae221c4..0ecd43f484e89 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 d54d091d374ed..33851540c7ce0 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 73bce8d7c58cc..ba56394e20da1 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 e78eeb4976587..d74c7c6555e2d 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 ddbb462cf5bd7..362a98c34443d 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 dda2c627fc50c..871533bc81d87 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 14287a8bb69ab..9ae59ef744198 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 ffd206905ff34..7c31bfef138f5 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 54d2016c78293..a6a9aee1ad2e3 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 edfaec99503bb..8bff28779762a 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 e08258b4aff32..ea99435f8e272 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 b1b34fca59ae4..8fa1a04067433 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 526eca9ee28bf..6d80fd5173a49 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 52c6e93fffeed..415dcf936d82f 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 878dbf1e2f0e1..cc9c27bc16406 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 ee8ebe133070c..13233f824006c 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 20d03887f4945..9374535025c77 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 0000000000000..6d9d49f551f62
--- /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 0000000000000..650bbf3c1d15a
--- /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 27a5a5eb77259..71f25d039da0d 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.
+ }
},
}
From a776b54863ea7e489b9506ece29c5a2fa31769e2 Mon Sep 17 00:00:00 2001
From: Ali Waseem
Date: Thu, 4 Jun 2026 07:41:39 -0600
Subject: [PATCH 4/4] fix(studio): show role permission descriptions in edit
access drawer (#46627)
Mirrors the recent invite drawer change (#46515) on the edit access
drawer. Each role option now describes its permissions via the shared
\`ROLE_DESCRIPTIONS\` map instead of showing just the role name.
Closes FE-3524.
## Summary by CodeRabbit
* **New Features**
* Role selection in Team Settings now shows full, role-specific
permission descriptions and appends any disabled-reason details for
clarity.
* **Tests**
* Added integration tests covering the role panel UI: role listing,
selected role label, documentation link, role-specific descriptions, and
an admin-safety notice; includes test environment compatibility stubs
for animations and routing.
---
.../UpdateRolesPanel/UpdateRolesPanel.tsx | 17 +-
.../UpdateRolesPanel.network.test.tsx | 196 ++++++++++++++++++
2 files changed, 207 insertions(+), 6 deletions(-)
create mode 100644 apps/studio/tests/components/Organization/TeamSettings/UpdateRolesPanel.network.test.tsx
diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx
index 127157cd7a19b..dbf645bfb8e05 100644
--- a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx
+++ b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx
@@ -28,6 +28,7 @@ import {
WarningIcon,
} from 'ui'
+import { ROLE_DESCRIPTIONS } from '../Roles.constants'
import { useGetRolesManagementPermissions } from '../TeamSettings.utils'
import { UpdateRolesConfirmationModal } from './UpdateRolesConfirmationModal'
import {
@@ -295,13 +296,17 @@ export const UpdateRolesPanel = ({ visible, member, onClose }: UpdateRolesPanelP
className="text-sm hover:bg-selection cursor-pointer"
disabled={disabled}
>
-
+
{role.name}
- {disabledReason && (
-
- {disabledReason}
-
- )}
+
+ {[
+ ROLE_DESCRIPTIONS[role.name] ??
+ 'Permissions are based on the configured organization role.',
+ disabledReason,
+ ]
+ .filter(Boolean)
+ .join(' ')}
+
)
diff --git a/apps/studio/tests/components/Organization/TeamSettings/UpdateRolesPanel.network.test.tsx b/apps/studio/tests/components/Organization/TeamSettings/UpdateRolesPanel.network.test.tsx
new file mode 100644
index 0000000000000..c8c4ca7bbabae
--- /dev/null
+++ b/apps/studio/tests/components/Organization/TeamSettings/UpdateRolesPanel.network.test.tsx
@@ -0,0 +1,196 @@
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { platformComponents as components } from 'api-types'
+import { mockAnimationsApi } from 'jsdom-testing-mocks'
+import { HttpResponse } from 'msw'
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+
+import { UpdateRolesPanel } from '@/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel'
+import type { OrganizationMember } from '@/data/organizations/organization-members-query'
+import type { ProfileContextType } from '@/lib/profile'
+import { createMockOrganizationResponse } from '@/tests/helpers'
+import { customRender } from '@/tests/lib/custom-render'
+import { addAPIMock } from '@/tests/lib/msw'
+
+type OrganizationResponse = components['schemas']['OrganizationResponse']
+type OrganizationRoleResponse = components['schemas']['OrganizationRoleResponse']
+type OrganizationProjectsResponse = components['schemas']['OrganizationProjectsResponse']
+type ListEntitlementsResponse = components['schemas']['ListEntitlementsResponse']
+type AccessControlPermission = components['schemas']['AccessControlPermission']
+
+const ORG_SLUG = 'test-org'
+
+const ROLE_IDS = { owner: 1, administrator: 2, developer: 3, readOnly: 4 } as const
+
+// Sheet + Select are built on Radix, which relies on the Web Animations API
+mockAnimationsApi()
+// Radix Select scrolls the active option into view when the dropdown opens
+window.HTMLElement.prototype.scrollIntoView = vi.fn()
+
+vi.mock('common', async (importOriginal) => {
+ const actual = (await importOriginal()) as typeof import('common')
+ return {
+ ...actual,
+ useParams: () => ({ slug: ORG_SLUG }),
+ useIsLoggedIn: () => true,
+ }
+})
+
+vi.mock('@/lib/constants', async (importOriginal) => {
+ const actual = await importOriginal
>()
+ return { ...actual, IS_PLATFORM: true }
+})
+
+const PROFILE_CONTEXT: ProfileContextType = {
+ profile: {
+ id: 1,
+ auth0_id: 'auth0|test',
+ gotrue_id: 'gotrue-test',
+ username: 'testuser',
+ primary_email: 'me@example.com',
+ first_name: null,
+ last_name: null,
+ mobile: null,
+ is_alpha_user: false,
+ is_sso_user: false,
+ disabled_features: [],
+ free_project_limit: null,
+ },
+ error: null,
+ isLoading: false,
+ isError: false,
+ isSuccess: true,
+}
+
+const buildRole = (id: number, name: string): OrganizationRoleResponse['org_scoped_roles'][0] => ({
+ id,
+ name,
+ base_role_id: id,
+ description: null,
+ projects: [],
+})
+
+const buildPermission = (resource: string): AccessControlPermission => ({
+ actions: ['write:Create', 'write:Delete'],
+ condition: null,
+ organization_id: 1,
+ organization_slug: ORG_SLUG,
+ project_ids: [],
+ project_refs: [],
+ resources: [resource],
+ restrictive: false,
+})
+
+const MEMBER: OrganizationMember = {
+ gotrue_id: 'gotrue-member',
+ is_sso_user: false,
+ metadata: {},
+ mfa_enabled: false,
+ primary_email: 'member@example.com',
+ role_ids: [ROLE_IDS.developer],
+ username: 'member',
+}
+
+function setupMocks() {
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations',
+ response: () =>
+ HttpResponse.json([
+ createMockOrganizationResponse({ id: 1, slug: ORG_SLUG, name: 'Test Org' }),
+ ]),
+ })
+
+ addAPIMock({
+ method: 'get',
+ path: '/platform/profile/permissions',
+ response: () =>
+ HttpResponse.json([buildPermission('auth.subject_roles')]),
+ })
+
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations/:slug/roles',
+ response: () =>
+ HttpResponse.json({
+ org_scoped_roles: [
+ buildRole(ROLE_IDS.owner, 'Owner'),
+ buildRole(ROLE_IDS.administrator, 'Administrator'),
+ buildRole(ROLE_IDS.developer, 'Developer'),
+ buildRole(ROLE_IDS.readOnly, 'Read-only'),
+ ],
+ project_scoped_roles: [],
+ }),
+ })
+
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations/:slug/projects',
+ response: () =>
+ HttpResponse.json({
+ pagination: { count: 0, limit: 96, offset: 0 },
+ projects: [],
+ }),
+ })
+
+ // Empty entitlements => project-level permissions are off, so the panel
+ // renders the org-scoped role flow (a single Select).
+ addAPIMock({
+ method: 'get',
+ path: '/platform/organizations/:slug/entitlements',
+ response: () => HttpResponse.json({ entitlements: [] }),
+ })
+}
+
+describe('UpdateRolesPanel (network)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ test('shows each role with its permission description and a roles docs link', async () => {
+ setupMocks()
+ customRender(, {
+ profileContext: PROFILE_CONTEXT,
+ })
+
+ // The current (Developer) role is reflected in the trigger
+ const trigger = await screen.findByRole('combobox')
+ expect(trigger).toHaveTextContent('Developer')
+
+ // Roles documentation is one click away (the ticket's other complaint).
+ // Asserted before opening the dropdown, which aria-hides the rest of the sheet.
+ const docsLink = screen.getByRole('link', { name: /docs/i })
+ expect(docsLink).toHaveAttribute(
+ 'href',
+ expect.stringContaining('/guides/platform/access-control')
+ )
+
+ // Open the role dropdown to reveal all the options
+ await userEvent.click(trigger)
+
+ // Every role is listed as an option
+ expect(await screen.findByRole('option', { name: /Owner/ })).toBeInTheDocument()
+ expect(screen.getByRole('option', { name: /Administrator/ })).toBeInTheDocument()
+ expect(screen.getByRole('option', { name: /Developer/ })).toBeInTheDocument()
+ expect(screen.getByRole('option', { name: /Read-only/ })).toBeInTheDocument()
+
+ // The key safety message from the ticket: Administrator can delete projects
+ expect(screen.getByText(/including deleting projects/i)).toBeInTheDocument()
+ })
+
+ test('renders a role description for every known role', async () => {
+ setupMocks()
+ customRender(, {
+ profileContext: PROFILE_CONTEXT,
+ })
+
+ await userEvent.click(await screen.findByRole('combobox'))
+
+ await waitFor(() => {
+ expect(screen.getByText(/deleting the organization/i)).toBeInTheDocument()
+ })
+ expect(screen.getByText(/Manage members, billing/i)).toBeInTheDocument()
+ expect(screen.getByText(/Manage project content/i)).toBeInTheDocument()
+ expect(screen.getByText(/limited to SELECT queries/i)).toBeInTheDocument()
+ })
+})