Files
supabase/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts
Ali Waseem 3cb440e844 fix(studio): fix multiple Sentry errors (#44715)
## Summary

-
[**SUPABASE-APP-E2R**](https://supabase.sentry.io/issues/SUPABASE-APP-E2R):
Guard against undefined entries in notifications array in
`AdvisorButton` (optional chaining on `.some()` callbacks)
-
[**SUPABASE-APP-EBA**](https://supabase.sentry.io/issues/SUPABASE-APP-EBA):
Remove render-time `handleError()` throw in `useEdgeFunctionsDiff` — the
hook already handles missing body data gracefully
-
[**SUPABASE-APP-BVN**](https://supabase.sentry.io/issues/SUPABASE-APP-BVN)
/
[**SUPABASE-APP-BTV**](https://supabase.sentry.io/issues/SUPABASE-APP-BTV):
Guard `localStorage` access in `FeaturePreviewContext` with try-catch,
matching the established pattern in `useLocalStorage.ts` (Safari private
browsing)
-
[**SUPABASE-APP-AV3**](https://supabase.sentry.io/issues/SUPABASE-APP-AV3):
Filter stale folder IDs before passing `expandedIds` to
`react-accessible-treeview` in the SQL editor nav

## Test plan

- [x] Verify AdvisorButton renders without errors when notifications
data has sparse pages
- [x] Verify branch merge page loads when edge function body fetch fails
- [x] Verify feature previews initialize correctly in Safari private
browsing
- [x] Verify SQL editor folder expand/collapse works after deleting a
folder

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Feature preview now falls back safely when browser storage is
unavailable
* Notifications display updated to tolerate missing entries without
errors
* Private snippets navigation no longer preserves expansion state for
removed nodes

* **Refactor**
  * Streamlined error aggregation in edge functions diff processing
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-09 11:10:47 -06:00

285 lines
8.8 KiB
TypeScript

import { basename } from 'path'
import { useQueries, useQueryClient } from '@tanstack/react-query'
import { useCallback, useMemo } from 'react'
import {
getEdgeFunctionBody,
type EdgeFunctionBodyData,
} from '@/data/edge-functions/edge-function-body-query'
import {
useEdgeFunctionsQuery,
type EdgeFunctionsData,
} from '@/data/edge-functions/edge-functions-query'
import { edgeFunctionsKeys } from '@/data/edge-functions/keys'
interface UseEdgeFunctionsDiffProps {
currentBranchRef?: string
mainBranchRef?: string
}
export type FileStatus = 'added' | 'removed' | 'modified' | 'unchanged'
export interface FileInfo {
key: string
status: FileStatus
}
export interface FunctionFileInfo {
[functionSlug: string]: FileInfo[]
}
export interface EdgeFunctionsDiffResult {
addedSlugs: string[]
removedSlugs: string[]
modifiedSlugs: string[]
addedBodiesMap: Record<string, EdgeFunctionBodyData | undefined>
removedBodiesMap: Record<string, EdgeFunctionBodyData | undefined>
currentBodiesMap: Record<string, EdgeFunctionBodyData | undefined>
mainBodiesMap: Record<string, EdgeFunctionBodyData | undefined>
functionFileInfo: FunctionFileInfo
isLoading: boolean
hasChanges: boolean
refetchCurrentBranchFunctions: () => void
refetchMainBranchFunctions: () => void
currentBranchFunctions?: EdgeFunctionsData
mainBranchFunctions?: EdgeFunctionsData
clearDiffsOptimistically: () => void
}
// Small helper around path.basename but avoids importing the full Node path lib for the browser bundle
const fileKey = (fullPath: string) => basename(fullPath)
export const useEdgeFunctionsDiff = ({
currentBranchRef,
mainBranchRef,
}: UseEdgeFunctionsDiffProps): EdgeFunctionsDiffResult => {
const queryClient = useQueryClient()
// Fetch edge functions for both branches
const {
data: currentBranchFunctions,
isPending: isCurrentFunctionsLoading,
refetch: refetchCurrentBranchFunctions,
} = useEdgeFunctionsQuery(
{ projectRef: currentBranchRef },
{
enabled: !!currentBranchRef,
refetchOnMount: 'always',
staleTime: 30000, // 30 seconds
}
)
const {
data: mainBranchFunctions,
isPending: isMainFunctionsLoading,
refetch: refetchMainBranchFunctions,
} = useEdgeFunctionsQuery(
{ projectRef: mainBranchRef },
{
enabled: !!mainBranchRef,
refetchOnMount: 'always',
staleTime: 30000, // 30 seconds
}
)
// Identify added / removed / overlapping functions
const {
added = [],
removed = [],
modified = [],
} = useMemo(() => {
if (!currentBranchFunctions || !mainBranchFunctions) {
return { added: [], removed: [], modified: [] }
}
const currentFuncs = currentBranchFunctions ?? []
const mainFuncs = mainBranchFunctions ?? []
const added = currentFuncs.filter((c) => !mainFuncs.find((m) => m.slug === c.slug))
const removed = mainFuncs.filter((m) => !currentFuncs.find((c) => c.slug === m.slug))
const modified = currentFuncs.filter((c) =>
mainFuncs.find(
(m) => m.slug === c.slug && (m.ezbr_sha256 === undefined || m.ezbr_sha256 !== c.ezbr_sha256)
)
)
return { added, removed, modified }
}, [currentBranchFunctions, mainBranchFunctions])
const addedSlugs = added.map((f) => f.slug)
const removedSlugs = removed.map((f) => f.slug)
const maybeModifiedSlugs = modified.map((f) => f.slug)
// Fetch function bodies ---------------------------------------------------
const currentBodiesQueries = useQueries({
queries: maybeModifiedSlugs.map((slug) => ({
queryKey: ['edge-function-body', currentBranchRef, slug],
queryFn: ({ signal }: { signal?: AbortSignal }) =>
getEdgeFunctionBody({ projectRef: currentBranchRef, slug }, signal),
enabled: !!currentBranchRef,
refetchOnMount: 'always' as const,
})),
})
const mainBodiesQueries = useQueries({
queries: maybeModifiedSlugs.map((slug) => ({
queryKey: ['edge-function-body', mainBranchRef, slug],
queryFn: ({ signal }: { signal?: AbortSignal }) =>
getEdgeFunctionBody({ projectRef: mainBranchRef, slug }, signal),
enabled: !!mainBranchRef,
refetchOnMount: 'always' as const,
})),
})
const addedBodiesQueries = useQueries({
queries: addedSlugs.map((slug) => ({
queryKey: ['edge-function-body', currentBranchRef, slug],
queryFn: ({ signal }: { signal?: AbortSignal }) =>
getEdgeFunctionBody({ projectRef: currentBranchRef, slug }, signal),
enabled: !!currentBranchRef,
refetchOnMount: 'always' as const,
})),
})
const removedBodiesQueries = useQueries({
queries: removedSlugs.map((slug) => ({
queryKey: ['edge-function-body', mainBranchRef, slug],
queryFn: ({ signal }: { signal?: AbortSignal }) =>
getEdgeFunctionBody({ projectRef: mainBranchRef, slug }, signal),
enabled: !!mainBranchRef,
refetchOnMount: 'always' as const,
})),
})
// Flatten loading flags ----------------------------------------------------
const isLoading =
[
...currentBodiesQueries,
...mainBodiesQueries,
...addedBodiesQueries,
...removedBodiesQueries,
].some((q) => q.isLoading) ||
isCurrentFunctionsLoading ||
isMainFunctionsLoading
// Build lookup maps --------------------------------------------------------
const currentBodiesMap: Record<string, EdgeFunctionBodyData | undefined> = {}
currentBodiesQueries.forEach((q, idx) => {
if (q.data) currentBodiesMap[maybeModifiedSlugs[idx]] = q.data
})
const mainBodiesMap: Record<string, EdgeFunctionBodyData | undefined> = {}
mainBodiesQueries.forEach((q, idx) => {
if (q.data) mainBodiesMap[maybeModifiedSlugs[idx]] = q.data
})
const addedBodiesMap: Record<string, EdgeFunctionBodyData | undefined> = {}
addedBodiesQueries.forEach((q, idx) => {
if (q.data) addedBodiesMap[addedSlugs[idx]] = q.data
})
const removedBodiesMap: Record<string, EdgeFunctionBodyData | undefined> = {}
removedBodiesQueries.forEach((q, idx) => {
if (q.data) removedBodiesMap[removedSlugs[idx]] = q.data
})
// Determine modified slugs and build file info -----------------------------
const modifiedSlugs: string[] = []
const functionFileInfo: FunctionFileInfo = {}
// Process overlapping functions to determine modifications and file info
maybeModifiedSlugs.forEach((slug) => {
const currentBody = currentBodiesMap[slug]
const mainBody = mainBodiesMap[slug]
if (!currentBody || !mainBody) return
const allFileKeys = new Set(
[...currentBody.files, ...mainBody.files].map((f) => fileKey(f.name))
)
const fileInfos: FileInfo[] = []
let hasModifications = false
for (const key of allFileKeys) {
const currentFile = currentBody.files.find((f) => fileKey(f.name) === key)
const mainFile = mainBody.files.find((f) => fileKey(f.name) === key)
let status: FileStatus = 'unchanged'
if (!currentFile && mainFile) {
status = 'removed'
hasModifications = true
} else if (currentFile && !mainFile) {
status = 'added'
hasModifications = true
} else if (currentFile && mainFile && currentFile.content !== mainFile.content) {
status = 'modified'
hasModifications = true
}
fileInfos.push({ key, status })
}
if (hasModifications) {
modifiedSlugs.push(slug)
functionFileInfo[slug] = fileInfos
}
})
// Add file info for added functions
addedSlugs.forEach((slug) => {
const body = addedBodiesMap[slug]
if (body) {
functionFileInfo[slug] = body.files.map((file) => ({
key: fileKey(file.name),
status: 'added' as FileStatus,
}))
}
})
// Add file info for removed functions
removedSlugs.forEach((slug) => {
const body = removedBodiesMap[slug]
if (body) {
functionFileInfo[slug] = body.files.map((file) => ({
key: fileKey(file.name),
status: 'removed' as FileStatus,
}))
}
})
const hasChanges = addedSlugs.length > 0 || removedSlugs.length > 0 || modifiedSlugs.length > 0
const clearDiffsOptimistically = useCallback(() => {
if (!currentBranchRef || !mainBranchFunctions) return
queryClient.setQueryData(edgeFunctionsKeys.list(currentBranchRef), mainBranchFunctions)
mainBranchFunctions.forEach((func) => {
const mainBody = mainBodiesMap[func.slug]
if (mainBody) {
queryClient.setQueryData(['edge-function-body', currentBranchRef, func.slug], mainBody)
}
})
}, [currentBranchRef, mainBranchFunctions, mainBodiesMap, queryClient])
return {
addedSlugs,
removedSlugs,
modifiedSlugs,
addedBodiesMap,
removedBodiesMap,
currentBodiesMap,
mainBodiesMap,
functionFileInfo,
isLoading,
hasChanges,
refetchCurrentBranchFunctions,
refetchMainBranchFunctions,
currentBranchFunctions,
mainBranchFunctions,
clearDiffsOptimistically,
}
}
export default useEdgeFunctionsDiff