performance

This commit is contained in:
2026-03-09 15:50:11 +01:00
parent 50d34bfb00
commit 88629e6dcc
3 changed files with 70 additions and 14 deletions

View File

@@ -8,6 +8,8 @@ import {
addEdge, addEdge,
applyNodeChanges, applyNodeChanges,
applyEdgeChanges, applyEdgeChanges,
useNodesInitialized,
useReactFlow,
type Node, type Node,
type Edge, type Edge,
type Connection, type Connection,
@@ -107,6 +109,18 @@ const PROJECT_VERSION = 1
export type ProjectMessage = { type: 'success' | 'error'; text: string } export type ProjectMessage = { type: 'success' | 'error'; text: string }
/** Calls fitView when nodes are initialized (e.g. after load/import). Must be rendered inside ReactFlowProvider. */
function FlowFitViewOnLoad() {
const nodesInitialized = useNodesInitialized()
const { fitView } = useReactFlow()
React.useEffect(() => {
if (nodesInitialized) {
fitView?.({ duration: 200 })
}
}, [nodesInitialized, fitView])
return null
}
export default function App() { export default function App() {
const { theme } = useTheme() const { theme } = useTheme()
const { const {
@@ -488,6 +502,7 @@ export default function App() {
</div> </div>
)} )}
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView> <ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
<FlowFitViewOnLoad />
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
@@ -508,6 +523,9 @@ export default function App() {
fitView fitView
onInit={onInit} onInit={onInit}
nodeDragThreshold={1} nodeDragThreshold={1}
nodesDraggable
nodesConnectable
elementsSelectable
> >
<Background variant="dots" gap={20} /> <Background variant="dots" gap={20} />
<Controls /> <Controls />

View File

@@ -20,10 +20,11 @@ import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
import { NodeMenubar } from '../base/NodeMenubar' import { NodeMenubar } from '../base/NodeMenubar'
import { NodeStatusIndicator } from '../base/NodeStatusIndicator' import { NodeStatusIndicator } from '../base/NodeStatusIndicator'
import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar' import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar'
import { Sparkles, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react' import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw } from 'lucide-react'
import { InputHandle } from '../base/NodeHandles' import { InputHandle } from '../base/NodeHandles'
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch' import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
import { Input } from '../ui/input' import { Input } from '../ui/input'
import { Button } from '../ui/button'
export type RenderingNodeData = { export type RenderingNodeData = {
viewportWidth?: number viewportWidth?: number
@@ -39,6 +40,7 @@ function RenderingNodeComponent({ id, data, width, height }: Props) {
const [renderedContent, setRenderedContent] = useState<string | null>(null) const [renderedContent, setRenderedContent] = useState<string | null>(null)
const [error, setError] = useState<null | { kind: string; message: string }>(null) const [error, setError] = useState<null | { kind: string; message: string }>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [retryCount, setRetryCount] = useState(0)
const runIdRef = useRef(0) const runIdRef = useRef(0)
const loadingStartedAtRef = useRef<number | null>(null) const loadingStartedAtRef = useRef<number | null>(null)
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -151,6 +153,8 @@ function RenderingNodeComponent({ id, data, width, height }: Props) {
[nodes, connectedNodeIds] [nodes, connectedNodeIds]
) )
const RENDER_DEBOUNCE_MS = 250
useEffect(() => { useEffect(() => {
if (!sourceContent && incomingIds.length > 0) { if (!sourceContent && incomingIds.length > 0) {
setRenderedContent(null) setRenderedContent(null)
@@ -473,16 +477,17 @@ function RenderingNodeComponent({ id, data, width, height }: Props) {
} }
} }
run() const debounceTimer = setTimeout(run, RENDER_DEBOUNCE_MS)
return () => { return () => {
cancelled = true cancelled = true
clearTimeout(debounceTimer)
if (minLoadingTimeoutRef.current != null) { if (minLoadingTimeoutRef.current != null) {
clearTimeout(minLoadingTimeoutRef.current) clearTimeout(minLoadingTimeoutRef.current)
minLoadingTimeoutRef.current = null minLoadingTimeoutRef.current = null
} }
} }
// Only re-run when inputs that affect the resolved output change (signatures + source). // Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry.
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, viewportWidth, viewportHeight]) }, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, viewportWidth, viewportHeight, retryCount])
const dimensions = const dimensions =
width != null && height != null && width > 0 && height > 0 width != null && height != null && width > 0 && height > 0
@@ -646,7 +651,19 @@ function RenderingNodeComponent({ id, data, width, height }: Props) {
) : srcData?.errorHtml ? ( ) : srcData?.errorHtml ? (
<div className="p-3 text-xs text-red-700 dark:text-red-400" dangerouslySetInnerHTML={{ __html: String(srcData.errorHtml) }} /> <div className="p-3 text-xs text-red-700 dark:text-red-400" dangerouslySetInnerHTML={{ __html: String(srcData.errorHtml) }} />
) : ( ) : (
<div className="p-3 text-xs text-red-700 dark:text-red-400">{error.message}</div> <div className="flex flex-col gap-2 p-3">
<p className="text-xs text-red-700 dark:text-red-400">{error.message}</p>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => setRetryCount((c) => c + 1)}
>
<RotateCw className="size-3 mr-1" />
Retry
</Button>
</div>
) )
) : loading ? ( ) : loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div> <div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div>

View File

@@ -33,6 +33,7 @@ export type ConfigType = {
} }
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg' const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
const KROKI_TIMEOUT_MS = 15000
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [ const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' }, { label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
@@ -131,16 +132,36 @@ export const CONFIG_TYPES: ConfigType[] = [
language: 'plantuml', language: 'plantuml',
insertBlocks: PLANTUML_INSERT_BLOCKS, insertBlocks: PLANTUML_INSERT_BLOCKS,
render: async (content: string) => { render: async (content: string) => {
const res = await fetch(KROKI_PLANTUML_SVG, { const controller = new AbortController()
method: 'POST', const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS)
headers: { 'Content-Type': 'text/plain' }, try {
body: content, const res = await fetch(KROKI_PLANTUML_SVG, {
}) method: 'POST',
if (!res.ok) { headers: { 'Content-Type': 'text/plain' },
const err = await res.text() body: content,
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`) signal: controller.signal,
})
clearTimeout(timeoutId)
if (!res.ok) {
const err = await res.text()
if (res.status >= 500) {
throw new Error('Diagram service unavailable. Try again later.')
}
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
}
return res.text()
} catch (err: unknown) {
clearTimeout(timeoutId)
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error('Diagram request timed out. The service may be slow or unavailable.')
}
if (err.message.includes('fetch') || err.message.includes('network') || err.message.includes('Failed to fetch')) {
throw new Error('Diagram service unavailable. Check your connection or try again later.')
}
}
throw err
} }
return res.text()
}, },
}, },
{ {