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:
Gildas Garcia authored and GitHub committed 2026-04-20 16:12:37 +02:00
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
}
+1 -1
View File
@@ -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",
+5 -5
View File
@@ -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: {}