Skip to content

Commit eece220

Browse files
optimize dev edit-refresh loop by caching the syntax highlighting (#1229)
Result is 11.3s edit-refresh time (from 16.7s edit-refresh time) on my M2 silicon mac. Or, 34.4s edit-refresh time (from 57.3s ) on intel mac. Co-authored-by: Augustine Kim <[email protected]>
1 parent 671bc83 commit eece220

File tree

4 files changed

+104
-3
lines changed

4 files changed

+104
-3
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ test-results
3030
*.tsbuildinfo
3131

3232
.wireit
33+
.highlights_cache

packages/lit-dev-content/.eleventy.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ module.exports = function (eleventyConfig) {
5656
eleventyConfig.addPlugin(eleventyNavigationPlugin);
5757
eleventyConfig.addPlugin(playgroundPlugin, {
5858
sandboxUrl: ENV.playgroundSandboxUrl,
59+
isDevMode: DEV,
5960
});
6061
if (!DEV) {
6162
// In dev mode, we symlink these directly to source.

packages/lit-dev-tools-cjs/src/playground-plugin/blocking-renderer.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66

77
import * as workerthreads from 'worker_threads';
88
import * as pathlib from 'path';
9+
import * as fs from 'fs';
10+
11+
const cachedHighlightsDir = pathlib.resolve(
12+
__dirname,
13+
'../../.highlights_cache/'
14+
);
915

1016
export type WorkerMessage = HandshakeMessage | Render | Shutdown;
1117

@@ -32,6 +38,38 @@ export interface Shutdown {
3238
type: 'shutdown';
3339
}
3440

41+
// Create a cache key for the highlighted strings. This is a
42+
// simple digest build from a DJB2-ish hash modified from:
43+
// https://github.com/darkskyapp/string-hash/blob/master/index.js
44+
// This is modified from @lit-labs/ssr-client.
45+
// Goals:
46+
// - Extremely low collision rate. We may not be able to detect collisions.
47+
// - Extremely fast.
48+
// - Extremely small code size.
49+
// - Safe to include in HTML comment text or attribute value.
50+
// - Easily specifiable and implementable in multiple languages.
51+
// We don't care about cryptographic suitability.
52+
const digestToFileName = (stringToDigest: string) => {
53+
// Number of 32 bit elements to use to create template digests
54+
const digestSize = 5;
55+
const hashes = new Uint32Array(digestSize).fill(5381);
56+
for (let i = 0; i < stringToDigest.length; i++) {
57+
hashes[i % digestSize] =
58+
(hashes[i % digestSize] * 33) ^ stringToDigest.charCodeAt(i);
59+
}
60+
const str = String.fromCharCode(...new Uint8Array(hashes.buffer));
61+
return (
62+
Buffer.from(str, 'binary')
63+
.toString('base64')
64+
// These characters do not play well in file names. Replace with
65+
// underscores.
66+
.replace(/[<>:"'/\\|?*]/g, '_')
67+
);
68+
};
69+
70+
const createUniqueFileNameKey = (lang: string, code: string) =>
71+
digestToFileName(`[${lang}]:${code}`);
72+
3573
export class BlockingRenderer {
3674
/** Worker that performs rendering. */
3775
private worker: workerthreads.Worker;
@@ -45,7 +83,20 @@ export class BlockingRenderer {
4583
private exited = false;
4684
private renderTimeout: number;
4785

48-
constructor({renderTimeout = 60_000, maxHtmlBytes = 1024 * 1024} = {}) {
86+
/**
87+
* Spawning a headless browser to syntax highlight code is expensive and slows
88+
* down the edit/refresh loop during development. When developing, cache the
89+
* syntax highlighted DOM in the filesystem so it can be retrieved if
90+
* previously seen.
91+
*/
92+
private isDevMode = false;
93+
94+
constructor({
95+
renderTimeout = 60_000,
96+
maxHtmlBytes = 1024 * 1024,
97+
isDevMode = false,
98+
} = {}) {
99+
this.isDevMode = isDevMode;
49100
this.renderTimeout = renderTimeout;
50101
this.sharedHtml = new Uint8Array(new SharedArrayBuffer(maxHtmlBytes));
51102
this.worker = new workerthreads.Worker(
@@ -70,6 +121,15 @@ export class BlockingRenderer {
70121
htmlBuffer: this.sharedHtml,
71122
notify: this.sharedNotify,
72123
});
124+
try {
125+
fs.mkdirSync(cachedHighlightsDir);
126+
} catch (error) {
127+
if ((error as {code: string}).code === 'EEXIST') {
128+
// Directory already exists.
129+
} else {
130+
throw error;
131+
}
132+
}
73133
}
74134

75135
async stop(): Promise<void> {
@@ -82,7 +142,46 @@ export class BlockingRenderer {
82142
});
83143
}
84144

145+
private getCachedRender(cachedFileName: string): string | null {
146+
const absoluteFilePath = pathlib.resolve(
147+
cachedHighlightsDir,
148+
cachedFileName
149+
);
150+
if (fs.existsSync(absoluteFilePath)) {
151+
return fs.readFileSync(absoluteFilePath, {encoding: 'utf8'});
152+
}
153+
return null;
154+
}
155+
156+
private writeCachedRender(cachedFileName: string, html: string) {
157+
const absoluteFilePath = pathlib.resolve(
158+
cachedHighlightsDir,
159+
cachedFileName
160+
);
161+
fs.writeFileSync(absoluteFilePath, html);
162+
}
163+
85164
render(lang: 'js' | 'ts' | 'html' | 'css', code: string): {html: string} {
165+
if (!this.isDevMode) {
166+
// In production, skip all caching.
167+
return this.renderWithWorker(lang, code);
168+
}
169+
// In dev mode, speed up the edit-refresh loop by caching the syntax
170+
// highlighted code.
171+
const cachedFileName = createUniqueFileNameKey(lang, code);
172+
const cachedResult = this.getCachedRender(cachedFileName);
173+
if (cachedResult !== null) {
174+
return {html: cachedResult};
175+
}
176+
const {html} = this.renderWithWorker(lang, code);
177+
this.writeCachedRender(cachedFileName, html);
178+
return {html};
179+
}
180+
181+
private renderWithWorker(
182+
lang: 'js' | 'ts' | 'html' | 'css',
183+
code: string
184+
): {html: string} {
86185
if (this.exited) {
87186
throw new Error('BlockingRenderer worker has already exited');
88187
}

packages/lit-dev-tools-cjs/src/playground-plugin/plugin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,12 @@ const countVisibleLines = (filename: string, code: string): number => {
8989
*/
9090
export const playgroundPlugin = (
9191
eleventyConfig: EleventyConfig,
92-
{sandboxUrl}: {sandboxUrl: string}
92+
{sandboxUrl, isDevMode}: {sandboxUrl: string; isDevMode: boolean}
9393
) => {
9494
let renderer: BlockingRenderer | undefined;
9595

9696
eleventyConfig.on('eleventy.before', () => {
97-
renderer = new BlockingRenderer();
97+
renderer = new BlockingRenderer({isDevMode});
9898
});
9999

100100
eleventyConfig.on('eleventy.after', async () => {

0 commit comments

Comments
 (0)