mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix: improve performances on large schema image export (#45042)
## Problem When users export a large schema, the UI becomes unresponsive for a long time. This is because the underlying `html-to-image` library calls `getComputedStyle` for every node. ## Solution - Upgrade `html-to-image` to its latest version - Use the new `includeStyleProperties` property to call `getComputedStyle` only once - Extract the image export logic into a new hook ## How to test - Open https://studio-staging-git-gildasgarcia-fe-2998-suggest-e7fb9e-supabase.vercel.app/dashboard/project/pdmusqfyrsascxykhlge/database/schemas?schema=auth - Rearrange tables so that they are all visible - Export the schema as png - It should takes (~10-15secs) - Do the same in this PR preview https://studio-staging-gy13zepyf-supabase.vercel.app/dashboard/project/pdmusqfyrsascxykhlge/database/schemas?schema=auth - It should takes ~3-5secs <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Improvements** * Improved schema export: more reliable PNG/SVG exports that better preserve visual styling, show progress state during downloads, and surface success/error notifications. * **Chores** * Updated image-export library to a newer version for improved compatibility and performance. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
5181be6005
commit
1a0fc71151
5 files changed
+108
-70
No files matched your search
@@ -8,9 +8,10 @@ import {
|
||||
useReactFlow,
|
||||
} from '@xyflow/react'
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Badge, cn } from 'ui'
|
||||
|
||||
import { useSchemaGraphContext } from './SchemaGraphContext'
|
||||
import { EdgeData } from './Schemas.constants'
|
||||
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
|
||||
import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
|
||||
@@ -35,6 +36,7 @@ export const DefaultEdge = ({
|
||||
pathOptions,
|
||||
...props
|
||||
}: EdgeProps<Edge<EdgeData>>) => {
|
||||
const { isDownloading } = useSchemaGraphContext()
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
@@ -46,13 +48,13 @@ export const DefaultEdge = ({
|
||||
offset: pathOptions?.offset,
|
||||
stepPosition: pathOptions?.stepPosition,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
className={cn(selected ? '!stroke-brand' : undefined)}
|
||||
className={cn(selected ? '!stroke-brand' : isDownloading ? '!stroke-black' : undefined)}
|
||||
stroke="#000000"
|
||||
{...props}
|
||||
/>
|
||||
{data && selected ? (
|
||||
|
||||
@@ -47,6 +47,7 @@ import { SchemaGraphLegend } from './SchemaGraphLegend'
|
||||
import { EdgeData, TableNodeData } from './Schemas.constants'
|
||||
import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils'
|
||||
import { TableNode } from './SchemaTableNode'
|
||||
import { useExportSchemaToImage } from './useExportSchemaToImage'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import SchemaSelector from '@/components/ui/SchemaSelector'
|
||||
@@ -70,6 +71,7 @@ export const SchemaGraph = () => {
|
||||
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
|
||||
const [selectedTable, setSelectedTable] = useState<PostgresTable | null>(null)
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
const { isDownloading, exportSchemaToImage } = useExportSchemaToImage()
|
||||
|
||||
const [copied, setCopied] = useState(false)
|
||||
useEffect(() => {
|
||||
@@ -78,8 +80,6 @@ export const SchemaGraph = () => {
|
||||
}
|
||||
}, [copied])
|
||||
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
const miniMapNodeColor = '#111318'
|
||||
const miniMapMaskColor = resolvedTheme?.includes('dark')
|
||||
? 'rgb(17, 19, 24, .8)'
|
||||
@@ -181,66 +181,12 @@ export const SchemaGraph = () => {
|
||||
}
|
||||
)
|
||||
|
||||
const downloadImage = (format: 'png' | 'svg') => {
|
||||
const downloadImage = async (format: 'png' | 'svg') => {
|
||||
const reactflowViewport = document.querySelector('.react-flow__viewport') as HTMLElement
|
||||
if (!reactflowViewport) return
|
||||
|
||||
setIsDownloading(true)
|
||||
const width = reactflowViewport.clientWidth
|
||||
const height = reactflowViewport.clientHeight
|
||||
if (!ref) return
|
||||
const { x, y, zoom } = reactFlowInstance.getViewport()
|
||||
|
||||
if (format === 'svg') {
|
||||
toSvg(reactflowViewport, {
|
||||
backgroundColor: 'white',
|
||||
width,
|
||||
height,
|
||||
style: {
|
||||
width: width.toString(),
|
||||
height: height.toString(),
|
||||
transform: `translate(${x}px, ${y}px) scale(${zoom})`,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
const a = document.createElement('a')
|
||||
a.setAttribute('download', `supabase-schema-${ref}.svg`)
|
||||
a.setAttribute('href', data)
|
||||
a.click()
|
||||
toast.success('Successfully downloaded as SVG')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to download:', error)
|
||||
toast.error('Failed to download current view:', error.message)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDownloading(false)
|
||||
})
|
||||
} else if (format === 'png') {
|
||||
toPng(reactflowViewport, {
|
||||
backgroundColor: 'white',
|
||||
width,
|
||||
height,
|
||||
style: {
|
||||
width: width.toString(),
|
||||
height: height.toString(),
|
||||
transform: `translate(${x}px, ${y}px) scale(${zoom})`,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
const a = document.createElement('a')
|
||||
a.setAttribute('download', `supabase-schema-${ref}.png`)
|
||||
a.setAttribute('href', data)
|
||||
a.click()
|
||||
toast.success('Successfully downloaded as PNG')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to download:', error)
|
||||
toast.error('Failed to download current view:', error.message)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDownloading(false)
|
||||
})
|
||||
}
|
||||
exportSchemaToImage({ element: reactflowViewport, format, x, y, zoom, projectRef: ref })
|
||||
}
|
||||
|
||||
const isFirstLoad = useRef(true)
|
||||
@@ -268,7 +214,7 @@ export const SchemaGraph = () => {
|
||||
selectedSchema,
|
||||
])
|
||||
|
||||
const schemaGraphPanelEditorContext = useMemo<SchemaGraphContextType>(
|
||||
const schemaGraphContext = useMemo<SchemaGraphContextType>(
|
||||
() => ({
|
||||
selectedEdge,
|
||||
isDownloading,
|
||||
@@ -427,7 +373,7 @@ export const SchemaGraph = () => {
|
||||
</Admonition>
|
||||
</div>
|
||||
) : (
|
||||
<SchemaGraphContextProvider value={schemaGraphPanelEditorContext}>
|
||||
<SchemaGraphContextProvider value={schemaGraphContext}>
|
||||
<div className="w-full h-full">
|
||||
<ReactFlow<Node<TableNodeData>, Edge<EdgeData>>
|
||||
// FIXME: https://github.com/xyflow/xyflow/issues/4876
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { toPng, toSvg } from 'html-to-image'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
|
||||
|
||||
export const useExportSchemaToImage = () => {
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
// By doing this once and passing the result to html-to-image options, we avoid html-to-image calculating it for every node.
|
||||
// This improves performance a lot. See https://github.com/bubkoo/html-to-image/issues/542#issuecomment-3249408793
|
||||
const allPropertyNames = useMemo(() => getAllPropertyNames(), [])
|
||||
|
||||
const exportSchemaToImage = useStaticEffectEvent(
|
||||
async ({
|
||||
element,
|
||||
projectRef,
|
||||
x,
|
||||
y,
|
||||
zoom,
|
||||
format,
|
||||
}: {
|
||||
element: HTMLElement
|
||||
projectRef: string
|
||||
x: number
|
||||
y: number
|
||||
zoom: number
|
||||
format: 'svg' | 'png'
|
||||
}) => {
|
||||
setIsDownloading(true)
|
||||
const width = element.clientWidth
|
||||
const height = element.clientHeight
|
||||
|
||||
const options = {
|
||||
includeStyleProperties: allPropertyNames,
|
||||
backgroundColor: 'white',
|
||||
width,
|
||||
height,
|
||||
style: {
|
||||
width: width.toString(),
|
||||
height: height.toString(),
|
||||
transform: `translate(${x}px, ${y}px) scale(${zoom})`,
|
||||
},
|
||||
}
|
||||
try {
|
||||
if (format === 'svg') {
|
||||
const data = await toSvg(element, options)
|
||||
const a = document.createElement('a')
|
||||
a.setAttribute('download', `supabase-schema-${projectRef}.svg`)
|
||||
a.setAttribute('href', data)
|
||||
a.click()
|
||||
toast.success('Successfully downloaded as SVG')
|
||||
} else if (format === 'png') {
|
||||
const data = await toPng(element, options)
|
||||
const a = document.createElement('a')
|
||||
a.setAttribute('download', `supabase-schema-${projectRef}.png`)
|
||||
a.setAttribute('href', data)
|
||||
a.click()
|
||||
toast.success('Successfully downloaded as PNG')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to download:', error)
|
||||
toast.error(`Failed to download current view: ${(error as Error).message}`)
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({ isDownloading, exportSchemaToImage }),
|
||||
[isDownloading, exportSchemaToImage]
|
||||
)
|
||||
}
|
||||
|
||||
// Get all property names accessible through getComputedStyle(), excluding custom properties
|
||||
export const getAllPropertyNames = () => {
|
||||
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
|
||||
return []
|
||||
}
|
||||
|
||||
const names = []
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
for (let i = 0; i < style.length; i++) {
|
||||
const name = style[i]
|
||||
if (!name.startsWith('--')) {
|
||||
names.push(name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -92,7 +92,7 @@
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^11.18.2",
|
||||
"generate-password-browser": "^1.1.0",
|
||||
"html-to-image": "^1.10.8",
|
||||
"html-to-image": "^1.11.13",
|
||||
"http-status": "^2.1.0",
|
||||
"icons": "workspace:*",
|
||||
"idb": "^8.0.2",
|
||||
|
||||
Generated
+5
-5
@@ -1040,8 +1040,8 @@ importers:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
html-to-image:
|
||||
specifier: ^1.10.8
|
||||
version: 1.11.11
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
http-status:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
@@ -12987,8 +12987,8 @@ packages:
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
html-to-image@1.11.11:
|
||||
resolution: {integrity: sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==}
|
||||
html-to-image@1.11.13:
|
||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||
|
||||
html-url-attributes@3.0.0:
|
||||
resolution: {integrity: sha512-/sXbVCWayk6GDVg3ctOX6nxaVj7So40FcFAnWlWGNAB1LpYKcV5Cd10APjPjW80O7zYW2MsjBV4zZ7IZO5fVow==}
|
||||
@@ -31372,7 +31372,7 @@ snapshots:
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
html-to-image@1.11.11: {}
|
||||
html-to-image@1.11.13: {}
|
||||
|
||||
html-url-attributes@3.0.0: {}
|
||||
|
||||
|
||||
Reference in new issue
Block a user