fix: prevent cross-document state contamination in useActiveDocument

Added lastSyncedStateRef to track the graph state that was last loaded/synced
for each document. This prevents stale comparisons when switching between
documents by ensuring dirty checks only run against the correct document's
state.

Changes:
- Track last synced state (nodes, edges, nodeTypes, edgeTypes) per document
- Skip dirty check if graph state belongs to a different document
- Compare current graph state against last synced state instead of active document
- Update last synced state reference when loading or saving changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-10 16:50:26 +02:00
parent ac252dc5ed
commit 99ab514c0c

View file

@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useWorkspaceStore } from '../workspaceStore'; import { useWorkspaceStore } from '../workspaceStore';
import { useGraphStore } from '../graphStore'; import { useGraphStore } from '../graphStore';
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig } from '../../types';
/** /**
* useActiveDocument Hook * useActiveDocument Hook
@ -38,6 +39,21 @@ export function useActiveDocument() {
const isLoadingRef = useRef(false); const isLoadingRef = useRef(false);
const lastLoadedDocIdRef = useRef<string | null>(null); const lastLoadedDocIdRef = useRef<string | null>(null);
// Track the last synced graph state per document to prevent stale comparisons
const lastSyncedStateRef = useRef<{
documentId: string | null;
nodes: Actor[];
edges: Relation[];
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
}>({
documentId: null,
nodes: [],
edges: [],
nodeTypes: [],
edgeTypes: [],
});
// Load active document into graphStore when it changes // Load active document into graphStore when it changes
useEffect(() => { useEffect(() => {
if (activeDocument && activeDocumentId) { if (activeDocument && activeDocumentId) {
@ -52,6 +68,15 @@ export function useActiveDocument() {
setNodeTypes(activeDocument.graph.nodeTypes as never[]); setNodeTypes(activeDocument.graph.nodeTypes as never[]);
setEdgeTypes(activeDocument.graph.edgeTypes as never[]); setEdgeTypes(activeDocument.graph.edgeTypes as never[]);
// Update the last synced state to match what we just loaded
lastSyncedStateRef.current = {
documentId: activeDocumentId,
nodes: activeDocument.graph.nodes as Actor[],
edges: activeDocument.graph.edges as Relation[],
nodeTypes: activeDocument.graph.nodeTypes as NodeTypeConfig[],
edgeTypes: activeDocument.graph.edgeTypes as EdgeTypeConfig[],
};
// Clear loading flag after a brief delay to allow state to settle // Clear loading flag after a brief delay to allow state to settle
setTimeout(() => { setTimeout(() => {
isLoadingRef.current = false; isLoadingRef.current = false;
@ -69,22 +94,41 @@ export function useActiveDocument() {
return; return;
} }
// CRITICAL: Prevent cross-document contamination
// Only process changes if the graph state belongs to the current active document
if (lastSyncedStateRef.current.documentId !== activeDocumentId) {
console.log(`Skipping dirty check - graph state is from different document (${lastSyncedStateRef.current.documentId} vs ${activeDocumentId})`);
return;
}
// Mark document as dirty when graph changes // Mark document as dirty when graph changes
const hasChanges = const hasChanges =
JSON.stringify(graphNodes) !== JSON.stringify(activeDocument.graph.nodes) || JSON.stringify(graphNodes) !== JSON.stringify(lastSyncedStateRef.current.nodes) ||
JSON.stringify(graphEdges) !== JSON.stringify(activeDocument.graph.edges) || JSON.stringify(graphEdges) !== JSON.stringify(lastSyncedStateRef.current.edges) ||
JSON.stringify(graphNodeTypes) !== JSON.stringify(activeDocument.graph.nodeTypes) || JSON.stringify(graphNodeTypes) !== JSON.stringify(lastSyncedStateRef.current.nodeTypes) ||
JSON.stringify(graphEdgeTypes) !== JSON.stringify(activeDocument.graph.edgeTypes); JSON.stringify(graphEdgeTypes) !== JSON.stringify(lastSyncedStateRef.current.edgeTypes);
if (hasChanges) { if (hasChanges) {
console.log(`Document ${activeDocumentId} has changes, marking as dirty`); console.log(`Document ${activeDocumentId} has changes, marking as dirty`);
markDocumentDirty(activeDocumentId); markDocumentDirty(activeDocumentId);
// Update the document in workspace // Update the last synced state
activeDocument.graph.nodes = graphNodes as never[]; lastSyncedStateRef.current = {
activeDocument.graph.edges = graphEdges as never[]; documentId: activeDocumentId,
activeDocument.graph.nodeTypes = graphNodeTypes as never[]; nodes: graphNodes as Actor[],
activeDocument.graph.edgeTypes = graphEdgeTypes as never[]; edges: graphEdges as Relation[],
nodeTypes: graphNodeTypes as NodeTypeConfig[],
edgeTypes: graphEdgeTypes as EdgeTypeConfig[],
};
// Update the document in the workspace store
const updatedDoc = documents.get(activeDocumentId);
if (updatedDoc) {
updatedDoc.graph.nodes = graphNodes as never[];
updatedDoc.graph.edges = graphEdges as never[];
updatedDoc.graph.nodeTypes = graphNodeTypes as never[];
updatedDoc.graph.edgeTypes = graphEdgeTypes as never[];
}
// Debounced save // Debounced save
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
@ -93,7 +137,7 @@ export function useActiveDocument() {
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
} }
}, [graphNodes, graphEdges, graphNodeTypes, graphEdgeTypes, activeDocumentId, activeDocument, markDocumentDirty, saveDocument]); }, [graphNodes, graphEdges, graphNodeTypes, graphEdgeTypes, activeDocumentId, activeDocument, documents, markDocumentDirty, saveDocument]);
// Memory management: Unload inactive documents after timeout // Memory management: Unload inactive documents after timeout
useEffect(() => { useEffect(() => {