performance
This commit is contained in:
18
src/App.tsx
18
src/App.tsx
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user