Skip to content

feat: add opt-in twoslash build cache - #118

Open
IMax153 wants to merge 2 commits into
withstudiocms:mainfrom
IMax153:feat/twoslash-disk-cache
Open

feat: add opt-in twoslash build cache#118
IMax153 wants to merge 2 commits into
withstudiocms:mainfrom
IMax153:feat/twoslash-disk-cache

Conversation

@IMax153

@IMax153 IMax153 commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

Description

  • Closes #
  • What does this PR change?
  • Be short and concise. Bullet points can help!
  • Before/after screenshots can help as well.
  • Don't forget a changeset! Run pnpm changeset.

Docs

@IMax153
IMax153 requested review from a team and Adammatthiesen as code owners March 30, 2026 18:26
@changeset-bot

changeset-bot Bot commented Mar 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a6f2e1f

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

This PR includes changesets to release 1 package
Name Type
expressive-code-twoslash 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

@IMax153 IMax153 changed the title feat: add opt-in twoslash cache feat: add opt-in twoslash build cache Mar 30, 2026
@IMax153

IMax153 commented Mar 30, 2026

Copy link
Copy Markdown
Contributor Author

👋 Hey @Adammatthiesen! We're currently working on the next iteration of the Effect Website, and I'm trying to optimize build times as much as possible.

As you know, we make heavy use of your expressive-code-twoslash plugin for rendering our code snippets, and I'm sure you know that the more Twoslash snippets you have, the slower the build times.

I've been experimenting on our new site with a build-time cache for Twoslash snippets. I locally patched the expressive-code-twoslash plugin to include support for caching Twoslash snippets based on deterministic keys.

For an Astro build with my cache PoC including ~40 Twoslash snippets, the cold build time was ~25s and warm build time was ~15s. Obviously this scales with more and more snippets that are cached vs. uncached, and the actual Effect website has many more snippets than this.

Given the positive results, I figured I'd make a PR to expressive-code-twoslash to see if you're interested in upstreaming this functionality.

Please note that the PR is fully AI generated - I'm happy to clean it up if you're interested in upstreaming it, and also happy to make whatever changes to the public API you want too!

@Adammatthiesen

Copy link
Copy Markdown
Member

@IMax153 Hey Max! I'm back! gonna be looking into this here soon!

@IMax153

IMax153 commented Jun 28, 2026

Copy link
Copy Markdown
Contributor Author

@IMax153 Hey Max! I'm back! gonna be looking into this here soon!

Take your time! There's no rush :)

@Adammatthiesen Adammatthiesen 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.

Looks pretty solid to me, Let me get a second pair of eyes on it but i give my approval

@dreyfus92 dreyfus92 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.

oh wow this is such an amazing work @IMax153. but found 2 things i think it would be worth to address (or at least document) before this lands:

  • functions and regexes are invisible to the cache key, normalizeJson maps funcs to null and regexes fall into the generic object branch with no enumerable keys, so they can become {}. verified locally that 2 keys built with different shouldGetHoverInfo callbacks and different regexes collide. means changing a callback in twoSlashOptions siliently serves stale cached output. fingerprint covers it as an escape hatch but the docs should call this out explicitly, and regexes could at least normalize via String(value)
  • hoisting the tsconfig parse is a behavioir change. on main, parseSnippetTsconfig only runs inside if (!tsLibDirectory). now it runs unconditionally and throws if there's no config at cwd so anywone passing tsLibDirectory explicitly without a tsconfig goes from working build to hard crash, even with the cache disabled.

Comment on lines +23 to +25
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
return null;
}

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 is a footgun, but worth a doc note that function-valued options don't participate in invalidation and fingerprint is the workaround.

Comment on lines +48 to +55
if (typeof value === "object") {
return Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.reduce<Record<string, JsonValue>>((record, [key, entryValue]) => {
record[key] = normalizeJson(entryValue);
return record;
}, {});
}

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.

regexes land here and normalize {} since they have no enum keys. an instanceofRegExp check returning String(value) before this branch would make trigger regex changes actually bust the cache.

Comment on lines +120 to +122
const resolvedTsConfigPath = resolveTsconfigPath(cwd, tsConfigPath);
const { source: tsConfigSource, options: baseCompilerOptions } =
parseSnippetTsconfig(resolvedTsConfigPath);

@dreyfus92 dreyfus92 Jul 8, 2026

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 is what i was talking about that on main this only ran when !tsLibDirectory, so this now might crash builds for anyone passing tsLibDirectory without a tsconfig on disk, cache enabled or not. we could gate the eager parse behind !tsLibDirectory || cache or make it tolerant when the lib dir is already known.

Comment on lines +204 to +207
pluginContext: {
resolvedTsConfigPath,
tsConfigSource,
},

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.

putting the absolute path in the key kills cache portability, restore the cache dir in CI at a different checkout path and everything misses. it can also be redundant, the resolved tsLibDirectory and the actual compilerOptions passed to twoslash are already in executeOptions. i'd either make it relative to cwd or drop it and keep just tsConfigSource, this is non-blocking, it could be as follow-up.

Comment on lines +30 to +40
process.once("exit", () => {
const total = stats.hits + stats.misses;
if (total === 0 || options.logLevel === "off") {
return;
}

const hitRate = ((stats.hits / total) * 100).toFixed(1);
console.info(
`[twoslash-cache] hits=${stats.hits} misses=${stats.misses} writes=${stats.writes} readErrors=${stats.readErrors} writeErrors=${stats.writeErrors} hitRate=${hitRate}% dir=${options.dir}`,
);
});

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.

both dedup guards here are per-instance, didRegisterSummary lives in this closure and process.once only dedupes the same listener, but every createTwoslashCacheStats() call registers a new one. so multiple plugin instances (second EC engine, dev-server reloads) = stacked exit listeners, and past 10 node prints MaxListenersExceededWarning, which looks like a leak.

a fix would be setting a module-level registry, one shared exit listener that walks all registered stats:

const registry: RegisteredStats[] = [];
let didRegisterExitListener = false;

function registerForSummary(entry: RegisteredStats) {
	registry.push(entry);
	if (didRegisterExitListener) return;
	didRegisterExitListener = true;

	process.once("exit", () => {
		for (const { stats, options } of registry) {
			// same summary log as now, per entry
		}
	});
}

Important

just hoisting the boolean isn't enough. first instance's closure only sees its own stats, everyone else's counters silently vanish. the registry keeps each instance's line (with its dir=) intact. not a blocker

P.D. this isn't a blocker, but ideally if you guys don't want noise in the logs, this would do it.

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