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).
This commit is contained in:
dayuan.jiang
2026-10-05 22:24:54 +09:00
parent b8b851fc5b
commit 8849c913e3
2 changed files with 86 additions and 20 deletions
+23 -20
View File
@@ -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<string | null>((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<string | null>((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<string | null> => {
// 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<string | null> => {
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<string | null> => {
+63
View File
@@ -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: '<mxCell id="2" value="Box" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
},
},
{ 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)
})