| 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 |