From 0e98e6ea271257ded7e52597598c92d303bf6cf0 Mon Sep 17 00:00:00 2001 From: ZeroDi Date: Wed, 23 Sep 2026 03:47:24 +0900 Subject: [PATCH 1/2] fix(preview): invalidate stale exports before debounce and cleanup --- src/lib/state/workspace-export.ts | 17 +++- tests/unit/workspace-export.test.ts | 153 ++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 tests/unit/workspace-export.test.ts diff --git a/src/lib/state/workspace-export.ts b/src/lib/state/workspace-export.ts index 5e67d44..3d497a9 100644 --- a/src/lib/state/workspace-export.ts +++ b/src/lib/state/workspace-export.ts @@ -50,14 +50,16 @@ export function createWorkspacePreviewRenderer({ let previewRenderTimer: ReturnType | undefined; function clearScheduledPreviewRender() { - if (previewRenderTimer) { + // Invalidate running exports as soon as the document changes or cleanup runs. + renderTurn += 1; + if (previewRenderTimer !== undefined) { clearTimeout(previewRenderTimer); previewRenderTimer = undefined; } + setIsRendering(false); } - async function renderPreview(document: JSONContent, options: DcExportOptions) { - const turn = ++renderTurn; + async function renderForTurn(document: JSONContent, options: DcExportOptions, turn: number) { setIsRendering(true); try { @@ -73,11 +75,18 @@ export function createWorkspacePreviewRenderer({ } } + async function renderPreview(document: JSONContent, options: DcExportOptions) { + clearScheduledPreviewRender(); + await renderForTurn(document, options, renderTurn); + } + function schedulePreviewRender(document: JSONContent, options: DcExportOptions) { clearScheduledPreviewRender(); + const turn = renderTurn; + setIsRendering(true); previewRenderTimer = setTimeout(() => { previewRenderTimer = undefined; - void renderPreview(document, options); + void renderForTurn(document, options, turn); }, debounceMs); } diff --git a/tests/unit/workspace-export.test.ts b/tests/unit/workspace-export.test.ts new file mode 100644 index 0000000..504bafc --- /dev/null +++ b/tests/unit/workspace-export.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { JSONContent } from "@tiptap/core"; +import { exportDocumentToDcHtml, type DcExportOptions } from "$lib/dc/export-document"; +import { createWorkspacePreviewRenderer } from "$lib/state/workspace-export"; + +vi.mock("$lib/dc/export-document", () => ({ exportDocumentToDcHtml: vi.fn() })); +vi.mock("$lib/dc/clipboard", () => ({ copyDcHtml: vi.fn(), copyPlainText: vi.fn() })); + +const options: DcExportOptions = { + theme: "github-dark", + bodyFontFamily: "Pretendard", + bodyFontSize: "17px", + codeFontSize: "15px", + showLineNumbers: false, +}; + +function document(text: string): JSONContent { + return { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text }] }], + }; +} + +function deferred() { + let resolve!: (value: string) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function createRenderer() { + const setHtml = vi.fn(); + const setIsRendering = vi.fn(); + const renderer = createWorkspacePreviewRenderer({ + debounceMs: 90, + setHtml, + setIsRendering, + }); + return { renderer, setHtml, setIsRendering }; +} + +describe("workspace preview render lifecycle", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(exportDocumentToDcHtml).mockReset(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("invalidates old results while a newer document is still debouncing", async () => { + const old = deferred(); + vi.mocked(exportDocumentToDcHtml) + .mockReturnValueOnce(old.promise) + .mockResolvedValueOnce("new-html"); + const { renderer, setHtml, setIsRendering } = createRenderer(); + const oldRender = renderer.renderPreview(document("old"), options); + + renderer.schedulePreviewRender(document("new"), options); + old.resolve("old-html"); + await oldRender; + + expect(setHtml).not.toHaveBeenCalled(); + expect(setIsRendering).toHaveBeenLastCalledWith(true); + await vi.advanceTimersByTimeAsync(90); + expect(setHtml).toHaveBeenCalledExactlyOnceWith("new-html"); + expect(setIsRendering).toHaveBeenLastCalledWith(false); + }); + + it("prevents a running export from publishing after cleanup", async () => { + const old = deferred(); + vi.mocked(exportDocumentToDcHtml).mockReturnValueOnce(old.promise); + const { renderer, setHtml, setIsRendering } = createRenderer(); + const oldRender = renderer.renderPreview(document("old"), options); + + renderer.clearScheduledPreviewRender(); + old.resolve("old-html"); + await oldRender; + + expect(setHtml).not.toHaveBeenCalled(); + expect(setIsRendering).toHaveBeenLastCalledWith(false); + expect(renderer.previewRenderTimer).toBeUndefined(); + }); + + it("cancels delayed work when an immediate render starts", async () => { + vi.mocked(exportDocumentToDcHtml).mockResolvedValue("now-html"); + const { renderer, setHtml } = createRenderer(); + const immediateDocument = document("now"); + + renderer.schedulePreviewRender(document("scheduled"), options); + await renderer.renderPreview(immediateDocument, options); + await vi.advanceTimersByTimeAsync(90); + + expect(exportDocumentToDcHtml).toHaveBeenCalledExactlyOnceWith(immediateDocument, options); + expect(setHtml).toHaveBeenCalledExactlyOnceWith("now-html"); + }); + + it("reports queued work as busy and coalesces rapid edits", async () => { + vi.mocked(exportDocumentToDcHtml).mockResolvedValue("b-html"); + const { renderer, setHtml, setIsRendering } = createRenderer(); + const latestDocument = document("b"); + + renderer.schedulePreviewRender(document("a"), options); + expect(setIsRendering).toHaveBeenLastCalledWith(true); + renderer.schedulePreviewRender(latestDocument, options); + await vi.advanceTimersByTimeAsync(90); + + expect(exportDocumentToDcHtml).toHaveBeenCalledExactlyOnceWith(latestDocument, options); + expect(setHtml).toHaveBeenCalledExactlyOnceWith("b-html"); + }); + + it("keeps the latest result when the older export finishes last", async () => { + const old = deferred(); + const next = deferred(); + vi.mocked(exportDocumentToDcHtml) + .mockReturnValueOnce(old.promise) + .mockReturnValueOnce(next.promise); + const { renderer, setHtml } = createRenderer(); + + const oldRender = renderer.renderPreview(document("old"), options); + const nextRender = renderer.renderPreview(document("new"), options); + next.resolve("new-html"); + await nextRender; + old.resolve("old-html"); + await oldRender; + + expect(setHtml).toHaveBeenCalledExactlyOnceWith("new-html"); + }); + + it("does not let an old rejection reset a newer queued busy state", async () => { + const old = deferred(); + vi.mocked(exportDocumentToDcHtml) + .mockReturnValueOnce(old.promise) + .mockResolvedValueOnce("new-html"); + const { renderer, setHtml, setIsRendering } = createRenderer(); + const oldRender = renderer.renderPreview(document("old"), options); + const rejected = expect(oldRender).rejects.toThrow("old failure"); + + renderer.schedulePreviewRender(document("new"), options); + old.reject(new Error("old failure")); + await rejected; + + expect(setHtml).not.toHaveBeenCalled(); + expect(setIsRendering).toHaveBeenLastCalledWith(true); + await vi.advanceTimersByTimeAsync(90); + expect(setHtml).toHaveBeenCalledExactlyOnceWith("new-html"); + }); +}); From 03be3daa272bf6b5d6f327289450a6e576b53e70 Mon Sep 17 00:00:00 2001 From: ZeroDi Date: Wed, 23 Sep 2026 04:00:14 +0900 Subject: [PATCH 2/2] test(preview): type mocks and await rejected render explicitly --- tests/unit/workspace-export.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/unit/workspace-export.test.ts b/tests/unit/workspace-export.test.ts index 504bafc..fb38b35 100644 --- a/tests/unit/workspace-export.test.ts +++ b/tests/unit/workspace-export.test.ts @@ -3,8 +3,13 @@ import type { JSONContent } from "@tiptap/core"; import { exportDocumentToDcHtml, type DcExportOptions } from "$lib/dc/export-document"; import { createWorkspacePreviewRenderer } from "$lib/state/workspace-export"; -vi.mock("$lib/dc/export-document", () => ({ exportDocumentToDcHtml: vi.fn() })); -vi.mock("$lib/dc/clipboard", () => ({ copyDcHtml: vi.fn(), copyPlainText: vi.fn() })); +vi.mock("$lib/dc/export-document", () => ({ + exportDocumentToDcHtml: vi.fn(), +})); +vi.mock("$lib/dc/clipboard", () => ({ + copyDcHtml: vi.fn<(html: string, plainText: string) => Promise>(), + copyPlainText: vi.fn<(text: string) => Promise>(), +})); const options: DcExportOptions = { theme: "github-dark", @@ -32,8 +37,8 @@ function deferred() { } function createRenderer() { - const setHtml = vi.fn(); - const setIsRendering = vi.fn(); + const setHtml = vi.fn<(html: string) => void>(); + const setIsRendering = vi.fn<(isRendering: boolean) => void>(); const renderer = createWorkspacePreviewRenderer({ debounceMs: 90, setHtml, @@ -139,11 +144,11 @@ describe("workspace preview render lifecycle", () => { .mockResolvedValueOnce("new-html"); const { renderer, setHtml, setIsRendering } = createRenderer(); const oldRender = renderer.renderPreview(document("old"), options); - const rejected = expect(oldRender).rejects.toThrow("old failure"); + const oldOutcome = oldRender.catch((error: unknown) => error); renderer.schedulePreviewRender(document("new"), options); old.reject(new Error("old failure")); - await rejected; + expect(await oldOutcome).toEqual(new Error("old failure")); expect(setHtml).not.toHaveBeenCalled(); expect(setIsRendering).toHaveBeenLastCalledWith(true);