From 08d58f7a6d8d49c6c2050438ae8519e4b4f8a4d2 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 15 Feb 2026 12:45:40 -0500 Subject: [PATCH] fix: Small race conditions in diagrams.net integration (#11458) --- shared/editor/extensions/Diagrams.ts | 86 ++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 24 deletions(-) diff --git a/shared/editor/extensions/Diagrams.ts b/shared/editor/extensions/Diagrams.ts index ba33f6f056..a31ab0a963 100644 --- a/shared/editor/extensions/Diagrams.ts +++ b/shared/editor/extensions/Diagrams.ts @@ -12,6 +12,16 @@ import { } from "../lib/DiagramsNetClient"; import { sanitizeUrl } from "../../utils/urls"; +/** + * Tracks the mutable state for a single diagram editing session. Callbacks + * close over a session object so that concurrent or overlapping sessions + * do not interfere with each other. + */ +interface DiagramSession { + /** The current src used to locate the node in the document. Updated after each successful export. */ + nodeSrc: string; +} + /** * An editor extension that adds commands to insert and edit diagrams using diagrams.net. * @@ -27,6 +37,10 @@ export default class Diagrams extends Extension { commands(): Record { return { editDiagram: (): Command => (state, dispatch) => { + if (!dispatch) { + return true; + } + const selectedNode = this.getSelectedImageNode(state); if (!selectedNode) { @@ -61,7 +75,10 @@ export default class Diagrams extends Extension { * @param state - the editor state. * @param dispatch - the dispatch function. */ - private insertEmptyDiagram(state: EditorState, dispatch?: any) { + private insertEmptyDiagram( + state: EditorState, + dispatch: (tr: ReturnType) => void + ) { const type = this.editor.schema.nodes.image; const { tr } = state; const transaction = tr.insert( @@ -71,7 +88,7 @@ export default class Diagrams extends Extension { source: ImageSource.DiagramsNet, }) ); - dispatch?.(transaction); + dispatch(transaction); } /** @@ -80,18 +97,21 @@ export default class Diagrams extends Extension { * @param node - the selected image node, if any. */ private openDiagramEditor(node?: Node) { - this.currentNodeSrc = node?.attrs.src ?? ""; - const sourceUrl = this.currentNodeSrc || EMPTY_DIAGRAM_IMAGE; + const nodeSrc = node?.attrs.src ?? ""; + const sourceUrl = nodeSrc || EMPTY_DIAGRAM_IMAGE; + + // Create a per-session object. Async callbacks close over this object so + // that a second editing session does not clobber the first session's state. + const session: DiagramSession = { nodeSrc }; // Clean up any existing client if (this.client) { this.client.close(); } - // Create new client with callbacks this.client = new DiagramsNetClient( - () => this.onDiagramReady(sourceUrl), - (base64Data) => this.onDiagramExported(base64Data) + (client) => this.onDiagramReady(client, sourceUrl), + (base64Data) => this.onDiagramExported(base64Data, session) ); this.client.open(this.getDiagramsNetUrl()); @@ -100,9 +120,10 @@ export default class Diagrams extends Extension { /** * Called when the diagram editor is ready to receive commands. * + * @param client - the DiagramsNetClient that fired the ready event. * @param sourceUrl - the URL of the diagram to load, or the empty diagram constant. */ - private async onDiagramReady(sourceUrl: string) { + private async onDiagramReady(client: DiagramsNetClient, sourceUrl: string) { let data: string; if (sourceUrl === EMPTY_DIAGRAM_IMAGE) { @@ -113,25 +134,38 @@ export default class Diagrams extends Extension { data = await FileHelper.urlToBase64(sourceUrl); } - this.client.loadDiagram(data); + client.loadDiagram(data); } /** * Called when a diagram has been exported from the editor. * * @param base64Data - the exported diagram as base64 encoded SVG. + * @param session - the editing session that produced this export. */ - private async onDiagramExported(base64Data: string) { - const file = FileHelper.base64ToFile( - base64Data, - "diagram.svg", - "image/svg+xml" - ); - const dimensions = await FileHelper.getImageDimensions(file); - const uploadedUrl = await this.uploadDiagramFile(file); + private async onDiagramExported(base64Data: string, session: DiagramSession) { + try { + const file = FileHelper.base64ToFile( + base64Data, + "diagram.svg", + "image/svg+xml" + ); - this.updateDiagramInDocument(uploadedUrl, dimensions || {}); - this.currentNodeSrc = uploadedUrl; + const dimensions = await FileHelper.getImageDimensions(file); + const uploadedUrl = await this.uploadDiagramFile(file); + + // Capture the src we need to search for *before* updating the session, + // then update the document and the session atomically. + const srcToFind = session.nodeSrc; + this.updateDiagramInDocument(uploadedUrl, dimensions || {}, srcToFind); + + // Update session so that subsequent saves within the same editing session + // can locate the node by its new uploaded URL. + session.nodeSrc = uploadedUrl; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Failed to export diagram:", error); + } } /** @@ -150,21 +184,26 @@ export default class Diagrams extends Extension { } /** - * Updates or inserts the diagram image in the document. + * Updates or inserts the diagram image in the document. Always reads fresh + * editor state at call-time so that positions are accurate even after async + * gaps. * * @param uploadedUrl - the URL of the uploaded diagram. * @param dimensions - the image dimensions. + * @param srcToFind - the src attribute value to search for in the document. */ private updateDiagramInDocument( uploadedUrl: string, - dimensions: { width?: number; height?: number } + dimensions: { width?: number; height?: number }, + srcToFind: string ) { + // Read fresh state at the moment of dispatch to avoid stale positions. const { state } = this.editor.view; const { dispatch } = this.editor.view; const imageType = this.editor.schema.nodes.image; - // Try to find and update existing node - const existingNode = this.findImageNodeBySrc(state, this.currentNodeSrc); + // Try to find and update the existing node by its current src attribute. + const existingNode = this.findImageNodeBySrc(state, srcToFind); const attrs = { ...dimensions, @@ -226,5 +265,4 @@ export default class Diagrams extends Extension { } private client: DiagramsNetClient; - private currentNodeSrc: string = ""; }