Skip to content

Commit 2a50aa1

Browse files
committed
perf(react): highlight each type once per thread
Of the ~20,000 types the Node.js docs highlight, 741 are different: `{string}` alone is on most pages. Each thread now keeps the markup of the types it highlighted, by highlighter, so a type it has seen is only embedded again, saving ~4% of the build's time and ~5% of its CPU. Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a06bb60 commit 2a50aa1

3 files changed

Lines changed: 103 additions & 28 deletions

File tree

‎packages/react/src/jsx-ast/index.mjs‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use strict';
22

33
import { getHighlighter } from '@doc-kit/core/plugins/shiki/highlighter.mjs';
4-
import { createTypeAnnotationHandler } from '@doc-kit/core/plugins/type-annotations/highlighter.mjs';
54

65
import { AST_NODE_TYPES } from './constants.mjs';
76
import { generate, processChunk } from './generate.mjs';
@@ -44,8 +43,8 @@ export default {
4443
// Types are highlighted, with their links embedded, as static
4544
// markup (see `./plugins/static-markup.mjs`)
4645
handlers: {
47-
typeAnnotation: embedHighlightedTypes(
48-
createTypeAnnotationHandler(() => getHighlighter('jsx-ast'))
46+
typeAnnotation: embedHighlightedTypes(() =>
47+
getHighlighter('jsx-ast')
4948
),
5049
},
5150
},

‎packages/react/src/jsx-ast/plugins/__tests__/static-markup.test.mjs‎

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
22
import { describe, it } from 'node:test';
33

44
import { createHighlighter } from '@doc-kit/core/plugins/shiki/highlighter.mjs';
5-
import { createTypeAnnotationHandler } from '@doc-kit/core/plugins/type-annotations/highlighter.mjs';
65
import { toString } from 'hast-util-to-string';
76
import rehypeRaw from 'rehype-raw';
87
import { unified } from 'unified';
@@ -13,9 +12,7 @@ import rehypeStaticMarkup, {
1312

1413
const highlighter = await createHighlighter();
1514

16-
const embedTypes = embedHighlightedTypes(
17-
createTypeAnnotationHandler(() => highlighter)
18-
);
15+
const embedTypes = embedHighlightedTypes(() => highlighter);
1916

2017
// A minimal mdast-util-to-hast state: the handlers only use patch/applyData
2118
const state = { patch: () => {}, applyData: (_, result) => result };
@@ -134,6 +131,53 @@ describe('embedHighlightedTypes', () => {
134131
assert.equal(textContent(html), 'Promise<string>');
135132
});
136133

134+
it('highlights the same type once for each highlighter', async () => {
135+
let calls = 0;
136+
137+
// The highlighter, counting what its Shiki instance highlights
138+
const counting = {
139+
resolveLanguage: highlighter.resolveLanguage,
140+
get shiki() {
141+
const { shiki } = highlighter;
142+
143+
return {
144+
...shiki,
145+
codeToHast: (...args) => {
146+
calls++;
147+
148+
return shiki.codeToHast(...args);
149+
},
150+
};
151+
},
152+
};
153+
154+
const themed = await createHighlighter({
155+
themes: { light: 'github-light', dark: 'github-dark' },
156+
});
157+
158+
let current = counting;
159+
160+
const embed = embedHighlightedTypes(() => current);
161+
162+
const promise = () =>
163+
makeNode('Promise<string>', {
164+
typescript: true,
165+
links: [{ start: 0, end: 7, text: 'Promise', href: 'mdn.io/promise' }],
166+
});
167+
168+
const first = embed(state, promise());
169+
const second = embed(state, promise());
170+
171+
assert.equal(calls, 1);
172+
// Each type is a node of its own, with the same markup
173+
assert.notEqual(second, first);
174+
assert.equal(innerHTML(second), innerHTML(first));
175+
176+
current = themed;
177+
178+
assert.notEqual(innerHTML(embed(state, promise())), innerHTML(first));
179+
});
180+
137181
it('leaves a type that was not highlighted as it is', () => {
138182
const element = embedTypes(state, makeNode('Whatever', { links: [] }));
139183

‎packages/react/src/jsx-ast/plugins/static-markup.mjs‎

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use strict';
22

3+
import { createTypeAnnotationHandler } from '@doc-kit/core/plugins/type-annotations/highlighter.mjs';
34
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
45
import { renderToString } from 'preact-render-to-string';
56
import { Fragment, jsx, jsxs } from 'preact/jsx-runtime';
@@ -51,29 +52,39 @@ const render = children =>
5152
);
5253

5354
/**
54-
* Turns the `<code>` of highlighted code into a JSX `<code>` holding its
55-
* markup as it is.
55+
* The attributes and the markup of the `<code>` of highlighted code.
5656
*
5757
* @param {import('hast').Element} code - The `<code>`
58-
* @param {boolean} [inline] - Whether it's a type's, rather than a block's
58+
* @returns {{ attributes: Record<string, unknown>, html: string }}
5959
*/
60-
const embedCode = (code, inline = false) => {
60+
const toMarkup = code => {
6161
const classNames = getClassNames(code);
6262

6363
// JSX takes the class names as one `className`, which comes first, as
6464
// `class` did
6565
const attributes = {
66-
inline,
6766
className: classNames.length > 0 ? classNames.join(' ') : undefined,
6867
...code.properties,
69-
dangerouslySetInnerHTML: { __html: render(code.children) },
7068
};
7169

7270
delete attributes.class;
7371

74-
return createJSXElement('code', attributes);
72+
return { attributes, html: render(code.children) };
7573
};
7674

75+
/**
76+
* A JSX `<code>` holding highlighted markup as it is.
77+
*
78+
* @param {ReturnType<typeof toMarkup>} markup
79+
* @param {boolean} [inline] - Whether it's a type's, rather than a block's
80+
*/
81+
const createCodeElement = ({ attributes, html }, inline = false) =>
82+
createJSXElement('code', {
83+
inline,
84+
...attributes,
85+
dangerouslySetInnerHTML: { __html: html },
86+
});
87+
7788
/**
7889
* Embeds the code blocks of a tree that Shiki highlighted.
7990
*
@@ -87,7 +98,7 @@ export const embedHighlightedBlocks = tree => {
8798

8899
if (node.tagName === 'pre' && code?.tagName === 'code') {
89100
if (isHighlighted(node)) {
90-
node.children[0] = embedCode(code);
101+
node.children[0] = createCodeElement(toMarkup(code));
91102
}
92103

93104
return SKIP;
@@ -98,25 +109,46 @@ export const embedHighlightedBlocks = tree => {
98109
};
99110

100111
/**
101-
* Wraps a `typeAnnotation` handler of `remark-rehype`, embedding the types it
102-
* highlights.
112+
* Creates the `typeAnnotation` handler of `remark-rehype` highlighting types
113+
* and embedding them.
114+
*
115+
* Every highlighted type is kept, by highlighter: the same few hundred types
116+
* are highlighted thousands of times, `{string}` alone on most pages.
103117
*
104-
* @param {(state: import('mdast-util-to-hast').State, node: import('mdast').Node) => import('hast').Element} handler
105-
* @returns {typeof handler}
118+
* @param {() => import('@doc-kit/core/plugins/shiki/highlighter.mjs').SyntaxHighlighter} getHighlighter - Gives the highlighter, once a type is highlighted
119+
* @returns {(state: import('mdast-util-to-hast').State, node: import('mdast').Node) => import('hast').ElementContent}
106120
*/
107-
export const embedHighlightedTypes = handler => (state, node) => {
108-
const result = handler(state, node);
121+
export const embedHighlightedTypes = getHighlighter => {
122+
const highlight = createTypeAnnotationHandler(getHighlighter);
123+
const highlighted = new WeakMap();
109124

110-
// A type that didn't parse, or links nowhere, isn't highlighted
111-
if (!isHighlighted(result)) {
112-
return result;
113-
}
125+
return (state, node) => {
126+
const highlighter = getHighlighter();
127+
128+
if (!highlighted.has(highlighter)) {
129+
highlighted.set(highlighter, new Map());
130+
}
131+
132+
const types = highlighted.get(highlighter);
133+
const key = JSON.stringify([node.value, node.data]);
134+
135+
if (!types.has(key)) {
136+
const result = highlight(state, node);
114137

115-
const code = embedCode(result, true);
138+
// A type that didn't parse, or links nowhere, isn't highlighted
139+
if (!isHighlighted(result)) {
140+
return result;
141+
}
142+
143+
types.set(key, toMarkup(result));
144+
}
116145

117-
state.patch(node, code);
146+
const code = createCodeElement(types.get(key), true);
118147

119-
return code;
148+
state.patch(node, code);
149+
150+
return code;
151+
};
120152
};
121153

122154
/**

0 commit comments

Comments
 (0)