plans/024-incremental-hmr-parse-cache.md
Executor instructions: This is a performance change with real correctness risk (cache invalidation). Measure first, change second, and keep a fast escape hatch. Run the full suite after each step. Honor STOP conditions. When done, update the status row in
plans/README.md.Drift check (run first):
git diff --stat c63cb120..HEAD -- packages/parser/src/fs.ts packages/parser/src/core.ts packages/slidev/node/vite/loaders.tsOn a mismatch with the excerpts below, treat it as a STOP condition.
c63cb120, 2026-07-10On every hot update, the loader reloads the entire deck: parser.load builds a
fresh markdownFiles map, re-reads and re-parses every src:-imported file
from disk (even unchanged ones), and re-runs feature detection over the whole
concatenated deck. So a one-character edit costs O(total deck bytes + number of
imported files) each debounced save — growing with deck size and import count.
An incremental cache (re-parse only changed files; re-detect features per file)
makes edit cost scale with the edit, not the deck.
packages/parser/src/fs.ts:40-159 (load):
const markdownFiles: Record<string, SlidevMarkdown> = {} // fresh each call
// loadMarkdown re-reads + parses any file not already in THIS call's map:
async function loadMarkdown(path, ...) {
let md = markdownFiles[path]
if (!md) { const raw = await loadSource(path); md = await parse(raw, path, extensions); markdownFiles[path] = md; ... }
// ...
}
// ...
return {
slides,
entry,
headmatter,
features: detectFeatures(slides.map(s => s.source.raw).join('')), // whole-deck scan every call
markdownFiles,
watchFiles,
}
Driver: packages/slidev/node/vite/loaders.ts:153-155 calls
serverOptions.loadData({ [ctx.file]: await ctx.read() }) on any watched change;
cli.ts:158-160 forwards to parser.load. detectFeatures and
scanMonacoReferencedMods (core.ts) run over the joined deck.
| Purpose | Command | Expected |
|---|---|---|
| Install | pnpm install | exit 0 |
| Build | pnpm build | exit 0 |
| Test | pnpm test -- parser | all pass (incl. new cache tests) |
| Typecheck | pnpm typecheck | exit 0 |
In scope:
packages/parser/src/fs.ts (per-file parse cache keyed by content)packages/parser/src/core.ts (per-file feature detection, if features are merged)packages/slidev/node/vite/loaders.ts (only if the driver must pass/keep a cache)test/parser.test.ts / colocated parser testsOut of scope:
load return type.perf/incremental-parse-cache.perf(parser): cache per-file parse across reloads.Before changing anything, quantify the cost so the win is real: add a temporary
timing log (or a Vitest benchmark) around load for a large synthetic deck
(e.g. 200 slides, several src: imports) and record parse time per reload.
If the measured cost is negligible, STOP and report — this plan may not be
worth doing for typical decks.
Introduce a cache (a Map<filepath, { hash: string; md: SlidevMarkdown }>) that
survives across load calls (owned by the caller and passed in, or a module-level
cache in fs.ts with an explicit invalidation API). In loadMarkdown, compute a
cheap hash of the file source; reuse the cached SlidevMarkdown when the hash
matches, otherwise re-parse and update the cache. Ensure preparser extensions
identity is part of the key (a different extension set must invalidate).
Instead of detectFeatures(join(all raw)) every call, detect features per file
(cache per file) and merge. Preserve the exact resulting features object shape
and values (it feeds data.features, which HMR compares with fast-deep-equal).
Confirm that editing one file invalidates exactly that file (and dependents via
src:), that adding/removing a slide still updates counts, and that
preparser-extension changes bust the cache.
Verify: pnpm build && pnpm test -- parser → all existing parser snapshots
unchanged (the cache must be transparent), plus new cache tests pass.
loads of the same unchanged content reuse the cached parse
(assert via a spy/counter that parse runs once); (b) changing a file's content
re-parses only that file; (c) features/markdownFiles output is identical to
the non-cached path for the existing fixtures (snapshot parity).test/parser.test.ts fixture snapshots are the transparency
guarantee — they must not change.pnpm build && pnpm typecheck && pnpm test -- parser passgit status)plans/README.md status row updatedStop and report if:
src: graphs in a way
you can't make provably correct — report rather than shipping a subtly-stale cache.utils refresh is awaited, this cache
reduces the cost that made that refresh expensive.