Skip to content

Commit 672d430

Browse files
committed
Lazy-load KaTeX and highlight.js, cut per-token markdown render costs
1 parent 17c9f39 commit 672d430

8 files changed

Lines changed: 158 additions & 90 deletions

File tree

src/lib/components/CodeBlock.svelte

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,20 @@
1515
1616
let previewOpen = $state(false);
1717
18+
// `code` always comes from our own highlighter (hljs or escapeHTML in
19+
// marked.ts), which only emits escaped text inside hljs <span> wrappers.
20+
// While the fence is still streaming (`loading`), the block re-renders on
21+
// every flush, and re-sanitizing the whole growing block each time is the
22+
// main-thread hot path of code streaming. Skip DOMPurify during that
23+
// window only while the html verifiably matches the highlighter's output
24+
// alphabet (raw `<` may open nothing but a span tag): any other markup —
25+
// which our highlighter cannot produce — falls back to a full sanitize.
26+
// Every completed block still gets sanitized as defense in depth.
27+
const NON_HIGHLIGHTER_TAG = /<(?!\/?span[\s>])/i;
28+
let sanitizedCode = $derived(
29+
loading && !NON_HIGHLIGHTER_TAG.test(code) ? code : DOMPurify.sanitize(code)
30+
);
31+
1832
function hasStrictHtml5Doctype(input: string): boolean {
1933
if (!input) return false;
2034
const withoutBOM = input.replace(/^\uFEFF/, "");
@@ -64,7 +78,7 @@
6478
</div>
6579
</div>
6680
<pre class="scrollbar-custom overflow-auto px-5 font-mono transition-[height]"><code
67-
><!-- eslint-disable svelte/no-at-html-tags -->{@html DOMPurify.sanitize(code)}</code
81+
><!-- eslint-disable svelte/no-at-html-tags -->{@html sanitizedCode}</code
6882
></pre>
6983

7084
{#if previewOpen}

src/lib/components/chat/ArtifactPanel.svelte

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { diffLines, diffStats, renderDiffHtml } from "$lib/utils/artifactDiff";
88
import { buildArtifactSrcdoc, isDeployableKind } from "$lib/utils/previewSrcdoc";
99
import { parseExternalUrl } from "$lib/utils/externalLink";
10-
import { highlightCode } from "$lib/utils/marked";
10+
import { escapeHTML } from "$lib/utils/markedLight";
1111
import { artifactPanel, ARTIFACT_PANEL_DEFAULT_FRACTION } from "$lib/stores/artifactPanel.svelte";
1212
import { pendingChatInput } from "$lib/stores/pendingChatInput";
1313
import { usePublicConfig } from "$lib/utils/PublicConfig.svelte";
@@ -111,8 +111,27 @@
111111
}
112112
}
113113
114+
// The highlighter lives in the KaTeX/highlight.js chunk, which is kept out
115+
// of the entry bundle (see markedLight.ts). Load it the first time the
116+
// panel opens; until it resolves, code renders as escaped plain text and
117+
// the effect below re-runs automatically once the real highlighter lands.
118+
let highlightCode: ((text: string, lang?: string) => string) | undefined = $state();
119+
$effect(() => {
120+
if (!artifactPanel.open || highlightCode) return;
121+
import("$lib/utils/marked")
122+
.then((markedModule) => {
123+
highlightCode = markedModule.highlightCode;
124+
})
125+
.catch(() => {
126+
// Chunk failed to load (offline, deploy rotation): keep escaped text
127+
// permanently rather than retrying on every effect re-run.
128+
highlightCode = (text: string) => escapeHTML(text);
129+
});
130+
});
131+
114132
$effect(() => {
115133
if (!artifactPanel.open) return;
134+
const highlight = highlightCode ?? ((text: string) => escapeHTML(text));
116135
// A pending throttled run would paint stale content over whatever the
117136
// branches below decide to show
118137
clearTimeout(highlightTimer);
@@ -127,9 +146,7 @@
127146
// The highlighter runs on the full old/new contents so token colors
128147
// survive in the diff (multi-line constructs included).
129148
const lang = hljsLanguageFor(version);
130-
highlightedCode = DOMPurify.sanitize(
131-
renderDiffHtml(diff, (text) => highlightCode(text, lang))
132-
);
149+
highlightedCode = DOMPurify.sanitize(renderDiffHtml(diff, (text) => highlight(text, lang)));
133150
lastHighlightAt = Date.now();
134151
return;
135152
}
@@ -138,7 +155,7 @@
138155
const complete = version.complete;
139156
140157
const run = () => {
141-
highlightedCode = DOMPurify.sanitize(highlightCode(content, lang));
158+
highlightedCode = DOMPurify.sanitize(highlight(content, lang));
142159
lastHighlightAt = Date.now();
143160
};
144161

src/lib/components/chat/MarkdownBlock.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import type { Token } from "$lib/utils/marked";
2+
import type { Token } from "$lib/utils/markedLight";
33
import CodeBlock from "../CodeBlock.svelte";
44
55
interface Props {

src/lib/components/chat/MarkdownRenderer.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { fallbackBlocks, type BlockToken } from "$lib/utils/marked";
2+
import { fallbackBlocks, type BlockToken } from "$lib/utils/markedLight";
33
import {
44
acquireMarkdownClientId,
55
cancelMarkdownClient,

src/lib/utils/markdownWorkerPool.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
import { browser } from "$app/environment";
22
import MarkdownWorker from "$lib/workers/markdownWorker?worker";
3-
import { fallbackBlocks, processBlocks, type BlockToken } from "$lib/utils/marked";
3+
import { fallbackBlocks, type BlockToken } from "$lib/utils/markedLight";
4+
5+
// The rich pipeline (KaTeX + highlight.js, ~700KB decoded) normally lives only
6+
// inside the worker's own chunk. The main thread needs it solely when workers
7+
// are unavailable (no Worker support, strict CSP, repeated worker deaths), so
8+
// it is loaded on demand and memoized — never in the entry bundle.
9+
let richModulePromise: Promise<typeof import("$lib/utils/marked")> | undefined;
10+
const loadRichModule = () =>
11+
(richModulePromise ??= import("$lib/utils/marked").catch((err) => {
12+
// Never memoize a rejected import: a transient chunk-load failure (deploy
13+
// rotation, flaky network) would otherwise disable rich markdown for the
14+
// rest of the session. Clearing lets the next render retry.
15+
richModulePromise = undefined;
16+
throw err;
17+
}));
418

519
type Source = { title?: string; link: string };
620
type ResultCallback = (blocks: BlockToken[], requestId: number) => void;
@@ -72,11 +86,13 @@ function runOnMainThread(job: {
7286
requestId: number;
7387
onResult: ResultCallback;
7488
}) {
75-
void processBlocks(job.content, job.sources, job.streaming)
89+
void loadRichModule()
90+
.then((markedModule) => markedModule.processBlocks(job.content, job.sources, job.streaming))
7691
.then((blocks) => job.onResult(blocks, job.requestId))
77-
// Symmetry with the worker path ("never go silent"): on a parse error still
78-
// resolve the render with the lightweight fallback instead of leaking an
79-
// unhandled rejection and stranding the renderer on escaped plaintext.
92+
// Symmetry with the worker path ("never go silent"): on a parse error (or a
93+
// failed chunk load) still resolve the render with the lightweight fallback
94+
// instead of leaking an unhandled rejection and stranding the renderer on
95+
// escaped plaintext.
8096
.catch(() => job.onResult(fallbackBlocks(job.content), job.requestId));
8197
}
8298

src/lib/utils/marked.ts

Lines changed: 8 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ import sql from "highlight.js/lib/languages/sql";
3131
import plaintext from "highlight.js/lib/languages/plaintext";
3232
import { parseIncompleteMarkdown } from "./parseIncompleteMarkdown";
3333
import { parseMarkdownIntoBlocks } from "./parseBlocks";
34+
import { escapeHTML, type BlockToken, type Token } from "./markedLight";
35+
36+
// Re-export the light pieces so existing consumers of this module (the worker,
37+
// tests) keep a single import site. Main-thread code must import them from
38+
// ./markedLight directly — importing this module eagerly pulls KaTeX +
39+
// highlight.js into the entry bundle.
40+
export { escapeHTML, fallbackBlocks } from "./markedLight";
41+
export type { BlockToken, CodeToken, TextToken, Token } from "./markedLight";
3442

3543
const bundledLanguages: [string, LanguageFn][] = [
3644
["javascript", javascript],
@@ -227,20 +235,6 @@ const katexInlineExtension: TokenizerExtension & RendererExtension = {
227235
},
228236
};
229237

230-
function escapeHTML(content: string) {
231-
return content.replace(
232-
/[<>&"']/g,
233-
(x) =>
234-
({
235-
"<": "&lt;",
236-
">": "&gt;",
237-
"&": "&amp;",
238-
"'": "&#39;",
239-
'"': "&quot;",
240-
})[x] || x
241-
);
242-
}
243-
244238
function addInlineCitations(md: string, webSearchSources: SimpleSource[] = []): string {
245239
const linkStyle =
246240
"color: rgb(59, 130, 246); text-decoration: none; hover:text-decoration: underline;";
@@ -421,19 +415,6 @@ function isFencedBlockClosed(raw?: string): boolean {
421415
return closingFencePattern.test(trimmed);
422416
}
423417

424-
type CodeToken = {
425-
type: "code";
426-
lang: string;
427-
code: string;
428-
rawCode: string;
429-
isClosed: boolean;
430-
};
431-
432-
type TextToken = {
433-
type: "text";
434-
html: string | Promise<string>;
435-
};
436-
437418
const blockCache = new Map<string, BlockToken>();
438419

439420
// The markdown worker pool keeps a small number of long-lived workers alive for the whole
@@ -499,14 +480,6 @@ export function processTokensSync(content: string, sources: SimpleSource[]): Tok
499480
});
500481
}
501482

502-
export type Token = CodeToken | TextToken;
503-
504-
export type BlockToken = {
505-
id: string;
506-
content: string;
507-
tokens: Token[];
508-
};
509-
510483
/**
511484
* Simple hash function for generating stable block IDs
512485
*/
@@ -555,37 +528,6 @@ export async function processBlocks(
555528
);
556529
}
557530

558-
/**
559-
* Cheap, allocation-light blocks used for SSR and the initial client render.
560-
*
561-
* Rich markdown rendering (marked + highlight.js + KaTeX + DOMPurify/jsdom) is
562-
* intentionally NOT run here: it executes synchronously on the single Node event loop
563-
* during SSR and, summed across every message of a conversation, can block the loop
564-
* long enough to fail liveness/readiness health checks (observed event-loop stalls up
565-
* to ~1.5s). The browser upgrades each message to fully rendered markdown via the
566-
* markdown worker (or async processBlocks fallback) on mount.
567-
*
568-
* The output is deterministic and identical on server and client, so hydration is not
569-
* affected; only the first paint shows lightly-formatted text before the worker result
570-
* arrives.
571-
*/
572-
export function fallbackBlocks(content: string): BlockToken[] {
573-
// Static id: it is only used as the {#each} key for this single throwaway block and
574-
// has no semantic meaning, so there is no need to hash the (potentially large) content.
575-
return [
576-
{
577-
id: "fallback",
578-
content,
579-
tokens: [
580-
{
581-
type: "text",
582-
html: `<div style="white-space:pre-wrap;overflow-wrap:anywhere">${escapeHTML(content)}</div>`,
583-
},
584-
],
585-
},
586-
];
587-
}
588-
589531
/**
590532
* Synchronous version of processBlocks. Kept for non-SSR callers and tests; it is no
591533
* longer used on the SSR render path (see fallbackBlocks).

src/lib/utils/markedLight.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Dependency-free subset of the markdown pipeline.
2+
//
3+
// Everything importable from the main-thread entry chunks (MarkdownRenderer,
4+
// the worker pool, ArtifactPanel) must come from here: the rich pipeline in
5+
// ./marked.ts pulls in KaTeX + highlight.js (~700KB decoded), which must only
6+
// ever load inside the markdown worker or through a dynamic import().
7+
8+
export type CodeToken = {
9+
type: "code";
10+
lang: string;
11+
code: string;
12+
rawCode: string;
13+
isClosed: boolean;
14+
};
15+
16+
export type TextToken = {
17+
type: "text";
18+
html: string | Promise<string>;
19+
};
20+
21+
export type Token = CodeToken | TextToken;
22+
23+
export type BlockToken = {
24+
id: string;
25+
content: string;
26+
tokens: Token[];
27+
};
28+
29+
export function escapeHTML(content: string) {
30+
return content.replace(
31+
/[<>&"']/g,
32+
(x) =>
33+
({
34+
"<": "&lt;",
35+
">": "&gt;",
36+
"&": "&amp;",
37+
"'": "&#39;",
38+
'"': "&quot;",
39+
})[x] || x
40+
);
41+
}
42+
43+
/**
44+
* Cheap, allocation-light blocks used for SSR and the initial client render.
45+
*
46+
* Rich markdown rendering (marked + highlight.js + KaTeX + DOMPurify/jsdom) is
47+
* intentionally NOT run here: it executes synchronously on the single Node event loop
48+
* during SSR and, summed across every message of a conversation, can block the loop
49+
* long enough to fail liveness/readiness health checks (observed event-loop stalls up
50+
* to ~1.5s). The browser upgrades each message to fully rendered markdown via the
51+
* markdown worker (or async processBlocks fallback) on mount.
52+
*
53+
* The output is deterministic and identical on server and client, so hydration is not
54+
* affected; only the first paint shows lightly-formatted text before the worker result
55+
* arrives.
56+
*/
57+
export function fallbackBlocks(content: string): BlockToken[] {
58+
// Static id: it is only used as the {#each} key for this single throwaway block and
59+
// has no semantic meaning, so there is no need to hash the (potentially large) content.
60+
return [
61+
{
62+
id: "fallback",
63+
content,
64+
tokens: [
65+
{
66+
type: "text",
67+
html: `<div style="white-space:pre-wrap;overflow-wrap:anywhere">${escapeHTML(content)}</div>`,
68+
},
69+
],
70+
},
71+
];
72+
}

src/lib/workers/markdownWorker.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,24 @@ onmessage = async (event) => {
4141
}
4242

4343
try {
44-
postMessage(
45-
JSON.parse(JSON.stringify({ type: "processed", blocks, requestId })) as OutgoingMessage
46-
);
44+
// Blocks are plain data (strings/booleans/arrays) and structured-clone
45+
// directly; the JSON round-trip below is only needed for exotic values
46+
// (e.g. an unresolved promise-valued html) that would fail to clone.
47+
postMessage({ type: "processed", blocks, requestId } as OutgoingMessage);
4748
} catch {
48-
// A block somehow isn't JSON/structured-clone safe. Reply with the fallback so the
49-
// pool always gets a reply and the shared worker is never left wedged. fallbackBlocks
50-
// output is plain data and always serializes.
51-
postMessage({
52-
type: "processed",
53-
blocks: fallbackBlocks(content),
54-
requestId,
55-
} as OutgoingMessage);
49+
try {
50+
postMessage(
51+
JSON.parse(JSON.stringify({ type: "processed", blocks, requestId })) as OutgoingMessage
52+
);
53+
} catch {
54+
// A block somehow isn't JSON-safe either. Reply with the fallback so the
55+
// pool always gets a reply and the shared worker is never left wedged.
56+
// fallbackBlocks output is plain data and always serializes.
57+
postMessage({
58+
type: "processed",
59+
blocks: fallbackBlocks(content),
60+
requestId,
61+
} as OutgoingMessage);
62+
}
5663
}
5764
};

0 commit comments

Comments
 (0)