Skip to content

perf(rehype-shiki): cache tokens for all themes - #9253

Open
ovflowd wants to merge 2 commits into
mainfrom
perf/rehype-shiki-single-pass
Open

ovflowd wants to merge 2 commits into
mainfrom
perf/rehype-shiki-single-pass

Conversation

@ovflowd

@ovflowd ovflowd commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Description

This PR makes @node-core/rehype-shiki cache each line's tokens for all the themes code is highlighted in, instead of tokenizing it again for each.

Shiki highlights in several themes by running codeToTokensBase once per theme, and each run goes through the grammar over every line again (grammar.tokenizeLine2). Matching a grammar doesn't depend on the theme, only the styles the matched scopes get do, so the default light and dark highlighting (and doc-kit's) tokenizes everything twice.

  • The new tokenization-cache module exports withTokenizationCache(shiki). With two or more themes given by name, it hands @shikijs/core's codeToHast, codeToHtml, codeToTokens and codeToTokensWithThemes a primitive whose grammar keeps two caches: each line's tokens for the length of one highlight, by the state the line starts in, and each scope path's metadata per grammar and theme, across highlights. A theme's metadata is computed from the scopes found the way vscode-textmate does: its mergeAttributes over the scope path, the bracket bit, and merging equal neighbours. Everything after that is Shiki's own code. Theme objects, a single theme, includeExplanation and grammars with token types of their own take Shiki's usual path. createHighlighter wraps its highlighter with it.
  • The default themes are now given by name, in a DEFAULT_THEME_OPTIONS constant. As objects, Shiki normalizes them and parses a new vscode-textmate theme on every highlight, and their tokens can't be cached for both. Given by name, a theme's own color replacements no longer apply to the code block's foreground and background, but the defaults' only replacement (#616e88) is neither.

The grammar state of a cached highlight only continues highlights made the same way, which the JSDoc says. Nothing here reads grammar state.

Validation

  • Every fenced code block in Node.js's doc/api (3,357 of them) highlights byte-identically to Shiki's own output, on Oniguruma and on the JavaScript regex engine. Highlighting them all takes 1,873ms instead of 2,755ms on Oniguruma (-32%), and 3,745ms instead of 6,541ms on the JavaScript engine (-43%).
  • The new tokenization-cache.test.mjs compares codeToHtml, codeToHast, codeToTokens and codeToTokensWithThemes with Shiki's for JavaScript, TypeScript, Markdown (with embedded code), HTML, C, shell and JSON on both engines. It also checks that each line is tokenized once for three themes, that theme objects, a single theme and an unsupported grammar take the usual path, and that a line stopped at the time limit runs preprocess hooks once.
  • The package's tests (17), tsc and ESLint pass and Prettier is clean, on each commit too. A minor changeset releases it.

Related Issues

Refs: nodejs/doc-kit#1205, which highlights with this package and gets the speedup once it's released.

Check List

  • I have read the Contributing Guidelines and made commit messages that follow the guideline.
  • I have run pnpm format to ensure the code follows the style guide.
  • I have run pnpm test to check if all tests are passing.
  • I have run pnpm build to check if the website builds without errors.
  • I've covered new added functionality with unit tests if necessary.

@vercel

vercel Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
nodejs-org Ready Ready Preview Oct 31, 2026 1:37pm UTC

Request Review

@codecov

codecov Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.39357% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.52%. Comparing base (53d777b) to head (61e78d9).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
packages/rehype-shiki/src/tokenization-cache.mjs 98.30% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9253      +/-   ##
==========================================
+ Coverage   86.14%   86.52%   +0.37%     
==========================================
  Files          87       88       +1     
  Lines        6164     6411     +247     
  Branches      369      385      +16     
==========================================
+ Hits         5310     5547     +237     
- Misses        850      860      +10     
  Partials        4        4              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build Size Comparison

Summary

Metric Value
Old Total First Load JS 7.21 MB
New Total First Load JS 7.21 MB
Delta 6.32 KB (+0.09%)

Changes

🔄 Modified Routes (4)
Route Old First Load JS New First Load JS Delta
/[locale] 1.69 MB 1.69 MB 📈 1.58 KB (+0.09%)
/[locale]/[...path] 1.69 MB 1.69 MB 📈 1.58 KB (+0.09%)
/[locale]/blog/[...path] 1.69 MB 1.69 MB 📈 1.58 KB (+0.09%)
/[locale]/download/archive/[version] 1.69 MB 1.69 MB 📈 1.58 KB (+0.09%)

@ovflowd
ovflowd force-pushed the perf/rehype-shiki-single-pass branch from 43e0fc2 to 991e49c Compare October 31, 2026 13:25
@ovflowd ovflowd changed the title perf(rehype-shiki): tokenize code once for all themes perf(rehype-shiki): cache tokens for all themes Oct 31, 2026
@ovflowd
ovflowd marked this pull request as ready for review October 31, 2026 13:29
@ovflowd
ovflowd requested a review from a team as a code owner October 31, 2026 13:29
Copilot AI balanced review requested due to automatic review settings October 31, 2026 13:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The cached ruleStack continuation path lacks a split-snippet test comparing each theme’s tokens with uncached Shiki, leaving carried-over theme metadata unverified.

1 open issue
What changed in this PR

This PR reduces repeated grammar tokenization in @node-core/rehype-shiki by sharing cached tokens across named themes while preserving Shiki’s rendering pipeline.

Changes:

  • Adds withTokenizationCache for multi-theme highlighting, with fallbacks for unsupported options and grammars.
  • Enables caching in the highlighter and selects default themes by name.
  • Adds output-parity and cache-behavior tests, plus a minor-release changeset.
File Description
packages/​rehype-shiki/​src/​tokenization-cache.mjs Implements shared tokenization and per-theme metadata caching.
packages/​rehype-shiki/​src/​highlighter.mjs Integrates caching and uses named default themes.
packages/​rehype-shiki/​src/​__tests__/​tokenization-cache.test.mjs Tests output parity, cache reuse, and fallback behavior across regex engines.
packages/​rehype-shiki/​src/​__tests__/​highlighter.test.mjs Updates mocks and assertions for named default themes.
.changeset/​rehype-shiki-tokenization-cache.md Documents the new cache wrapper for a minor release.

Review effort: Balanced

}
}

return { tokens: Uint32Array.from(binary), ruleStack, stoppedEarly };
Shiki tokenizes code once for each theme it's highlighted in: every
theme's pass runs the grammar over every line again, though matching a
grammar doesn't depend on the theme, only the styles the matched scopes
get do. Highlighting in a light and a dark theme, as doc-kit and the
site do, tokenizes everything twice.

`withTokenizationCache`, in the new `tokenization-cache` module, wraps
a highlighter so that with two or more themes given by name each line's
tokens are cached for all of them: the line is tokenized once, and each
theme's token metadata is computed from the scopes found, as
vscode-textmate computes it, and cached by scope path. Everything after
that is Shiki's own code, so the output doesn't change: it's
byte-identical on the 3,357 code blocks of Node.js's API docs with both
regex engines, and in full doc-kit builds. Highlighting them takes 32%
less time on Oniguruma and 43% less on the JavaScript engine.

Theme objects, a single theme and `includeExplanation` take Shiki's
usual path, as does a grammar with token types of its own.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
The default themes were passed as theme objects, which Shiki
normalizes, and parses into a new vscode-textmate theme, on every
highlight, and whose tokens can't be cached for both. They're
registered with the highlighter anyway, so they're now given by name,
in a `DEFAULT_THEME_OPTIONS` constant.

The output is the same. Given by name, a theme's own color replacements
no longer apply to the code block's foreground and background, but the
default themes' only replacement (`#616e88`) is neither.

Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>

@avivkeller avivkeller left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't really understand this, but I reviewed what I can reasonably understand

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we rename this tokenizationCache.mjs? Or just cache.mjs?

Comment on lines +9 to +24
/**
* A vscode-textmate grammar, with the internals its tokens' styles are
* computed from, which the interface Shiki types it with leaves out.
*
* @typedef {Object} Grammar
* @property {string} name
* @property {(line: string, stack: import('shiki/textmate').StateStack | null, timeLimit?: number) => import('shiki/textmate').ITokenizeLineResult} tokenizeLine
* @property {(scopeName: string) => { languageId: number, tokenType: number }} getMetadataForScope
* @property {{ getDefaults: () => Style, themeMatch: (scopePath: ScopePath) => Style | null }} themeProvider
* @property {{ matchesAlways: boolean, matchesNever: boolean, match: (scopes: Array<string>) => boolean } | null} balancedBracketSelectors
* @property {{ getDefaultAttributes: () => { languageId: number, tokenType: number } }} _basicScopeAttributesProvider
* @property {Array<unknown>} [_tokenTypeMatchers]
*
* @typedef {{ fontStyle: number, foregroundId: number, backgroundId: number }} Style
* @typedef {{ scopeName: string, parent: ScopePath | null }} ScopePath
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we need to explicitly type this? Doesn't Shiki provide the type? If not, can we use unknown?

Comment on lines +30 to +48
/**
* Gets a map's value for a key, setting it first if it has none.
*
* @template K, V
* @param {Map<K, V> | (K extends object ? WeakMap<K, V> : never)} map
* @param {K} key
* @param {() => V} create
* @returns {V}
*/
const getOrSet = (map, key, create) => {
let value = map.get(key);

if (value === undefined) {
value = create();
map.set(key, value);
}

return value;
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This branch was successfully deployed

1 active deployment
Preview — 61e78d96 Deployed Oct 31, 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.

3 participants