summaryrefslogtreecommitdiffhomepage
path: root/src/features/markdown/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-06 18:17:38 +0900
committerAdam Malczewski <[email protected]>2026-09-06 18:17:38 +0900
commit719f0038dbb09f0fe9b073c3dc8e4f403815223d (patch)
tree45f5d05fe0d36dcae80c0f8d06ac1276c4c38a7a /src/features/markdown/ui
parent39ad647b745ea4250a428c21f848c07b915cae17 (diff)
downloaddispatch-web-719f0038dbb09f0fe9b073c3dc8e4f403815223d.tar.gz
dispatch-web-719f0038dbb09f0fe9b073c3dc8e4f403815223d.zip
feat(markdown): checkpoint streaming render — settled-block cache, highlight on close
Diffstat (limited to 'src/features/markdown/ui')
-rw-r--r--src/features/markdown/ui/Markdown.svelte38
-rw-r--r--src/features/markdown/ui/markdown.test.ts93
2 files changed, 124 insertions, 7 deletions
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 @@
<script lang="ts">
import { renderMarkdown } from "../logic/markdown";
+ import { advance, createStreamingMarkdown } from "../logic/streaming-markdown";
let {
text,
@@ -10,15 +11,27 @@
streaming?: boolean;
} = $props();
- // Pure transform; the HTML is already DOMPurify-sanitized in renderMarkdown.
- const html = $derived(renderMarkdown(text, { streaming }));
+ // Checkpoint streaming state: one per component instance (per streaming
+ // message). It caches the settled blocks' HTML so each delta re-renders only
+ // the live tail — never the settled prefix (see logic/streaming-markdown).
+ const streamingState = createStreamingMarkdown();
+
+ // While streaming: the checkpoint split (settled prefix + live tail), which
+ // equals today's full render at every step modulo inter-block newlines and
+ // highlight-on-close. When the message completes (or for every non-streaming
+ // chunk): ONE full authoritative render — today's exact behavior, so the
+ // final/persisted output is byte-identical by construction.
+ const rendered = $derived.by(() => {
+ if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text) };
+ return advance(streamingState, text);
+ });
let container: HTMLElement;
// One delegated listener on the stable container handles every code block's
- // copy button — including blocks re-created when `html` changes (streaming),
- // since the listener lives on the container, not the buttons. Clipboard is the
- // edge effect; absent (insecure context) → no-op.
+ // copy button — including blocks re-created when the tail html changes
+ // (streaming), since the listener lives on the container, not the buttons.
+ // Clipboard is the edge effect; absent (insecure context) → no-op.
$effect(() => {
const el = container;
if (el === undefined) return;
@@ -53,6 +66,17 @@
</script>
<div class="markdown-body" bind:this={container}>
- <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
- {@html html}
+ {#if streaming}
+ <!-- Two sibling wrappers (no padding/border/min-height, so block margins
+ collapse across them exactly as if the blocks were direct children).
+ Svelte 5 skips the {@html} DOM write when the string is unchanged, so
+ the settled prefix's subtree is never re-parsed/re-created/re-laid-out;
+ only the tail's subtree is replaced per delta. Both strings are
+ DOMPurify-sanitized in the logic modules. -->
+ <div class="markdown-stream-prefix">{@html rendered.prefixHtml}</div>
+ <div class="markdown-stream-tail">{@html rendered.tailHtml}</div>
+ {:else}
+ <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
+ {@html rendered.tailHtml}
+ {/if}
</div>
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<HTMLElement>(".markdown-stream-prefix");
+ const tail = container.querySelector<HTMLElement>(".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<HTMLElement>(".markdown-stream-tail");
+ expect(tail?.innerHTML).toContain("<pre><code");
+ expect(tail?.innerHTML).toContain("data-copy"); // same copy button
+ expect(tail?.innerHTML).not.toContain("hljs-keyword"); // uncolored while open
+ expect(spy).not.toHaveBeenCalled();
+
+ // Close the fence → highlighted EXACTLY once, settled into the prefix.
+ await rerender({ text: "intro\n\n```js\nconst x = 1;\n```\n\ndone", streaming: true });
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(container.querySelector(".markdown-stream-prefix")?.innerHTML).toContain("hljs-keyword");
+ });
+
+ it("streaming → done flips to the single-container full render (completion backstop)", async () => {
+ 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<HTMLElement>("[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;");
+ });
});