mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-09 19:19:50 +08:00
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:
@@ -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> => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user