refactor: performance
This commit is contained in:
@@ -31,7 +31,7 @@ import {
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
|
||||
import { getExampleGraph } from '@/app/canvas/canvasGraphUtils'
|
||||
import { getExampleGraph, backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
|
||||
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
||||
import { useCanvasConnectionPath } from '@/app/canvas/useCanvasConnectionPath'
|
||||
@@ -189,6 +189,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
|
||||
const nodesRef = useRef(nodes)
|
||||
nodesRef.current = nodes
|
||||
const graphRef = useRef<{ nodes: AppNode[]; edges: AppEdge[] }>({ nodes: [], edges: [] })
|
||||
graphRef.current.nodes = nodes
|
||||
graphRef.current.edges = edges
|
||||
|
||||
const [apiTodosCount, setApiTodosCount] = React.useState<number | null>(null)
|
||||
React.useEffect(() => {
|
||||
@@ -244,7 +247,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
)
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
||||
(params: Connection) => {
|
||||
const targetType =
|
||||
nodesRef.current.find((n) => n.id === params.target)?.type ?? ''
|
||||
const conn = { ...params, data: { targetType } as Record<string, unknown> }
|
||||
setEdges((eds) => addEdge(conn, eds))
|
||||
},
|
||||
[setEdges]
|
||||
)
|
||||
|
||||
@@ -324,7 +332,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
toast.error('Invalid file: expected nodes and edges arrays')
|
||||
return
|
||||
}
|
||||
setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] })
|
||||
const nodes = state.nodes as AppNode[]
|
||||
const edges = backfillEdgeTargetTypes(nodes, state.edges as AppEdge[])
|
||||
setStateImmediate({ nodes, edges })
|
||||
if (state.version != null && state.version > PROJECT_VERSION) {
|
||||
toast.error('Project was created with a newer app version')
|
||||
} else {
|
||||
@@ -393,8 +403,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
}, [])
|
||||
|
||||
const graphContextValue = useMemo(
|
||||
() => ({ nodes, setNodes, edges, setEdges }),
|
||||
[nodes, setNodes, edges, setEdges]
|
||||
() => ({ setNodes, setEdges, graphRef, edges }),
|
||||
[setNodes, setEdges, edges]
|
||||
)
|
||||
const connectionPathContextValue = useMemo(
|
||||
() => ({
|
||||
@@ -438,18 +448,36 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
]
|
||||
)
|
||||
|
||||
const nodesForFlow = useMemo(
|
||||
() =>
|
||||
nodes.map((n) => ({
|
||||
const prevNodesRef = useRef<AppNode[]>([])
|
||||
const prevNodesForFlowRef = useRef<Node[]>([])
|
||||
const nodesForFlow = useMemo(() => {
|
||||
const prev = prevNodesRef.current
|
||||
if (nodes === prev) return prevNodesForFlowRef.current
|
||||
const prevById = new Map(prev.map((n) => [n.id, n]))
|
||||
const prevWrappedById = new Map(
|
||||
prevNodesForFlowRef.current.map((w, i) => [prev[i]?.id, w])
|
||||
)
|
||||
const result = nodes.map((n) => {
|
||||
const prevNode = prevById.get(n.id)
|
||||
if (prevNode === n && prevWrappedById.has(n.id)) {
|
||||
return prevWrappedById.get(n.id)!
|
||||
}
|
||||
return {
|
||||
...n,
|
||||
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
|
||||
})),
|
||||
[nodes]
|
||||
)
|
||||
}
|
||||
})
|
||||
prevNodesRef.current = nodes
|
||||
prevNodesForFlowRef.current = result
|
||||
return result
|
||||
}, [nodes])
|
||||
const edgesForFlow = useMemo(
|
||||
() =>
|
||||
edges.map((e) => {
|
||||
const targetType = nodes.find((nd) => nd.id === e.target)?.type ?? ''
|
||||
const targetType =
|
||||
(typeof e.data === 'object' && e.data !== null && (e.data as Record<string, unknown>).targetType != null
|
||||
? (e.data as Record<string, unknown>).targetType
|
||||
: '') as string
|
||||
const connectionLabel = getConnectionLabelForTarget(targetType)
|
||||
const baseData =
|
||||
typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
|
||||
@@ -458,7 +486,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
data: { ...baseData, connectionLabel },
|
||||
}
|
||||
}),
|
||||
[edges, nodes]
|
||||
[edges]
|
||||
)
|
||||
|
||||
const onContextMenuCapture = useCallback((ev: React.MouseEvent) => {
|
||||
|
||||
@@ -17,25 +17,34 @@ export { ViewportDisplayContext }
|
||||
|
||||
/**
|
||||
* Must be rendered inside ReactFlowProvider. Subscribes to viewport once,
|
||||
* maps zoom to displayMode with hysteresis, and provides it to descendants.
|
||||
* maps zoom to displayMode with hysteresis. Throttles updates via rAF to avoid
|
||||
* re-rendering all contextual nodes on every zoom tick.
|
||||
*/
|
||||
export function ViewportDisplayProvider({ children }: { children: React.ReactNode }) {
|
||||
const { zoom } = useViewport()
|
||||
const [displayMode, setDisplayMode] = useState<ViewportDisplayMode>(() =>
|
||||
zoom <= CONTEXTUAL_ZOOM_THRESHOLD ? 'compact' : 'full'
|
||||
)
|
||||
const lastRef = useRef(displayMode)
|
||||
const lastModeRef = useRef(displayMode)
|
||||
const zoomRef = useRef(zoom)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
zoomRef.current = zoom
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (rafRef.current !== null) return
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null
|
||||
const z = zoomRef.current
|
||||
const low = CONTEXTUAL_ZOOM_THRESHOLD - HYSTERESIS
|
||||
const high = CONTEXTUAL_ZOOM_THRESHOLD + HYSTERESIS
|
||||
let next: ViewportDisplayMode = lastRef.current
|
||||
if (zoom <= low) next = 'compact'
|
||||
else if (zoom >= high) next = 'full'
|
||||
if (next !== lastRef.current) {
|
||||
lastRef.current = next
|
||||
let next: ViewportDisplayMode = lastModeRef.current
|
||||
if (z <= low) next = 'compact'
|
||||
else if (z >= high) next = 'full'
|
||||
if (next !== lastModeRef.current) {
|
||||
lastModeRef.current = next
|
||||
setDisplayMode(next)
|
||||
}
|
||||
})
|
||||
}, [zoom])
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,20 @@ import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
||||
import { loadGraphFromStorage } from '@/app/pleroma/projectGraphStorage'
|
||||
|
||||
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
|
||||
export function backfillEdgeTargetTypes(
|
||||
nodes: AppNode[],
|
||||
edges: AppEdge[]
|
||||
): AppEdge[] {
|
||||
const typeById = new Map(nodes.map((n) => [n.id, n.type ?? '']))
|
||||
return edges.map((e) => {
|
||||
const targetType = typeById.get(e.target) ?? (e.data as Record<string, unknown>)?.targetType ?? ''
|
||||
const data = typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
|
||||
if (data.targetType === targetType) return e
|
||||
return { ...e, data: { ...data, targetType } }
|
||||
})
|
||||
}
|
||||
|
||||
const NODE_GAP = 150
|
||||
|
||||
const EXAMPLE_NODES: AppNode[] = [
|
||||
@@ -45,20 +59,24 @@ const EXAMPLE_EDGES: AppEdge[] = [
|
||||
]
|
||||
|
||||
export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||
return {
|
||||
nodes: EXAMPLE_NODES.map((n) => ({
|
||||
const nodes = EXAMPLE_NODES.map((n) => ({
|
||||
...n,
|
||||
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
|
||||
})),
|
||||
edges: EXAMPLE_EDGES.map((e) => ({ ...e })),
|
||||
}
|
||||
}))
|
||||
const edges = backfillEdgeTargetTypes(
|
||||
nodes,
|
||||
EXAMPLE_EDGES.map((e) => ({ ...e }))
|
||||
)
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
export function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||
if (projectId) {
|
||||
const stored = loadGraphFromStorage(projectId)
|
||||
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
|
||||
return { nodes: stored.nodes as AppNode[], edges: stored.edges as AppEdge[] }
|
||||
const nodes = stored.nodes as AppNode[]
|
||||
const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[])
|
||||
return { nodes, edges }
|
||||
}
|
||||
return { nodes: [], edges: [] }
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export function FlowKeyboardShortcuts() {
|
||||
const { fitView, screenToFlowPosition } = useReactFlow()
|
||||
const graphCtx = useContext(GraphContext)
|
||||
const uiCtx = useContext(FlowUIContext)
|
||||
const nodes = graphCtx?.nodes ?? []
|
||||
const setNodes = graphCtx?.setNodes
|
||||
const setConnectionFrom = uiCtx?.setConnectionFrom
|
||||
const flowActionsRef = uiCtx?.flowActionsRef
|
||||
@@ -80,6 +79,7 @@ export function FlowKeyboardShortcuts() {
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
||||
if (ev.key === 'Escape') {
|
||||
const openDialog = document.querySelector('[role="dialog"]')
|
||||
if (openDialog && ev.target instanceof Node && openDialog.contains(ev.target)) return
|
||||
@@ -152,7 +152,7 @@ export function FlowKeyboardShortcuts() {
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [
|
||||
nodes,
|
||||
graphCtx?.graphRef,
|
||||
setNodes,
|
||||
setConnectionFrom,
|
||||
pasteAtViewportCenter,
|
||||
|
||||
@@ -11,9 +11,7 @@ type Props = {
|
||||
export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
||||
const graphCtx = useContext(GraphContext)
|
||||
const uiCtx = useContext(FlowUIContext)
|
||||
const nodes = graphCtx?.nodes ?? []
|
||||
const setNodes = graphCtx?.setNodes
|
||||
const edges = graphCtx?.edges ?? []
|
||||
const setEdges = graphCtx?.setEdges
|
||||
const renamingNodeId = uiCtx?.renamingNodeId ?? null
|
||||
const setRenamingNodeId = uiCtx?.setRenamingNodeId
|
||||
@@ -32,7 +30,9 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
||||
}, [isRenaming, nodeId])
|
||||
|
||||
const applyRename = useCallback(() => {
|
||||
if (!setNodes || !setEdges || !setRenamingNodeId) return
|
||||
if (!setNodes || !setEdges || !setRenamingNodeId || !graphCtx?.graphRef) return
|
||||
const nodes = graphCtx.graphRef.current.nodes
|
||||
const edges = graphCtx.graphRef.current.edges
|
||||
const newId = inputValue.trim()
|
||||
if (!newId || newId === nodeId) {
|
||||
setRenamingNodeId(null)
|
||||
@@ -46,7 +46,7 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
||||
setNodes(nextNodes as AppNode[])
|
||||
setEdges(nextEdges)
|
||||
setRenamingNodeId(null)
|
||||
}, [nodeId, inputValue, nodes, edges, setNodes, setEdges, setRenamingNodeId])
|
||||
}, [nodeId, inputValue, graphCtx?.graphRef, setNodes, setEdges, setRenamingNodeId])
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setRenamingNodeId?.(null)
|
||||
|
||||
@@ -45,10 +45,9 @@ type Props = {
|
||||
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent: nodeMenuExtraContentProp, outputMenuContent, dataMenuContent }: Props) {
|
||||
const graphCtx = useContext(GraphContext)
|
||||
const uiCtx = useContext(FlowUIContext)
|
||||
const nodes = graphCtx?.nodes ?? []
|
||||
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
||||
const setNodes = graphCtx?.setNodes
|
||||
const setEdges = graphCtx?.setEdges
|
||||
|
||||
const edges = graphCtx?.edges ?? []
|
||||
const node = nodes.find((n: any) => n.id === nodeId)
|
||||
const nodeMenuExtraContent = useMemo(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* pipeline interface.
|
||||
*/
|
||||
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useContext, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useAbstractNode } from '@/lib/graph/abstractNode'
|
||||
import { ConnectionPathContext } from '@/lib/graph/flowContext'
|
||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||
@@ -118,6 +118,8 @@ export function useRenderingNodeState(
|
||||
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
||||
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
||||
|
||||
const deferredNodes = useDeferredValue(nodes)
|
||||
const deferredEdges = useDeferredValue(edges)
|
||||
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
||||
const srcNode = useMemo(
|
||||
() => (srcId ? nodes.find((n: { id: string }) => n.id === srcId) : null),
|
||||
@@ -150,8 +152,8 @@ export function useRenderingNodeState(
|
||||
: ''
|
||||
|
||||
const signatures = useMemo(
|
||||
() => buildSourceSignatures(nodes as NodeLike[], edges as EdgeLike[], id, incomingIds),
|
||||
[nodes, edges, id, incomingIds]
|
||||
() => buildSourceSignatures(deferredNodes as NodeLike[], deferredEdges as EdgeLike[], id, incomingIds),
|
||||
[deferredNodes, deferredEdges, id, incomingIds]
|
||||
)
|
||||
const {
|
||||
connectedNodeIds,
|
||||
|
||||
@@ -82,7 +82,7 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
||||
): AbstractNodeContext<TData> {
|
||||
const graphCtx = useContext(GraphContext)
|
||||
const pathCtx = useContext(ConnectionPathContext)
|
||||
const nodes = graphCtx?.nodes ?? []
|
||||
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
||||
const edges = graphCtx?.edges ?? []
|
||||
const setNodes = graphCtx?.setNodes
|
||||
const setEdges = graphCtx?.setEdges
|
||||
|
||||
@@ -25,14 +25,20 @@ export type FlowActions = {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph context (nodes, edges, setters)
|
||||
// Graph context (setters + graphRef for reads; edges in context so edge changes trigger re-renders)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GraphContextRef = {
|
||||
current: { nodes: AppNode[]; edges: AppEdge[] }
|
||||
}
|
||||
|
||||
export type GraphContextValue = {
|
||||
nodes: AppNode[]
|
||||
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
|
||||
edges: AppEdge[]
|
||||
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
|
||||
/** Current nodes/edges; updated every render. Read from here to avoid re-rendering on position-only changes. */
|
||||
graphRef: GraphContextRef
|
||||
/** Edges in context so consumers (e.g. edge indicators, useAbstractNode) re-render when edges change. */
|
||||
edges: AppEdge[]
|
||||
}
|
||||
|
||||
const GraphContext = React.createContext<GraphContextValue | null>(null)
|
||||
|
||||
Reference in New Issue
Block a user