返回 slidev
024-incremental-hmr-parse-cache.md
根目录 / plans / 024-incremental-hmr-parse-cache.md
1 # Plan 024: Incremental parse cache for HMR (stop re-parsing the whole deck per edit)
2
3 > **Executor instructions**: This is a **performance** change with real
4 > correctness risk (cache invalidation). Measure first, change second, and keep
5 > a fast escape hatch. Run the full suite after each step. Honor STOP conditions.
6 > When done, update the status row in `plans/README.md`.
7 >
8 > **Drift check (run first)**: `git diff --stat c63cb120..HEAD -- packages/parser/src/fs.ts packages/parser/src/core.ts packages/slidev/node/vite/loaders.ts`
9 > On a mismatch with the excerpts below, treat it as a STOP condition.
10
11 ## Status
12
13 - **Priority**: P3
14 - **Effort**: L
15 - **Risk**: MED
16 - **Depends on**: none (coordinate with 012, which awaits a per-HMR utils refresh)
17 - **Category**: perf
18 - **Planned at**: commit `c63cb120`, 2026-07-10
19
20 ## Why this matters
21
22 On every hot update, the loader reloads the entire deck: `parser.load` builds a
23 **fresh** `markdownFiles` map, re-reads and re-`parse`s every `src:`-imported file
24 from disk (even unchanged ones), and re-runs feature detection over the whole
25 concatenated deck. So a one-character edit costs O(total deck bytes + number of
26 imported files) each debounced save — growing with deck size and import count.
27 An incremental cache (re-parse only changed files; re-detect features per file)
28 makes edit cost scale with the edit, not the deck.
29
30 ## Current state
31
32 `packages/parser/src/fs.ts:40-159` (`load`):
33 ```ts
34 const markdownFiles: Record<string, SlidevMarkdown> = {} // fresh each call
35 // loadMarkdown re-reads + parses any file not already in THIS call's map:
36 async function loadMarkdown(path, ...) {
37 let md = markdownFiles[path]
38 if (!md) { const raw = await loadSource(path); md = await parse(raw, path, extensions); markdownFiles[path] = md; ... }
39 // ...
40 }
41 // ...
42 return {
43 slides,
44 entry,
45 headmatter,
46 features: detectFeatures(slides.map(s => s.source.raw).join('')), // whole-deck scan every call
47 markdownFiles,
48 watchFiles,
49 }
50 ```
51 Driver: `packages/slidev/node/vite/loaders.ts:153-155` calls
52 `serverOptions.loadData({ [ctx.file]: await ctx.read() })` on any watched change;
53 `cli.ts:158-160` forwards to `parser.load`. `detectFeatures` and
54 `scanMonacoReferencedMods` (`core.ts`) run over the joined deck.
55
56 ## Commands you will need
57
58 | Purpose | Command | Expected |
59 |---------|---------|----------|
60 | Install | `pnpm install` | exit 0 |
61 | Build | `pnpm build` | exit 0 |
62 | Test | `pnpm test -- parser` | all pass (incl. new cache tests) |
63 | Typecheck | `pnpm typecheck` | exit 0 |
64
65 ## Scope
66
67 **In scope**:
68 - `packages/parser/src/fs.ts` (per-file parse cache keyed by content)
69 - `packages/parser/src/core.ts` (per-file feature detection, if features are merged)
70 - `packages/slidev/node/vite/loaders.ts` (only if the driver must pass/keep a cache)
71 - Tests in `test/parser.test.ts` / colocated parser tests
72
73 **Out of scope**:
74 - Changing the parsed output shape or the public `load` return type.
75 - Preparser-extension semantics (must remain correct across the cache).
76
77 ## Git workflow
78
79 - Branch: `perf/incremental-parse-cache`.
80 - Conventional commit(s): `perf(parser): cache per-file parse across reloads`.
81 - Do NOT push/PR unless instructed.
82
83 ## Steps
84
85 ### Step 1: Measure the baseline (STOP-gated by data)
86
87 Before changing anything, quantify the cost so the win is real: add a temporary
88 timing log (or a Vitest benchmark) around `load` for a large synthetic deck
89 (e.g. 200 slides, several `src:` imports) and record parse time per reload.
90 **If the measured cost is negligible, STOP** and report — this plan may not be
91 worth doing for typical decks.
92
93 ### Step 2: Add a content-keyed per-file parse cache
94
95 Introduce a cache (a `Map<filepath, { hash: string; md: SlidevMarkdown }>`) that
96 survives across `load` calls (owned by the caller and passed in, or a module-level
97 cache in `fs.ts` with an explicit invalidation API). In `loadMarkdown`, compute a
98 cheap hash of the file source; reuse the cached `SlidevMarkdown` when the hash
99 matches, otherwise re-parse and update the cache. Ensure preparser `extensions`
100 identity is part of the key (a different extension set must invalidate).
101
102 ### Step 3: Make feature detection incremental
103
104 Instead of `detectFeatures(join(all raw))` every call, detect features per file
105 (cache per file) and merge. Preserve the exact resulting `features` object shape
106 and values (it feeds `data.features`, which HMR compares with `fast-deep-equal`).
107
108 ### Step 4: Verify correctness across edits
109
110 Confirm that editing one file invalidates exactly that file (and dependents via
111 `src:`), that adding/removing a slide still updates counts, and that
112 preparser-extension changes bust the cache.
113
114 **Verify**: `pnpm build && pnpm test -- parser` → all existing parser snapshots
115 **unchanged** (the cache must be transparent), plus new cache tests pass.
116
117 ## Test plan
118
119 - New tests: (a) two `load`s of the same unchanged content reuse the cached parse
120 (assert via a spy/counter that `parse` runs once); (b) changing a file's content
121 re-parses only that file; (c) features/`markdownFiles` output is identical to
122 the non-cached path for the existing fixtures (snapshot parity).
123 - The existing `test/parser.test.ts` fixture snapshots are the transparency
124 guarantee — they must not change.
125
126 ## Done criteria
127
128 - [ ] Unchanged files are not re-parsed across reloads (proven by a test/counter)
129 - [ ] Feature detection no longer scans the whole deck when only one file changed
130 - [ ] All existing parser snapshots are unchanged (cache is transparent)
131 - [ ] Preparser-extension changes correctly invalidate the cache
132 - [ ] `pnpm build && pnpm typecheck && pnpm test -- parser` pass
133 - [ ] Only in-scope files modified (`git status`)
134 - [ ] `plans/README.md` status row updated
135
136 ## STOP conditions
137
138 Stop and report if:
139
140 - Step 1 shows the current cost is negligible for realistic decks (don't add cache
141 complexity for no measurable gain).
142 - Any existing parser snapshot changes (indicates the cache is not transparent —
143 a correctness regression).
144 - Cache invalidation interacts with preparser extensions or `src:` graphs in a way
145 you can't make provably correct — report rather than shipping a subtly-stale cache.
146
147 ## Maintenance notes
148
149 - A stale parse cache is a nasty class of bug; keep the invalidation key simple
150 and total (content hash + extensions identity + import graph).
151 - Coordinates with plan 012: if the per-HMR `utils` refresh is awaited, this cache
152 reduces the cost that made that refresh expensive.
153 - Reviewer: focus on invalidation correctness, not just the speedup.
154
154 lines MARKDOWN