From 8849c913e368d60909f8c64fe40c3aa23e96151c Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Mon, 5 Oct 2026 22:24:54 +0900 Subject: [PATCH] fix(chat): stop saving and exporting an idle chat every second Each auto-save takes a thumbnail, and draw.io's SVG gets a new random id every time, so latestSvg changed and the diagram context rendered again. getThumbnailSvg was a new function on every render, the auto-save depends on it, so the next save was scheduled a second later: a chat with some diagrams was saved and exported once a second for as long as it was open. getThumbnailSvg (and the export helper it uses) now keep one identity; they read refs only. Found by hand in the production build; the new e2e test fails without the fix on a production build (as CI runs it). --- contexts/diagram-context.tsx | 43 +++++++++++---------- tests/e2e/history-restore.spec.ts | 63 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 20 deletions(-) diff --git a/contexts/diagram-context.tsx b/contexts/diagram-context.tsx index 8ec4b05f..0295a98f 100644 --- a/contexts/diagram-context.tsx +++ b/contexts/diagram-context.tsx @@ -1,7 +1,7 @@ "use client" import type React from "react" -import { createContext, useContext, useRef, useState } from "react" +import { createContext, useCallback, useContext, useRef, useState } from "react" import type { DrawIoEmbedRef, EventExport } from "react-drawio" import { toast } from "sonner" import type { ExportFormat } from "@/components/save-dialog" @@ -126,25 +126,28 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { // Export with a tag in `message` (draw.io echoes it back in the export // event) and wait for that result. Resolves to null on timeout, which is // expected occasionally. - const requestTaggedExport = ( - tag: ExportTag, - format: "xmlsvg" | "png", - timeoutMs: number, - ) => - new Promise((resolve) => { - const id = `${tag}-${++exportSeqRef.current}` - const finish = (value: string | null) => { - clearTimeout(timer) - delete exportResolversRef.current[id] - resolve(value) - } - const timer = setTimeout(() => finish(null), timeoutMs) - exportResolversRef.current[id] = finish - drawioRef.current?.exportDiagram({ format, message: id }) - }) + // (Reads refs only, so it keeps one identity) + const requestTaggedExport = useCallback( + (tag: ExportTag, format: "xmlsvg" | "png", timeoutMs: number) => + new Promise((resolve) => { + const id = `${tag}-${++exportSeqRef.current}` + const finish = (value: string | null) => { + clearTimeout(timer) + delete exportResolversRef.current[id] + resolve(value) + } + const timer = setTimeout(() => finish(null), timeoutMs) + exportResolversRef.current[id] = finish + drawioRef.current?.exportDiagram({ format, message: id }) + }), + [], + ) - // Get current diagram as SVG for thumbnail (used by session storage) - const getThumbnailSvg = async (): Promise => { + // Get current diagram as SVG for thumbnail (used by session storage). + // One identity: the chat's auto-save depends on it, and each thumbnail + // renders this provider again (latestSvg), which would otherwise start + // the next save + const getThumbnailSvg = useCallback(async (): Promise => { if (!drawioRef.current) return null // Don't export if diagram is empty if (!isRealDiagram(chartXMLRef.current)) return null @@ -157,7 +160,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { return svgData } return null - } + }, [requestTaggedExport]) // Capture current diagram as PNG for VLM validation const captureValidationPng = async (): Promise => { diff --git a/tests/e2e/history-restore.spec.ts b/tests/e2e/history-restore.spec.ts index 37b5982c..1fa48d8a 100644 --- a/tests/e2e/history-restore.spec.ts +++ b/tests/e2e/history-restore.spec.ts @@ -424,3 +424,66 @@ test("new chat right after an answer saves that chat once", async ({ expect(await countSessions(page), `run ${run}`).toBe(run) } }) + +test("an idle chat is not saved again and again", async ({ page }) => { + test.setTimeout(90_000) + await page.route("**/api/chat", async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + // The tool result comes from the page, as with a real model + body: `${[ + { type: "start" }, + { type: "text-start", id: "t" }, + { type: "text-delta", id: "t", delta: "Drew the box." }, + { type: "text-end", id: "t" }, + { + type: "tool-input-start", + toolCallId: "c1", + toolName: "display_diagram", + }, + { + type: "tool-input-available", + toolCallId: "c1", + toolName: "display_diagram", + input: { + // Some diagrams (this one) export a new image each time + xml: '', + }, + }, + { type: "finish" }, + ] + .map((e) => `data: ${JSON.stringify(e)}\n\n`) + .join("")}data: [DONE]\n\n`, + }) + }) + // Every auto-save takes a thumbnail: count draw.io's thumbnail exports + await page.addInitScript(() => { + const w = window as unknown as { thumbnails: number } + w.thumbnails = 0 + window.addEventListener("message", (e) => { + try { + const m = JSON.parse(e.data) + if ( + m.event === "export" && + String(m.message?.message ?? "").startsWith("thumbnail") + ) { + w.thumbnails++ + } + } catch {} + }) + }) + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + await sendMessage(page, "Draw a box") + await waitForText(page, "Drew the box.") + // Let the auto-save after the answer finish + await page.waitForTimeout(5000) + const thumbnails = () => + page.evaluate( + () => (window as unknown as { thumbnails: number }).thumbnails, + ) + const settled = await thumbnails() + await page.waitForTimeout(5000) + expect(await thumbnails()).toBe(settled) +})