From 1a0fc71151a163a248ca2a5556c8dadd57637ded Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:12:37 +0200 Subject: [PATCH] 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 ## 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. --- .../Database/Schemas/DefaultEdge.tsx | 8 +- .../Database/Schemas/SchemaGraph.tsx | 68 ++------------ .../Schemas/useExportSchemaToImage.ts | 90 +++++++++++++++++++ apps/studio/package.json | 2 +- pnpm-lock.yaml | 10 +-- 5 files changed, 108 insertions(+), 70 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Schemas/useExportSchemaToImage.ts diff --git a/apps/studio/components/interfaces/Database/Schemas/DefaultEdge.tsx b/apps/studio/components/interfaces/Database/Schemas/DefaultEdge.tsx index dddf394a9d8..fee55849475 100644 --- a/apps/studio/components/interfaces/Database/Schemas/DefaultEdge.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/DefaultEdge.tsx @@ -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>) => { + const { isDownloading } = useSchemaGraphContext() const [edgePath, labelX, labelY] = getSmoothStepPath({ sourceX, sourceY, @@ -46,13 +48,13 @@ export const DefaultEdge = ({ offset: pathOptions?.offset, stepPosition: pathOptions?.stepPosition, }) - return ( <> {data && selected ? ( diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx index 5da4bfb5bc2..0d470a0cfe9 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -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(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( + const schemaGraphContext = useMemo( () => ({ selectedEdge, isDownloading, @@ -427,7 +373,7 @@ export const SchemaGraph = () => { ) : ( - +
, Edge> // FIXME: https://github.com/xyflow/xyflow/issues/4876 diff --git a/apps/studio/components/interfaces/Database/Schemas/useExportSchemaToImage.ts b/apps/studio/components/interfaces/Database/Schemas/useExportSchemaToImage.ts new file mode 100644 index 00000000000..abab66249cd --- /dev/null +++ b/apps/studio/components/interfaces/Database/Schemas/useExportSchemaToImage.ts @@ -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 +} diff --git a/apps/studio/package.json b/apps/studio/package.json index ba7532a05d6..f296005281b 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf3a8c26057..db54b835949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}