From 719f0038dbb09f0fe9b073c3dc8e4f403815223d Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 6 Sep 2026 18:17:38 +0900 Subject: feat(markdown): checkpoint streaming render — settled-block cache, highlight on close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/markdown/ui/Markdown.svelte | 38 ++++++++++--- src/features/markdown/ui/markdown.test.ts | 93 +++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 7 deletions(-) (limited to 'src/features/markdown/ui') diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte index 72b892b..da2c9ba 100644 --- a/src/features/markdown/ui/Markdown.svelte +++ b/src/features/markdown/ui/Markdown.svelte @@ -1,5 +1,6 @@
- - {@html html} + {#if streaming} + +
{@html rendered.prefixHtml}
+
{@html rendered.tailHtml}
+ {:else} + + {@html rendered.tailHtml} + {/if}
diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts index d65b3d1..721dddb 100644 --- a/src/features/markdown/ui/markdown.test.ts +++ b/src/features/markdown/ui/markdown.test.ts @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/svelte"; +import hljs from "highlight.js/lib/core"; import { describe, expect, it, vi } from "vitest"; import Markdown from "./Markdown.svelte"; @@ -37,4 +38,96 @@ describe("Markdown", () => { expect(writeText).toHaveBeenCalledTimes(1); expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;"); }); + + it("non-streaming renders a single container (today's full-render path)", () => { + const { container } = render(Markdown, { props: { text: "# Hello" } }); + expect(container.querySelector(".markdown-stream-prefix")).toBeNull(); + expect(container.querySelector(".markdown-stream-tail")).toBeNull(); + // The rendered blocks are DIRECT children of .markdown-body (no wrappers). + const body = container.querySelector(".markdown-body"); + expect(body?.querySelector("h1")).not.toBeNull(); + expect(body?.children[0]?.tagName).toBe("H1"); + }); + + it("streaming renders the prefix/tail split with content matching the full render", () => { + const { container } = render(Markdown, { + props: { text: "# Hello\n\nsettled paragraph\n\ngrowing tail", streaming: true }, + }); + + const prefix = container.querySelector(".markdown-stream-prefix"); + const tail = container.querySelector(".markdown-stream-tail"); + expect(prefix).not.toBeNull(); + expect(tail).not.toBeNull(); + // Same visible content as today's render, split across the two containers. + expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); + expect(container.textContent).toContain("growing tail"); + expect(prefix?.querySelector("h1")).not.toBeNull(); + }); + + it("streaming: the settled prefix's html string identity is stable across text appends (no DOM churn)", async () => { + const spy = vi.spyOn(hljs, "highlight"); + const { container, rerender } = render(Markdown, { + props: { text: "intro\n\n```js\nconst x = 1;\n```\n\nafter", streaming: true }, + }); + + const prefixBefore = container.querySelector(".markdown-stream-prefix")?.innerHTML; + expect(prefixBefore).toContain("hljs-keyword"); // the closed fence settled, highlighted once + expect(spy).toHaveBeenCalledTimes(1); + + // Appends that settle nothing leave the prefix string (and its DOM) alone. + await rerender({ text: "intro\n\n```js\nconst x = 1;\n```\n\nafter and", streaming: true }); + await rerender({ + text: "intro\n\n```js\nconst x = 1;\n```\n\nafter and more", + streaming: true, + }); + expect(container.querySelector(".markdown-stream-prefix")?.innerHTML).toBe(prefixBefore); + expect(spy).toHaveBeenCalledTimes(1); // still exactly one highlight + }); + + it("streaming: an open fence renders uncolored, then highlights exactly once when it closes", async () => { + const spy = vi.spyOn(hljs, "highlight"); + const { container, rerender } = render(Markdown, { + props: { text: "intro\n\n```js\nconst x", streaming: true }, + }); + + const tail = container.querySelector(".markdown-stream-tail"); + expect(tail?.innerHTML).toContain("
 {
+    const { container, rerender } = render(Markdown, {
+      props: { text: "# Hello\n\nbody", streaming: true },
+    });
+    expect(container.querySelector(".markdown-stream-prefix")).not.toBeNull();
+
+    await rerender({ text: "# Hello\n\nbody", streaming: false });
+    expect(container.querySelector(".markdown-stream-prefix")).toBeNull();
+    expect(container.querySelector(".markdown-stream-tail")).toBeNull();
+    expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument();
+  });
+
+  it("streaming: the delegated copy-button listener still works for tail code blocks", async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined);
+    Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
+
+    const { container } = render(Markdown, {
+      props: { text: "para\n\n```js\nconst y = 2;", streaming: true },
+    });
+
+    const button = container.querySelector("[data-copy]");
+    expect(button).not.toBeNull();
+    if (button === null) throw new Error("expected a copy button");
+    await fireEvent.click(button);
+
+    expect(writeText).toHaveBeenCalledTimes(1);
+    expect(writeText.mock.calls[0]?.[0]).toContain("const y = 2;");
+  });
 });
-- 
cgit v1.2.3