diff --git a/frontend/src/app/canvas/CanvasRoute.tsx b/frontend/src/app/canvas/CanvasRoute.tsx index 8ce9438..d77e783 100644 --- a/frontend/src/app/canvas/CanvasRoute.tsx +++ b/frontend/src/app/canvas/CanvasRoute.tsx @@ -2,7 +2,7 @@ * Route wrapper for the canvas: resolves projectId from URL and updates lastEditedAt on open. */ -import React, { useEffect } from 'react' +import React, { useEffect, useRef } from 'react' import { useParams } from 'react-router-dom' import { CanvasPage } from './CanvasPage' import { usePlatform } from '@/app/kosmos/KosmosContext' @@ -10,12 +10,14 @@ import { usePlatform } from '@/app/kosmos/KosmosContext' export function CanvasRoute() { const { projectId } = useParams<{ projectId: string }>() const { projects, updateLastEdited } = usePlatform() + const updateLastEditedRef = useRef(updateLastEdited) + updateLastEditedRef.current = updateLastEdited const project = projects.find((p) => p.id === projectId) useEffect(() => { - if (projectId) updateLastEdited(projectId) - }, [projectId, updateLastEdited]) + if (projectId) updateLastEditedRef.current(projectId) + }, [projectId]) if (!projectId) return null if (!project) { diff --git a/frontend/src/components/editor/CodeEditor.tsx b/frontend/src/components/editor/CodeEditor.tsx index 785c1a0..7b81986 100644 --- a/frontend/src/components/editor/CodeEditor.tsx +++ b/frontend/src/components/editor/CodeEditor.tsx @@ -4,7 +4,7 @@ * Wherever this editor is used, it provides the same features (syntax highlighting + Nunjucks). * The parent (node) provides the expected base language for highlighting. */ -import React, { useCallback, useRef } from 'react' +import React, { useCallback, useLayoutEffect, useRef } from 'react' import Editor from 'react-simple-code-editor' import { highlight, type HighlightLanguage } from '@/lib/syntaxHighlight' @@ -53,6 +53,7 @@ export function CodeEditor({ minHeight = 120, }: CodeEditorProps) { const containerRef = useRef(null) + const selectionRestoreRef = useRef<{ start: number; end: number } | null>(null) const highlightCode = useCallback( (code: string) => highlight(code, language), @@ -69,17 +70,27 @@ export function CodeEditor({ (textareaId ? document.getElementById(textareaId) : containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null const start = ta?.selectionStart ?? newValue.length const end = ta?.selectionEnd ?? newValue.length + selectionRestoreRef.current = { start, end } onValueChange(newValue) - requestAnimationFrame(() => { - const el = (textareaId - ? document.getElementById(textareaId) - : containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null - if (el) el.setSelectionRange(start, end) - }) }, [onValueChange, readOnly, textareaId] ) + useLayoutEffect(() => { + const pending = selectionRestoreRef.current + if (pending == null) return + selectionRestoreRef.current = null + const el = (textareaId + ? document.getElementById(textareaId) + : containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null + if (el) { + const { start, end } = pending + const safeEnd = Math.min(end, el.value.length) + const safeStart = Math.min(start, safeEnd) + el.setSelectionRange(safeStart, safeEnd) + } + }, [value, textareaId]) + return (
{ + pathCtxRef.current?.addConnectionPathTrigger?.(id) loadingStartedAtRef.current = Date.now() setLoading(true) setError(null) @@ -263,7 +273,7 @@ export function useRenderingNodeState( setResolvedContent(null) setStreamingMarkdown(null) setReasoningContent('') - updateData({ + updateDataRef.current({ cachedRenderedContent: undefined, cachedResolvedContent: undefined, cachedReasoningContent: undefined, @@ -278,8 +288,8 @@ export function useRenderingNodeState( renderNodeId: id, viewportWidth, viewportHeight, - setNodes: setNodes ?? undefined, - aiConnection, + setNodes: setNodesRef.current ?? undefined, + aiConnection: aiConnectionRef.current, ...(isAgentSource && { onStreamingStart: () => setStreamingMarkdown(''), onStreamingChunk: (chunk: string) => @@ -297,7 +307,7 @@ export function useRenderingNodeState( if (thisRunId !== runIdRef.current) return setRenderedContent(htmlOrSvg) setError(null) - updateData({ + updateDataRef.current({ cachedRenderedContent: htmlOrSvg, cachedResolvedContent: resolved, cachedReasoningContent: reasoning ?? '', @@ -351,26 +361,18 @@ export function useRenderingNodeState( minLoadingTimeoutRef.current = null } } + // Content updates only when connected-node data changes (sourceSignature) or explicit run/viewport. + // React Flow updates (position, selection, context ref churn) do not trigger re-runs. }, [ id, srcId, srcNode?.type, effectiveUpdateMode, runTrigger, - sourceContent, sourceSignature, - configSignature, - edgesSignature, - variablesSignature, - functionsSignature, - dataSignature, viewportWidth, viewportHeight, retryCount, - updateData, - setNodes, - aiConnection, - isAgentSource, incomingIds.length, ]) diff --git a/frontend/src/lib/graph/abstractNode.ts b/frontend/src/lib/graph/abstractNode.ts index 9d6d94d..d765c6f 100644 --- a/frontend/src/lib/graph/abstractNode.ts +++ b/frontend/src/lib/graph/abstractNode.ts @@ -4,7 +4,7 @@ * - **AbstractNodeProps** — Typed props (id, data, width?, height?, selected?) for your node. * - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges, * updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. Calling updateData() - * also reports this node as a trigger for connection path (lifecycle "trigger"). + * also marks this node as a connection-path trigger so edges update on data changes. * - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual. * * **Node lifecycle / connection status:** Nodes that can be updating, paused, or in error should @@ -15,7 +15,7 @@ * component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent). */ -import React, { useCallback, useContext, useMemo } from 'react' +import React, { useCallback, useContext, useMemo, useRef } from 'react' import { GraphContext, ConnectionPathContext } from './flowContext' import { nodePropsAreEqual } from './flowUtils' import type { AppNode } from './nodeTypes' @@ -82,12 +82,13 @@ export function useAbstractNode>( ): AbstractNodeContext { const graphCtx = useContext(GraphContext) const pathCtx = useContext(ConnectionPathContext) + const addTriggerRef = useRef(pathCtx?.addConnectionPathTrigger) + addTriggerRef.current = pathCtx?.addConnectionPathTrigger const nodes = graphCtx?.graphRef?.current?.nodes ?? [] const edges = graphCtx?.edges ?? [] const setNodes = graphCtx?.setNodes const setEdges = graphCtx?.setEdges - const addConnectionPathTrigger = pathCtx?.addConnectionPathTrigger const updateData = useCallback( (partial: Partial) => { if (!setNodes) return @@ -96,9 +97,9 @@ export function useAbstractNode>( n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n ) as AppNode[] ) - addConnectionPathTrigger?.(id) + addTriggerRef.current?.(id) }, - [id, setNodes, addConnectionPathTrigger] + [id, setNodes] ) const incomingEdges = useMemo( diff --git a/frontend/src/lib/graph/nodeLifecycle.ts b/frontend/src/lib/graph/nodeLifecycle.ts index 5e468ec..5a3ba9f 100644 --- a/frontend/src/lib/graph/nodeLifecycle.ts +++ b/frontend/src/lib/graph/nodeLifecycle.ts @@ -54,36 +54,39 @@ export function useSyncConnectionStatus( state: NodeConnectionStatusState ): void { const ctx = useContext(ConnectionPathContext) + const ctxRef = useRef(ctx) + ctxRef.current = ctx const { updating, error, paused } = state const prevRef = useRef({ updating: false, error: false, paused: false }) useEffect(() => { + const pathCtx = ctxRef.current const prev = prevRef.current const nowUpdating = Boolean(updating) const nowError = Boolean(error) const nowPaused = Boolean(paused) if (prev.updating !== nowUpdating) { - if (nowUpdating) ctx?.startConnectionPathUpdate?.(nodeId) - else ctx?.endConnectionPathUpdate?.(nodeId) + if (nowUpdating) pathCtx?.startConnectionPathUpdate?.(nodeId) + else pathCtx?.endConnectionPathUpdate?.(nodeId) prev.updating = nowUpdating } if (prev.error !== nowError) { - if (nowError) ctx?.addConnectionPathError?.(nodeId) - else ctx?.removeConnectionPathError?.(nodeId) + if (nowError) pathCtx?.addConnectionPathError?.(nodeId) + else pathCtx?.removeConnectionPathError?.(nodeId) prev.error = nowError } if (prev.paused !== nowPaused) { - if (nowPaused) ctx?.addConnectionPathPausedNode?.(nodeId) - else ctx?.removeConnectionPathPausedNode?.(nodeId) + if (nowPaused) pathCtx?.addConnectionPathPausedNode?.(nodeId) + else pathCtx?.removeConnectionPathPausedNode?.(nodeId) prev.paused = nowPaused } return () => { - if (prevRef.current.updating) ctx?.endConnectionPathUpdate?.(nodeId) - if (prevRef.current.error) ctx?.removeConnectionPathError?.(nodeId) - if (prevRef.current.paused) ctx?.removeConnectionPathPausedNode?.(nodeId) + if (prevRef.current.updating) pathCtx?.endConnectionPathUpdate?.(nodeId) + if (prevRef.current.error) pathCtx?.removeConnectionPathError?.(nodeId) + if (prevRef.current.paused) pathCtx?.removeConnectionPathPausedNode?.(nodeId) prevRef.current = { updating: false, error: false, paused: false } } - }, [nodeId, updating, error, paused, ctx]) + }, [nodeId, updating, error, paused]) }