From defc6961764a786177952e42ea0cc81be33533de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 04:56:21 +0000 Subject: [PATCH] perf(render): highlight against one theme when the scheme is known MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shiki tokenizes once PER THEME, so asking for a light/dark pair costs exactly twice as much as asking for one. We asked for both on every render and then threw one away: shikiSchemeCss flipped to the dark colors with CSS, and the viewer had already told us which scheme it resolved — Card.tsx appends &mode= to every surface iframe src. Highlight against the single resolved theme when mode is pinned, and drop the now-unnecessary flip rule. An unpinned direct load of /s/:id (someone opening the link outside the viewer) still gets both themes and follows the OS through the existing media query. Measured with the benchmark suite, 1402-line TypeScript surface: code/large 1.14 s -> 543 ms (-52%) code/large document 751 KB -> 529 KB (-30%) markdown/large 51.9 ms -> 27.9 ms (-45%) theme switch burst 99.9 ms -> 53.8 ms (-45%) Through the real route (/s/:id?mode=dark): 1109 ms -> 582 ms. Output is visually identical: dual-theme emitted the light color inline plus a --shiki-dark custom property and overrode it with `color: var(--shiki-dark) !important`; single-theme emits the same final color inline. The token color sequences are identical, and the pre background resolves to the same value either way. Terminal, diff, html and mermaid surfaces are untouched — none of them take the dual-theme path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MGMuudU8anVrxB7sjY9hpt --- .changeset/single-theme-highlighting.md | 11 +++++++++ server/richRender.ts | 31 +++++++++++++++++-------- 2 files changed, 32 insertions(+), 10 deletions(-) create mode 100644 .changeset/single-theme-highlighting.md diff --git a/.changeset/single-theme-highlighting.md b/.changeset/single-theme-highlighting.md new file mode 100644 index 00000000..586a4523 --- /dev/null +++ b/.changeset/single-theme-highlighting.md @@ -0,0 +1,11 @@ +--- +"sideshow": patch +--- + +Highlight code against one theme instead of two when the color scheme is already +known. shiki tokenizes once per theme, so asking for a light/dark pair costs +exactly twice as much — and the viewer always tells the server which scheme it +resolved, so half that work was being discarded with CSS. Rendering a +1400-line code surface drops from 1.14s to 543ms and its document from 751KB to +529KB; markdown with fenced code improves by roughly the same proportion. An +unpinned direct load of `/s/:id` still gets both themes and follows the OS. diff --git a/server/richRender.ts b/server/richRender.ts index 6b44e4ee..4780e711 100644 --- a/server/richRender.ts +++ b/server/richRender.ts @@ -47,8 +47,9 @@ const SHIKI_DARK_RULE = // given (no media query), else follows the OS — identical to shikiSchemeCss in // the viewer's highlight.ts (kept in lockstep so a refactor can delete that). function shikiSchemeCss(mode?: Mode): string { - if (mode === "dark") return SHIKI_DARK_RULE; - if (mode === "light") return ""; + // A resolved mode highlights against ONE theme (see shikiThemeOptions), so the + // colors are already the right ones inline and there is nothing to flip. + if (mode) return ""; return `@media (prefers-color-scheme: dark){${SHIKI_DARK_RULE}}`; } @@ -83,11 +84,11 @@ function highlight( hl: Highlighter, code: string, lang: string, - pair: { light: string; dark: string }, + themeOpts: ShikiThemeOptions, ): string | null { if (!lang) return null; try { - return hl.codeToHtml(code, { lang, themes: pair }); + return hl.codeToHtml(code, { lang, ...themeOpts }); } catch { return null; } @@ -97,9 +98,19 @@ function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } -function shikiPair(theme?: string): { light: string; dark: string } { +type ShikiThemeOptions = { theme: string } | { themes: { light: string; dark: string } }; + +// shiki tokenizes once PER THEME, so asking for a light/dark pair costs exactly +// twice as much as asking for one. When the caller already resolved the scheme — +// which the viewer always does, it appends &mode= to every surface iframe src — +// the second tokenization is pure waste: shikiSchemeCss then discards one half +// with CSS. Only an unpinned load (a bare /s/:id opened outside the viewer) needs +// both, and that one follows the OS through a media query. +function shikiThemeOptions(theme: string | undefined, mode: Mode | undefined): ShikiThemeOptions { const t = themeById(theme); - return { light: t.shiki.light, dark: t.shiki.dark }; + if (mode === "dark") return { theme: t.shiki.dark }; + if (mode === "light") return { theme: t.shiki.light }; + return { themes: { light: t.shiki.light, dark: t.shiki.dark } }; } // --------------------------------------------------------------------------- @@ -167,14 +178,14 @@ export async function renderMarkdown( opts: RenderOpts = {}, ): Promise { const src = part.markdown ?? ""; - const pair = shikiPair(opts.theme); + const themeOpts = shikiThemeOptions(opts.theme, opts.mode); const hl = await getHighlighter(); await loadLangs(hl, fenceLangs(src)); const md = new MarkdownIt({ html: false, linkify: true, - highlight: (code, lang) => highlight(hl, code, lang, pair) ?? "", + highlight: (code, lang) => highlight(hl, code, lang, themeOpts) ?? "", }); const renderLinkOpen = md.renderer.rules.link_open ?? @@ -303,11 +314,11 @@ export async function renderCode( const code = part.code ?? ""; const lang = part.language ?? "text"; const lineStart = part.lineStart ?? 1; - const pair = shikiPair(opts.theme); + const themeOpts = shikiThemeOptions(opts.theme, opts.mode); const hl = await getHighlighter(); if (lang && lang !== "text") await loadLangs(hl, [lang]); - const highlighted = highlight(hl, code, lang, pair); + const highlighted = highlight(hl, code, lang, themeOpts); const pre = highlighted ? highlighted.replace(/\n*(<\/span>)\n*(