diff --git a/apps/design-system/content/docs/ui-patterns/markdown.mdx b/apps/design-system/content/docs/ui-patterns/markdown.mdx
index 0b6e4a0d8b034..ac69e60d50654 100644
--- a/apps/design-system/content/docs/ui-patterns/markdown.mdx
+++ b/apps/design-system/content/docs/ui-patterns/markdown.mdx
@@ -37,17 +37,8 @@ import {
Code,
CodeBlockPre,
DefaultPre,
- H1,
- H2,
- H3,
- H4,
- H5,
- H6,
Hr,
Img,
- InlineCode,
- ListItem,
- OrderedList,
Paragraph,
Quote,
SimplePre,
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/BranchManagement/Overview.tsx b/apps/studio/components/interfaces/BranchManagement/Overview.tsx
index af133208449d1..bec2163e23bee 100644
--- a/apps/studio/components/interfaces/BranchManagement/Overview.tsx
+++ b/apps/studio/components/interfaces/BranchManagement/Overview.tsx
@@ -8,6 +8,7 @@ import {
Infinity,
MoreVertical,
Pencil,
+ Redo,
RefreshCw,
Shield,
Trash2,
@@ -30,6 +31,7 @@ import { EditBranchModal } from './EditBranchModal'
import { PreviewBranchesEmptyState } from './EmptyStates'
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
+import { useBranchPushMutation } from '@/data/branches/branch-push-mutation'
import { useBranchQuery } from '@/data/branches/branch-query'
import { useBranchResetMutation } from '@/data/branches/branch-reset-mutation'
import { useBranchRestoreMutation } from '@/data/branches/branch-restore-mutation'
@@ -262,6 +264,7 @@ const PreviewBranchActions = ({
setShowPersistentBranchDeleteConfirmationModal,
] = useState(false)
const [showEditBranchModal, setShowEditBranchModal] = useState(false)
+ const [showConfirmRetriggersModal, setShowConfirmRetriggersModal] = useState(false)
const { mutate: resetBranch, isPending: isResetting } = useBranchResetMutation({
onSuccess() {
@@ -286,6 +289,20 @@ const PreviewBranchActions = ({
},
})
+ const { mutate: branchPushMutate, isPending: isRetriggering } = useBranchPushMutation({
+ onSuccess() {
+ toast.success('Success! Please allow a few minutes for the branch to update.')
+ setShowConfirmRetriggersModal(false)
+ },
+ onError: (data) => {
+ toast.error(`Failed to trigger workflow: ${data.message}`)
+ },
+ })
+
+ const onRetriggerBranch = () => {
+ branchPushMutate({ branchRef, projectRef })
+ }
+
const onRestoreBranch = () => {
restoreBranch({ branchRef, projectRef })
}
@@ -345,28 +362,54 @@ const PreviewBranchActions = ({
{!branch.deletion_scheduled_at && (
- {
- e.stopPropagation()
- setShowConfirmResetModal(true)
- }}
- onClick={(e) => {
- e.stopPropagation()
- setShowConfirmResetModal(true)
- }}
- tooltip={{
- content: {
- side: 'left',
- text: !isBranchActiveHealthy
- ? 'Branch is still initializing. Please wait for it to become healthy before resetting.'
- : undefined,
- },
- }}
- >
- Reset branch
-
+ <>
+ {
+ e.stopPropagation()
+ setShowConfirmRetriggersModal(true)
+ }}
+ onClick={(e) => {
+ e.stopPropagation()
+ setShowConfirmRetriggersModal(true)
+ }}
+ tooltip={{
+ content: {
+ side: 'left',
+ text: !canUpdateBranches
+ ? `You need additional permissions to ${branch.git_branch ? 'resync' : 'rebase'} branches`
+ : !isBranchActiveHealthy
+ ? `Branch is still initializing. Please wait for it to become healthy before ${branch.git_branch ? 'resyncing' : 'rebasing'}.`
+ : undefined,
+ },
+ }}
+ >
+ {branch.git_branch ? 'Resync branch' : 'Rebase branch'}
+
+ {
+ e.stopPropagation()
+ setShowConfirmResetModal(true)
+ }}
+ onClick={(e) => {
+ e.stopPropagation()
+ setShowConfirmResetModal(true)
+ }}
+ tooltip={{
+ content: {
+ side: 'left',
+ text: !isBranchActiveHealthy
+ ? 'Branch is still initializing. Please wait for it to become healthy before resetting.'
+ : undefined,
+ },
+ }}
+ >
+ Reset branch
+
+ >
)}
{!branch.deletion_scheduled_at && (
@@ -497,10 +540,26 @@ const PreviewBranchActions = ({
+ setShowConfirmRetriggersModal(false)}
+ onConfirm={onRetriggerBranch}
+ >
+
+ {branch.git_branch
+ ? 'This will re-run all steps of the workflow based on the latest git branch state.'
+ : 'This will re-run all steps of the workflow based on the latest dashboard state.'}
+
+
+
setShowPersistentBranchDeleteConfirmationModal(false)}
diff --git a/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx b/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx
index f5967e6d7be7f..307289013f2f0 100644
--- a/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx
+++ b/apps/studio/components/interfaces/BranchManagement/WorkflowLogs.tsx
@@ -111,7 +111,7 @@ export const WorkflowLogs = ({ branch }: WorkflowLogsProps) => {
(workflowRuns.length > 0 ? (
{workflowRuns.map((workflowRun) => (
-
+
{
- const [selected, setSelected] = useState(files[0])
- const [showDialog, setShowDialog] = useState(false)
+import 'swiper/css'
+import { Swiper, SwiperSlide } from 'swiper/react'
+import { Image } from 'ui-patterns/Image'
+
+export const FilesViewer = ({ files }: { files: { src: string; alt: string }[] }) => {
return (
- <>
-
-
setShowDialog(true)}>
-
+ {files.map((file, i) => (
+
+
-
-
- {files.length > 1 && (
-
- {files.map((x) => (
-
setSelected(x)}>
-
-
- ))}
-
- )}
-
-
-
-
-
-
- >
+
+ ))}
+
)
}
diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/MarkdownContent.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/MarkdownContent.tsx
index a4d46551f1ab3..e5fcb83e8e5b1 100644
--- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/MarkdownContent.tsx
+++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/MarkdownContent.tsx
@@ -33,5 +33,5 @@ export const MarkdownContent = ({
const content = remoteContent || localContent
- return {content}
+ return {content}
}
diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx
index 617ba40e1e779..c650c29d021d5 100644
--- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx
+++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx
@@ -69,7 +69,7 @@ export type IntegrationDefinition = {
icon: (props?: { className?: string; style?: Record }) => ReactNode
description: string | null
content?: string | null
- files?: string[]
+ files?: { src: string; alt: string }[]
docsUrl: string | null
siteUrl?: string | null
author: {
diff --git a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx
index 581c4cf3e1557..a65e1167846cc 100644
--- a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx
+++ b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx
@@ -97,7 +97,10 @@ export const useAvailableIntegrations = () => {
source: 'Partner' as const,
categories: categories.map((x) => x.slug),
content,
- files: images?.map((image) => fullImageUrl(image)),
+ files: images?.map((image, i) => ({
+ src: fullImageUrl(image),
+ alt: `${title} image ${i + 1}`,
+ })),
description,
docsUrl,
siteUrl,
@@ -186,7 +189,10 @@ export const useAvailableIntegrations = () => {
docsUrl,
siteUrl,
author: authorName ? { name: authorName, websiteUrl: '' } : undefined,
- files: images?.map((image) => fullImageUrl(image)),
+ files: images?.map((image, i) => ({
+ src: fullImageUrl(image),
+ alt: `${title} screenshot ${i + 1}`,
+ })),
icon: listingLogo ? renderMarketplaceLogo(listingLogo) : undefined,
}
diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx
index 28421079dc969..f0ab5d79339ee 100644
--- a/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx
+++ b/apps/studio/components/interfaces/Integrations/Marketplace/Marketplace.constants.tsx
@@ -33,8 +33,8 @@ export type { MarketplaceSource } from '@/components/interfaces/Integrations/Lan
// Defines featured integrations and their order in the featured hero
export const FEATURED_INTEGRATION_IDS = [
- 'grafana',
'stripe_sync_engine',
+ 'grafana',
'aikido',
'aikido-security',
'doppler',
diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx
index 1297da2759812..0562b10799ec3 100644
--- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx
+++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetail.tsx
@@ -3,8 +3,8 @@ import { Button, cn } from 'ui'
import { GenericSkeletonLoader, ShimmeringLoader } from 'ui-patterns'
import { Admonition } from 'ui-patterns/admonition'
+import { MarketplaceDetailBreadrumbs } from './MarketplaceDetailBreadcrumbs'
import { MarketplaceDetailHero } from './MarketplaceDetailHero'
-import { MarketplaceDetailTopBar } from './MarketplaceDetailTopBar'
import { OverviewTab } from './OverviewTab'
import { IntegrationDetailTabShortcuts } from '@/components/interfaces/Integrations/Integration/IntegrationDetailTabShortcuts'
import { InstallIntegrationSheet } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallIntegrationSheet'
@@ -38,9 +38,9 @@ export const MarketplaceDetail = () => {
if (isAvailableLoading || isInstalledLoading) {
return (
<>
-
-
-
+
+
+
@@ -55,7 +55,7 @@ export const MarketplaceDetail = () => {
if (!integration) {
return (
<>
-
+
Please try again later or contact support if the problem persists.
@@ -86,7 +86,7 @@ export const MarketplaceDetail = () => {
return (
<>
- {
+ const { ref } = useParams()
+
+ return (
+
+ {isLoading ? : actions}
+
+ }
+ >
+
+
+
+ Integrations
+
+
+
+
+
+ {isLoading ? : title}
+
+ {isInstalled && Installed }
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailRail.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailRail.tsx
index 101c76773b9c6..88e04e8a51704 100644
--- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailRail.tsx
+++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailRail.tsx
@@ -43,13 +43,13 @@ const RailRow = ({ label, value, href, mono, icon }: RailRowProps) => {
}
interface RailGroupProps {
- title: string
+ title?: string
children: ReactNode
}
const RailGroup = ({ title, children }: RailGroupProps) => (
-
{title}
+ {title &&
{title}
}
{children}
)
@@ -75,8 +75,8 @@ export const MarketplaceDetailRail = ({ integration, isInstalled }: MarketplaceD
const siteHost = tryHostname(siteUrl)
return (
-
-
+
+
{isInstalled && }
diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailTopBar.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailTopBar.tsx
deleted file mode 100644
index 9c81a495d7216..0000000000000
--- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceDetailTopBar.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { useParams } from 'common'
-import Link from 'next/link'
-import type { ReactNode } from 'react'
-import {
- Badge,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbList,
- BreadcrumbPage,
- BreadcrumbSeparator,
-} from 'ui'
-
-interface MarketplaceDetailTopBarProps {
- title: string
- actions?: ReactNode
- isInstalled?: boolean
-}
-
-export const MarketplaceDetailTopBar = ({
- title,
- isInstalled,
- actions,
-}: MarketplaceDetailTopBarProps) => {
- const { ref } = useParams()
-
- return (
-
-
-
-
-
- Integrations
-
-
-
-
- {title}
- {isInstalled && Installed }
-
-
-
-
-
{actions}
-
- )
-}
diff --git a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFilterBar.tsx b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFilterBar.tsx
index 28742635421df..23bf98fc4a8a8 100644
--- a/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFilterBar.tsx
+++ b/apps/studio/components/interfaces/Integrations/Marketplace/MarketplaceFilterBar.tsx
@@ -79,7 +79,7 @@ export const MarketplaceFilterBar = ({
return (
+ import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/FilesViewer').then(
+ (mod) => mod.FilesViewer
+ )
+)
+
interface OverviewTabProps extends PropsWithChildren {
integration: IntegrationDefinition
isInstalled: boolean
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/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/branches/branch-push-mutation.ts b/apps/studio/data/branches/branch-push-mutation.ts
index 2a2055a73a924..4d70401f7db33 100644
--- a/apps/studio/data/branches/branch-push-mutation.ts
+++ b/apps/studio/data/branches/branch-push-mutation.ts
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
+import { actionKeys } from '../actions/keys'
import { branchKeys } from './keys'
import { handleError, post } from '@/data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
@@ -36,6 +37,7 @@ export const useBranchPushMutation = ({
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries({ queryKey: branchKeys.list(projectRef) })
+ await queryClient.invalidateQueries({ queryKey: actionKeys.list(projectRef) })
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
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/package.json b/apps/studio/package.json
index 080a7853ba13d..a9632ed86f031 100644
--- a/apps/studio/package.json
+++ b/apps/studio/package.json
@@ -135,6 +135,7 @@
"sqlstring": "^2.3.2",
"streamdown": "^1.3.0",
"stripe-experiment-sync": "1.0.31",
+ "swiper": "^12.1.2",
"tus-js-client": "^4.1.0",
"ui": "workspace:*",
"ui-patterns": "workspace:*",
diff --git a/apps/studio/pages/project/[ref]/integrations/index.tsx b/apps/studio/pages/project/[ref]/integrations/index.tsx
index 61ad4429f6999..970de70a53d97 100644
--- a/apps/studio/pages/project/[ref]/integrations/index.tsx
+++ b/apps/studio/pages/project/[ref]/integrations/index.tsx
@@ -54,7 +54,7 @@ function getIntegrationImage(integration: IntegrationDefinition) {
if (integration.files?.length) {
const heroImage = integration?.files?.[0]
- return heroImage
+ return heroImage?.src ?? undefined
}
}
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/studio/styles/globals.css b/apps/studio/styles/globals.css
index 0e44329ecd6b1..f312b19cfa811 100644
--- a/apps/studio/styles/globals.css
+++ b/apps/studio/styles/globals.css
@@ -412,3 +412,32 @@ div[data-radix-portal]:not(.portal--toast) {
border-color: transparent;
}
}
+
+/* Zoomable image (react-medium-image-zoom) */
+
+[data-rmiz-modal]:focus,
+[data-rmiz-modal-overlay]:focus {
+ outline: none !important;
+}
+
+[data-rmiz-modal-overlay],
+[data-rmiz-modal-img] {
+ transition-timing-function: cubic-bezier(0.24, 0.25, 0.05, 1) !important;
+}
+
+[data-rmiz-modal-overlay='visible'] {
+ @apply bg-background!;
+ opacity: 0.8;
+}
+
+[data-rmiz-modal-img] {
+ image-rendering: high-quality;
+}
+
+.prose.text-sm ol > li::before {
+ top: 0.05rem;
+}
+
+.prose.text-sm ul > li::before {
+ top: 0.65rem;
+}
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()
+ })
+})
diff --git a/apps/studio/types/config.d.ts b/apps/studio/types/config.d.ts
index c8ce1d21d5602..ac2b7e9d549e8 100644
--- a/apps/studio/types/config.d.ts
+++ b/apps/studio/types/config.d.ts
@@ -1,3 +1,5 @@
+declare module 'swiper/css'
+
declare module 'config/tailwind.config' {
import type { Config } from 'tailwindcss'
const wrapper: (tailwindConfig: Partial) => Config
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/apps/www/components/BuiltWithSupabase/ExamplesMobile.tsx b/apps/www/components/BuiltWithSupabase/ExamplesMobile.tsx
index 39219d6dd1cc2..2e327f1129c40 100644
--- a/apps/www/components/BuiltWithSupabase/ExamplesMobile.tsx
+++ b/apps/www/components/BuiltWithSupabase/ExamplesMobile.tsx
@@ -1,12 +1,12 @@
import 'swiper/css'
-import React, { FC } from 'react'
+import content from '~/data/home/content'
+import type { Example } from 'data/Examples'
import { useRouter } from 'next/router'
+import React, { FC } from 'react'
import { Swiper, SwiperSlide } from 'swiper/react'
-import ExampleCard from '../ExampleCard'
-import content from '~/data/home/content'
-import type { Example } from 'data/Examples'
+import ExampleCard from '../ExampleCard'
interface Props {
examples: Example[]
diff --git a/apps/www/public/.well-known/agent-skills/index.json b/apps/www/public/.well-known/agent-skills/index.json
new file mode 100644
index 0000000000000..718d9e4d63c4d
--- /dev/null
+++ b/apps/www/public/.well-known/agent-skills/index.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
+ "skills": [
+ {
+ "name": "supabase",
+ "type": "archive",
+ "description": "Use when doing ANY task involving Supabase. Triggers: Supabase products (Database, Auth, Edge Functions, Realtime, Storage, Vectors, Cron, Queues); client libraries and SSR integrations (supabase-js, @supabase/ssr) in Next.js, React, SvelteKit, Astro, Remix; auth issues (login, logout, sessions, JWT, cookies, getSession, getUser, getClaims, RLS); Supabase CLI or MCP server; schema changes, migrations, security audits, Postgres extensions (pg_graphql, pg_cron, pg_vector).",
+ "url": "https://github.com/supabase/agent-skills/releases/download/v0.1.5/supabase.tar.gz",
+ "digest": "sha256:5acb0974e54db43ac66fcb55170dee2059b423d5253a9c1f14a51df44358f07a"
+ },
+ {
+ "name": "supabase-postgres-best-practices",
+ "type": "archive",
+ "description": "Postgres performance optimization and best practices from Supabase. Use this skill when writing, reviewing, or optimizing Postgres queries, schema designs, or database configurations.",
+ "url": "https://github.com/supabase/agent-skills/releases/download/v0.1.5/supabase-postgres-best-practices.tar.gz",
+ "digest": "sha256:2db40cf84865e73c908ff080d101ab1ca60829cd379f4e6c15d7287d33ce61e5"
+ }
+ ]
+}
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-patterns/src/Markdown/components.tsx b/packages/ui-patterns/src/Markdown/components.tsx
index 659ee6d61cf47..1a77896948947 100644
--- a/packages/ui-patterns/src/Markdown/components.tsx
+++ b/packages/ui-patterns/src/Markdown/components.tsx
@@ -9,83 +9,10 @@ const DynamicCodeBlock = dynamic(() =>
import('../CodeBlock').then((m) => ({ default: m.CodeBlock }))
)
-export const H1 = ({ className, ...props }: HTMLAttributes) => (
-
-)
-
-export const H2 = ({ className, ...props }: HTMLAttributes) => (
-
-)
-
-export const H3 = ({ className, ...props }: HTMLAttributes) => (
-
-)
-
-export const H4 = ({ className, ...props }: HTMLAttributes) => (
-
-)
-
-export const H5 = ({ className, ...props }: HTMLAttributes) => (
-
-)
-
-export const H6 = ({ className, ...props }: HTMLAttributes) => (
-
-)
-
-export const Paragraph = ({ className, ...props }: HTMLAttributes) => (
-
-)
-
export const InlineCode = ({ className, ...props }: HTMLAttributes) => (
)
-export const Anchor = ({
- href,
- className,
- ...props
-}: HTMLAttributes & { href?: string }) => (
-
-)
-
-export const UnorderedList = ({ className, ...props }: HTMLAttributes) => (
-
-)
-
-export const OrderedList = ({ className, ...props }: HTMLAttributes) => (
-
-)
-
-export const ListItem = ({ className, ...props }: HTMLAttributes) => (
-
-)
-
export const Blockquote = ({ className, ...props }: HTMLAttributes) => (
)
/>
)
-export const Hr = ({ ...props }: HTMLAttributes) => (
-
-)
-
export const Img = ({
className,
alt = '',
@@ -106,36 +29,6 @@ export const Img = ({
)
-export const Table = ({ className, ...props }: HTMLAttributes) => (
-
-)
-
-export const Tr = ({ className, ...props }: HTMLAttributes) => (
-
-)
-
-export const Th = ({ className, ...props }: HTMLAttributes) => (
-
-)
-
-export const Td = ({ className, ...props }: HTMLAttributes) => (
-
-)
-
export const SimplePre = ({ className, ...props }: HTMLAttributes) => (
,
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 (
-
+
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.
+ }
},
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3205b004f755e..70cd6bae73a25 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1163,6 +1163,9 @@ importers:
stripe-experiment-sync:
specifier: 1.0.31
version: 1.0.31(@types/node@22.13.14)(supports-color@8.1.1)
+ swiper:
+ specifier: ^12.1.2
+ version: 12.1.2
tus-js-client:
specifier: ^4.1.0
version: 4.1.0
@@ -1325,7 +1328,7 @@ importers:
version: 2.11.3(@types/node@22.13.14)(typescript@6.0.2)
next-router-mock:
specifier: ^0.9.13
- version: 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)
+ version: 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)
node-mocks-http:
specifier: ^1.17.2
version: 1.17.2(@types/node@22.13.14)
@@ -2206,7 +2209,7 @@ importers:
version: link:../config
next-router-mock:
specifier: ^0.9.13
- version: 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)
+ version: 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)
tailwindcss:
specifier: 'catalog:'
version: 4.2.4
@@ -2694,7 +2697,7 @@ importers:
version: link:../config
next-router-mock:
specifier: ^0.9.13
- version: 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)
+ version: 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)
tailwindcss:
specifier: ^4.2.4
version: 4.2.4
@@ -13075,6 +13078,7 @@ packages:
lucide-vue-next@0.562.0:
resolution: {integrity: sha512-LN0BLGKMFulv0lnfK29r14DcngRUhIqdcaL0zXTt2o0oS9odlrjCGaU3/X9hIihOjjN8l8e+Y9G/famcNYaI7Q==}
+ deprecated: Package deprecated. Please use @lucide/vue instead.
peerDependencies:
vue: '>=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