Skip to content

feat: add cache option to reuse highlighted output across remounts - #208

Open
AVGVSTVS96 wants to merge 3 commits into
mainfrom
feat/highlight-cache
Open

AVGVSTVS96 wants to merge 3 commits into
mainfrom
feat/highlight-cache

Conversation

@AVGVSTVS96

Copy link
Copy Markdown
Owner

Remounting a code block highlights it from scratch today. In chat apps that remount transcripts on thread switch, every block re-tokenizes on the main thread (Hermes Agent hit this in NousResearch/hermes-agent#95595 and wrote its own LRU around shiki to fix it).

What it does

const cache = new Map();

<ShikiHighlighter language="tsx" theme="github-dark" cache={cache}>
  {code}
</ShikiHighlighter>
mount    cache hit  → initial state = cached output, paints on first render, no highlight call
         cache miss → highlight like today
unmount  store the output that was on screen

Decisions

  • Opt-in, user-owned. No cache unless you pass one; any { get, set } works, so a plain Map does. The app clears it on real events (thread closed) instead of react-shiki picking a size cap.
  • Store on unmount only. A streaming block goes through hundreds of partial versions; only what was on screen when it unmounted is kept.
  • Key = code + output format + resolved shiki options + line-number settings, so blocks with different themes/options can share one cache. Functions (transformer hooks) aren't part of the key; transformers are told apart by name.
  • Zero cost without a cache: the key isn't even computed, and cache is pulled out before options are deep-compared, so a growing Map never re-triggers highlighting.

Tests

4 new hook tests: remount paints synchronously with no extra highlight call, streaming partials aren't stored, different theme misses, switching back to cached code while mounted reuses it. 129/129 pass, tsc + biome clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_019UaWsciHPYLqiWN1aQq5WL

@changeset-bot

changeset-bot Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c4577ae

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
react-shiki Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
react-shiki Error Error Oct 10, 2026 9:11am UTC

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UaWsciHPYLqiWN1aQq5WL
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UaWsciHPYLqiWN1aQq5WL
@AVGVSTVS96
AVGVSTVS96 marked this pull request as ready for review October 13, 2026 10:05
Comment thread package/src/lib/types.ts
Comment on lines +238 to +241
type HighlightCache = Pick<
Map<string, HighlightResultMap[OutputFormat]>,
'get' | 'set'
>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pick<Map<…>, 'get' | 'set'> carries over Map's set return type, so set must return Map<string, …>. That rejects the "any { get, set } works" case from the PR description: a hand-rolled LRU whose set returns this or void fails assignability (and this is exactly what the motivating Hermes use case wrote). The runtime never uses the return value, so an explicit interface matches intent and is easier to read in hover tooltips:

Suggested change
type HighlightCache = Pick<
Map<string, HighlightResultMap[OutputFormat]>,
'get' | 'set'
>;
interface HighlightCache {
get(key: string): HighlightResultMap[OutputFormat] | undefined;
set(key: string, value: HighlightResultMap[OutputFormat]): unknown;
}

A plain Map still satisfies this (method parameters are bivariant, so Map<string, ReactElement> passes too).

Comment thread package/src/lib/hook.ts
Comment on lines +125 to +137
const cacheScope = useMemo(
() =>
hasCache
? JSON.stringify([
stableOpts.outputFormat ?? 'react',
resolved.shikiOptions,
stableOpts.startingLineNumber,
stableOpts.highlightLineNumbers,
])
: undefined,
[hasCache, resolved, stableOpts]
);
const cacheKey = cacheScope && `${cacheScope}\n${code}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, memory: resolved.shikiOptions.theme/themes holds the raw theme input, so when a user passes a custom TextMate theme object the whole theme JSON is serialized into cacheScope, and every key is ${cacheScope}\n${code}. V8 flattens the cons-string when the Map hashes it, so each cached entry carries its own full copy of the theme JSON in its key. A 30–50 KB theme times a few hundred blocks adds megabytes of key strings on top of the output itself.

Cheap fix: a JSON.stringify replacer that swaps theme objects for their name (resolveTheme already requires name or type to recognize one), falling back to the full object only when name is absent. Not blocking; bundled theme names keep the scope at a few hundred bytes.

});
});

describe('Cache', () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The four cases cover the core contract well. Three gaps worth closing, all cheap in this harness:

  • react output format. Every test uses html; the default path stores and re-renders a React element tree across mounts, which is the realistic case and the one most likely to surprise if it ever regresses.
  • Unmount while a newer highlight is in flight. The shown ref deliberately stores the output under the key that produced it, not the current key. A test that rerenders to new code, unmounts before the mock resolves, and asserts the cache holds only the old key would pin that design.
  • Component pass-through. One assertion that <ShikiHighlighter cache={cache}> reaches the hook via the ...options spread, so a future prop refactor can't silently drop it.

@claude

claude Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Review: cache option for reusing highlighted output across remounts

Verdict: approve with one recommended type change. The design is sound and the implementation is small and well reasoned. Storing on unmount under the key that produced the shown output (rather than the current key) is the right call, and pulling cache out before the deep-compared options means a growing Map can never re-trigger highlighting.

Checklist

  • Changeset: present. Patch for a feature matches the repo convention (named engines and preloadLanguages imports also shipped as patches in 0.11.1).
  • Docs: README option table, a new "Caching Across Remounts" section, and JSDoc on the option and type. The playground MDX does not list hook options, so nothing is needed there. One nit: the README table lists the type as Map while the export is HighlightCache; worth naming the exported type.
  • Architecture: clean. Hook order is unconditional, the cache-hit path still bumps requestId so in-flight results for the previous code are discarded, and the early return is safe because the prior effect cleanup already cleared any throttled timeout.
  • Tests: 4 focused hook tests covering remount, streaming partials, theme miss, and in-session reuse.

Findings (inline comments)

  1. HighlightCache rejects non-Map caches (types.ts). Pick<Map, "get" | "set"> inherits set(): Map<…>, so a custom LRU whose set returns this or void fails to type-check, contradicting "any { get, set } works". Suggested an explicit two-method interface with set(): unknown.
  2. Custom theme objects bloat every key (hook.ts). The full TextMate theme JSON lands in cacheScope, and each key carries its own flattened copy. Suggested keying object themes by name. Low priority; bundled theme names keep keys small.
  3. Test gaps (hook.test.tsx). No coverage of the default react output format, of unmounting while a newer highlight is in flight (the shown ref design), or of the component prop pass-through.

Verification
I could not run the suite, tsc, or biome in this environment (commands were not permitted), so the "129/129 pass" claim is unverified here and should be confirmed by CI.


🤖 Reviewed by Fable 5
🔁 Re-review: add the fable-review or opus-review label

@AVGVSTVS96

Copy link
Copy Markdown
Owner Author

@claude can u fix all review comments

@claude

claude Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Claude finished @AVGVSTVS96's task in 1m 56s —— View job


Review comments: fixed locally, but I couldn't push

  • Gather context
  • HighlightCache → explicit { get, set } interface (types.ts)
  • Key custom theme objects by name (hook.ts)
  • Add tests: react output, unmount during an in-flight highlight, component pass-through (plus custom-cache and theme-key tests)
  • README: name HighlightCache in the options table
  • Run tests, tsc, biome — not run: these commands weren't permitted in this job
  • Push — failed: remote: Permission to AVGVSTVS96/react-shiki.git denied to github-actions[bot] (HTTP 403)

The fixes are in local commit 5dfc0a9, which isn't on the branch. Because the push was denied, nothing on this PR has changed. To get the commit pushed, the workflow token needs contents: write; see the FAQ. After that, re-trigger me. Or you can apply the changes below yourself.

Changes

1. package/src/lib/types.ts: the type now accepts any { get, set }, so a custom LRU whose set returns void or this type-checks:

interface HighlightCache {
  get(key: string): HighlightResultMap[OutputFormat] | undefined;
  set(key: string, value: HighlightResultMap[OutputFormat]): unknown;
}

2. package/src/lib/hook.ts: custom theme objects are keyed by name. I exported isTextmateTheme from theme.ts and pass a replacer to the cacheScope JSON.stringify:

import { isTextmateTheme, resolveTheme } from './theme';

// Custom theme objects can be tens of KB; key them by name so every cache
// key doesn't carry a copy of the theme JSON.
const themeByName = (_key: string, value: unknown) =>
  isTextmateTheme(value) && value.name ? `theme:${value.name}` : value;
...
        ? JSON.stringify(
            [
              stableOpts.outputFormat ?? 'react',
              resolved.shikiOptions,
              stableOpts.startingLineNumber,
              stableOpts.highlightLineNumbers,
            ],
            themeByName
          )

A theme object without a name still goes into the key in full. The theme: prefix keeps a custom theme from sharing a key with a bundled theme of the same name. Two different custom themes with the same name would share entries, which matches how transformers are already told apart.

3. package/README.md: the options table now shows HighlightCache instead of Map.

4. Tests

  • hook.test.tsx, under Cache:
    • Default react output: a cached element tree repaints on remount without calling codeToHast again.
    • Unmount during a newer highlight: the test rerenders to SELECT 2 and unmounts before that result resolves. The cache holds only the SELECT 1 output, and a remount with SELECT 1 paints it right away.
    • Custom cache: a { get, set } whose set returns void works and type-checks.
    • Theme key: a custom theme object with a 5 KB token color produces a key containing theme:my-theme that is under 1000 chars.
  • component.test.tsx: <ShikiHighlighter cache={cache}> puts one entry in the cache on unmount, which pins that the prop reaches the hook.

Since I couldn't run anything, please check these with pnpm test and pnpm check in package/. I formatted by hand to the 74-column limit in biome.json.

This branch had an error being deployed

1 failed deployment
Preview — c4577ae2 Deployed Oct 10, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant