Skip to content

Commit a4d7687

Browse files
authored
docs: Optimize RSC payload size (#9228)
* Optimize syntax highlighting payload * Optimize downloaded strings * lint
1 parent 3cb1bce commit a4d7687

File tree

9 files changed

+261
-124
lines changed

9 files changed

+261
-124
lines changed

packages/dev/s2-docs/src/Code.tsx

Lines changed: 136 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import {CodeClient} from './CodeClient';
12
import {CodeFold} from './CodeFold';
23
import {CodeLink} from './Link';
34
import {CodeProps} from './VisualExampleClient';
45
import {HastNode, HastTextNode, highlightHast, Language} from 'tree-sitter-highlight';
5-
import React, {ReactNode} from 'react';
6+
import React, {cache} from 'react';
67
import {style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'};
78
import {TabLink} from './FileTabs';
9+
import {Token, TokenType} from './CodeToken';
810

911
const property = style({color: 'indigo-1000'});
1012
const fn = style({color: 'red-1000'});
@@ -36,14 +38,18 @@ const mark = style({
3638
color: 'inherit'
3739
});
3840

39-
function Highlight({children}) {
40-
return <mark className={mark}>{children}</mark>;
41+
function Highlight({tokens}) {
42+
return <mark className={mark}><CodeClient tokens={tokens} /></mark>;
43+
}
44+
45+
function Focus({tokens}) {
46+
return <span><CodeClient tokens={tokens} /></span>;
4147
}
4248

4349
const groupings = {
4450
highlight: Highlight,
4551
collapse: CodeFold,
46-
focus: 'span'
52+
focus: Focus
4753
};
4854

4955
type Links = {[name: string]: string};
@@ -57,59 +63,11 @@ export interface ICodeProps {
5763

5864
export function Code({children, lang, hideImports = true, links, styles}: ICodeProps) {
5965
if (lang) {
60-
// @ts-ignore
61-
let highlighted = highlightHast(children, Language[lang === 'json' ? 'JS' : lang.toUpperCase()]);
62-
let lineNodes = lines(highlighted);
63-
let idx = lineNodes.findIndex(line => !/^(["']use client["']|(\s*$))/.test(text(line)));
64-
if (idx > 0) {
65-
lineNodes = lineNodes.slice(idx);
66-
}
67-
68-
if (hideImports) {
69-
// Group into hidden and visible nodes.
70-
// Hidden nodes will include all import statements. If a highlighted block is seen,
71-
// then we'll hide all the lines up until 2 lines before this.
72-
let hidden: HastNode[] = [];
73-
let visible: HastNode[] = [];
74-
let seenNonImportLine = false;
75-
let hasHighlight = false;
76-
for (let line of lineNodes) {
77-
if (!seenNonImportLine && /^(["']use client["']|@?import|(\s*$))/.test(text(line))) {
78-
hidden.push(line);
79-
} else {
80-
seenNonImportLine = true;
81-
visible.push(line);
82-
}
83-
84-
if ((line.tagName === 'highlight' || line.tagName === 'focus') && !hasHighlight) {
85-
hasHighlight = true;
86-
// Center highlighted lines within collapsed window (~8 lines).
87-
let highlightedLines = line.children.length;
88-
let contextLines = highlightedLines < 6
89-
? Math.floor((8 - highlightedLines) / 2)
90-
: 2;
91-
contextLines++;
92-
hidden.push(...visible.slice(0, -contextLines));
93-
visible = visible.slice(-contextLines);
94-
}
95-
}
96-
97-
if (hidden.length && visible.length) {
98-
lineNodes = [
99-
{
100-
type: 'element',
101-
tagName: 'span',
102-
children: hidden,
103-
properties: {
104-
className: 'import'
105-
}
106-
},
107-
...visible
108-
];
109-
}
110-
}
111-
112-
return <code className={styles} style={{fontFamily: 'inherit', WebkitTextSizeAdjust: 'none'}}>{renderChildren(lineNodes, '0', links)}</code>;
66+
return (
67+
<code className={styles} style={{fontFamily: 'inherit', WebkitTextSizeAdjust: 'none'}}>
68+
<CodeClient tokens={highlightCode(children, lang, hideImports, links)} />
69+
</code>
70+
);
11371
}
11472

11573
return (
@@ -129,6 +87,62 @@ export function Code({children, lang, hideImports = true, links, styles}: ICodeP
12987
);
13088
}
13189

90+
const highlightCode = cache((children: string, lang: string, hideImports = true, links?: Links): Token[] => {
91+
// @ts-ignore
92+
let highlighted = highlightHast(children, Language[lang === 'json' ? 'JS' : lang.toUpperCase()]);
93+
let lineNodes = lines(highlighted);
94+
let idx = lineNodes.findIndex(line => !/^(["']use client["']|(\s*$))/.test(text(line)));
95+
if (idx > 0) {
96+
lineNodes = lineNodes.slice(idx);
97+
}
98+
99+
if (hideImports) {
100+
// Group into hidden and visible nodes.
101+
// Hidden nodes will include all import statements. If a highlighted block is seen,
102+
// then we'll hide all the lines up until 2 lines before this.
103+
let hidden: HastNode[] = [];
104+
let visible: HastNode[] = [];
105+
let seenNonImportLine = false;
106+
let hasHighlight = false;
107+
for (let line of lineNodes) {
108+
if (!seenNonImportLine && /^(["']use client["']|@?import|(\s*$))/.test(text(line))) {
109+
hidden.push(line);
110+
} else {
111+
seenNonImportLine = true;
112+
visible.push(line);
113+
}
114+
115+
if ((line.tagName === 'highlight' || line.tagName === 'focus') && !hasHighlight) {
116+
hasHighlight = true;
117+
// Center highlighted lines within collapsed window (~8 lines).
118+
let highlightedLines = line.children.length;
119+
let contextLines = highlightedLines < 6
120+
? Math.floor((8 - highlightedLines) / 2)
121+
: 2;
122+
contextLines++;
123+
hidden.push(...visible.slice(0, -contextLines));
124+
visible = visible.slice(-contextLines);
125+
}
126+
}
127+
128+
if (hidden.length && visible.length) {
129+
lineNodes = [
130+
{
131+
type: 'element',
132+
tagName: 'span',
133+
children: hidden,
134+
properties: {
135+
className: 'import'
136+
}
137+
},
138+
...visible
139+
];
140+
}
141+
}
142+
143+
return renderChildren(lineNodes, '0', links);
144+
});
145+
132146
function lines(node: HastNode) {
133147
let resultLines: HastNode[] = [];
134148
let currentLine: (HastNode | HastTextNode)[] = [];
@@ -210,47 +224,57 @@ function lines(node: HastNode) {
210224
return resultLines;
211225
}
212226

213-
function renderHast(node: HastNode | HastTextNode, key: string, links?: Links, indent = ''): ReactNode {
227+
// Renders a Hast Node to a list of tokens. A token is either a string, a React element, or a token type (number) + string.
228+
// These are flattened into an array that gets sent to the client. This format significantly reduces the payload size vs JSX.
229+
function renderHast(node: HastNode | HastTextNode, key: string, links?: Links, indent = ''): Token | Token[] {
214230
if (node.type === 'element' && 'children' in node) {
215-
let childArray: ReactNode[] = renderChildren(node.children, key, links);
231+
let childArray: Token[] = renderChildren(node.children, key, links);
216232
if (node.tagName === 'div') {
217-
if (typeof childArray.at(-1) === 'string') {
233+
if (typeof childArray.at(-1) === 'string' && typeof childArray.at(-2) !== 'number') {
218234
childArray[childArray.length - 1] += '\n';
219235
} else {
220236
childArray.push('\n');
221237
}
222238
}
223239

224-
let children = childArray.length === 1 ? childArray[0] : childArray;
225-
let className = node.properties?.className.split(' ').map(c => styles[c]).filter(Boolean).join(' ') || undefined;
240+
let tokenType = node.properties?.className.split(' ').map(c => TokenType[c]).filter(v => v != null) || [];
226241
if (node.properties?.className === 'comment' && text(node) === '/* PROPS */') {
227242
return <CodeProps key={key} indent={indent} />;
228243
}
229244

230245
// CodeProps includes the indent and newlines in case there are no props to show.
231246
if (node.tagName === 'div' && typeof childArray[0] === 'string' && /^\s+$/.test(childArray[0]) && React.isValidElement(childArray[1]) && childArray[1].type === CodeProps) {
232-
children = childArray.slice(1);
247+
childArray = childArray.slice(1);
233248
}
234249

250+
let children = childArray.length === 1 ? childArray[0] : childArray;
235251
let tagName: any = node.tagName;
236252
let properties: any = node.properties;
237253
if (links && typeof children === 'string' && links[children]) {
238254
let link = links[children];
239-
tagName = CodeLink;
240-
properties = {...properties, href: link};
255+
return (
256+
<CodeLink
257+
key={key}
258+
className={styles[properties?.className]}
259+
href={link}>
260+
{children}
261+
</CodeLink>
262+
);
241263
}
242264

243265
// Link to imported files.
244266
if (properties?.className === 'string' && typeof children === 'string' && /^['"]\.\//.test(children)) {
245-
tagName = TabLink;
246-
properties = {...properties, name: children.slice(3, -1)};
267+
return (
268+
<TabLink
269+
key={key}
270+
className={styles.string}
271+
name={children.slice(3, -1)}>
272+
<CodeClient tokens={childArray} />
273+
</TabLink>
274+
);
247275
}
248276

249-
if (tagName === 'span' && !className) {
250-
return children;
251-
}
252-
253-
if (tagName === 'div' && !className) {
277+
if ((tagName === 'div' || tagName === 'span') && tokenType.length === 0) {
254278
return children;
255279
}
256280

@@ -260,24 +284,56 @@ function renderHast(node: HastNode | HastTextNode, key: string, links?: Links, i
260284
type = 'span';
261285
}
262286

263-
return React.createElement(type, {...properties, className, key}, children);
287+
if (type === 'span') {
288+
return [tokenType[0], children];
289+
}
290+
291+
let className = node.properties?.className.split(' ').map(c => styles[c]).filter(Boolean).join(' ') || undefined;
292+
return React.createElement(type, {...properties, className, key, tokens: childArray});
264293
} else {
265294
// @ts-ignore
266295
return node.value;
267296
}
268297
}
269298

270-
function renderChildren(children: (HastNode | HastTextNode)[], key: string, links?: Links) {
271-
let childArray: ReactNode[] = [];
299+
function renderChildren(children: (HastNode | HastTextNode)[], key: string, links?: Links): Token[] {
300+
let childArray: Token[] = [];
301+
let type = -1;
302+
let stringIndex = -1;
272303
for (let [i, child] of children.entries()) {
273-
let indent = i === 1 && typeof childArray[0] === 'string' && /^\s+$/.test(childArray[0]) ? childArray[0] : '';
304+
let indent = i === 1 && stringIndex >= 0 && /^\s+$/.test(childArray[stringIndex] as string) ? childArray[stringIndex] as string : '';
274305
let childNode = renderHast(child, `${key}.${i}`, links, indent);
275306
let childNodes = Array.isArray(childNode) ? childNode : [childNode];
276-
for (let childNode of childNodes) {
277-
if (typeof childNode === 'string' && typeof childArray.at(-1) === 'string') {
278-
childArray[childArray.length - 1] += childNode;
307+
let childIndex = 0;
308+
while (childIndex < childNodes.length) {
309+
let child = childNodes[childIndex++];
310+
let childType = -1;
311+
if (typeof child === 'number') {
312+
// A number represents a token type. Consume the next value.
313+
childType = child;
314+
child = childNodes[childIndex++];
315+
if (childType !== type) {
316+
childArray.push(childType);
317+
}
318+
}
319+
320+
// If this is a string, either append to the previous string if it is
321+
// the same token type, or push a new string.
322+
if (typeof child === 'string') {
323+
if (childType !== type) {
324+
type = childType;
325+
stringIndex = childArray.length;
326+
childArray.push(child);
327+
} else if (stringIndex >= 0) {
328+
childArray[stringIndex] += child;
329+
} else {
330+
stringIndex = childArray.length;
331+
childArray.push(child);
332+
}
279333
} else {
280-
childArray.push(childNode);
334+
type = -1;
335+
stringIndex = -1;
336+
childArray.push(child);
281337
}
282338
}
283339
}

packages/dev/s2-docs/src/CodeBlock.tsx

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ function TruncatedCode({children, maxLines = 6, ...props}: TruncatedCodeProps) {
164164
interface FilesProps {
165165
children?: ReactNode,
166166
files: string[],
167-
downloadFiles?: {[name: string]: string},
167+
downloadFiles?: DownloadFiles['files'],
168168
type?: 'vanilla' | 'tailwind' | 's2',
169169
defaultSelected?: string,
170170
maxLines?: number
@@ -182,7 +182,7 @@ export function Files({children, files, downloadFiles, type, defaultSelected, ma
182182
if (!files[name]) {
183183
extraFiles[name] = (
184184
<CodePlatter type={type}>
185-
<TruncatedCode lang={path.extname(name).slice(1)} hideImports={false} maxLines={maxLines}>{downloadFiles[name]}</TruncatedCode>
185+
<TruncatedCode lang={path.extname(name).slice(1)} hideImports={false} maxLines={maxLines}>{downloadFiles[name].contents}</TruncatedCode>
186186
</CodePlatter>
187187
);
188188
}
@@ -213,21 +213,25 @@ export function File({filename, maxLines, type}: {filename: string, maxLines?: n
213213
);
214214
}
215215

216+
const readFileReplace = cache((file: string) => {
217+
let contents = readFile(file)
218+
.replace(/(vanilla-starter|tailwind-starter)\//g, './')
219+
.replace(/import (.*?) from ['"]url:(.*?)['"]/g, (_, name, specifier) => {
220+
return `const ${name} = '${resolveUrl(specifier, file)}'`;
221+
});
222+
return {contents};
223+
});
224+
216225
// Reads files, parses imports, and loads recursively.
217-
export function getFiles(files: string[], type: string | undefined, npmDeps = {}) {
218-
let fileContents = {};
226+
export function getFiles(files: string[], type: string | undefined, npmDeps = {}): DownloadFiles {
227+
let fileContents: DownloadFiles['files'] = {};
219228
for (let file of findAllFiles(files, npmDeps)) {
220229
let name = path.basename(file);
221-
let contents = readFile(file);
222-
fileContents[name] = contents
223-
.replace(/(vanilla-starter|tailwind-starter)\//g, './')
224-
.replace(/import (.*?) from ['"]url:(.*?)['"]/g, (_, name, specifier) => {
225-
return `const ${name} = '${resolveUrl(specifier, file)}'`;
226-
});
230+
fileContents[name] = readFileReplace(file);
227231
}
228232

229233
if (type === 'tailwind' && !fileContents['index.css']) {
230-
fileContents['index.css'] = readFile(path.resolve('../../../starters/tailwind/src/index.css'));
234+
fileContents['index.css'] = readFileReplace(path.resolve('../../../starters/tailwind/src/index.css'));
231235
}
232236

233237
return {files: fileContents, deps: npmDeps};
@@ -286,7 +290,19 @@ function parseFile(file: string, contents: string, npmDeps = {}, urls = {}) {
286290
return deps;
287291
}
288292

289-
function getExampleFiles(file: string, contents: string, type: string | undefined) {
293+
export interface DownloadFiles {
294+
files: {
295+
[name: string]: {contents: string}
296+
},
297+
deps: {
298+
[name: string]: string
299+
},
300+
urls?: {
301+
[url: string]: string
302+
}
303+
}
304+
305+
function getExampleFiles(file: string, contents: string, type: string | undefined): DownloadFiles {
290306
let npmDeps = {};
291307
let urls = {};
292308
let fileDeps = parseFile(file, contents, npmDeps, urls);

0 commit comments

Comments
 (0)