| 1 | # Plan 019: Key the `getRoots()` cache by entry (fix multi-entry build/export) |
| 2 | |
| 3 | > **Executor instructions**: Follow this plan step by step. Run every |
| 4 | > verification command and confirm the expected result. If anything in "STOP |
| 5 | > conditions" occurs, stop and report. When done, update the status row in |
| 6 | > `plans/README.md`. |
| 7 | > |
| 8 | > **Drift check (run first)**: `git diff --stat c63cb120..HEAD -- packages/slidev/node/resolver.ts packages/slidev/node/resolver.test.ts` |
| 9 | > On a mismatch with the excerpts below, treat it as a STOP condition. |
| 10 | |
| 11 | ## Status |
| 12 | |
| 13 | - **Priority**: P2 |
| 14 | - **Effort**: M |
| 15 | - **Risk**: MED |
| 16 | - **Depends on**: none |
| 17 | - **Category**: bug |
| 18 | - **Planned at**: commit `c63cb120`, 2026-07-10 |
| 19 | |
| 20 | ## Why this matters |
| 21 | |
| 22 | `getRoots()` caches its result in a module-global and returns it for **any** |
| 23 | subsequent call, ignoring the `entry` argument. The CLI processes multiple decks |
| 24 | in one process — `slidev build a/slides.md b/slides.md`, and the export / |
| 25 | export-notes loops — calling `resolveOptions` (→ `getRoots(entry)`) per entry. So |
| 26 | every deck after the first resolves its `@/…`/absolute imports, `src:` includes, |
| 27 | theme, and `package.json` against the **first** deck's directory: silent wrong |
| 28 | output for multi-entry builds/exports where the entries live in different folders. |
| 29 | |
| 30 | ## Current state |
| 31 | |
| 32 | `packages/slidev/node/resolver.ts:356-386`: |
| 33 | ```ts |
| 34 | let rootsInfo: RootsInfo | null = null |
| 35 | |
| 36 | export async function getRoots(entry?: string): Promise<RootsInfo> { |
| 37 | if (rootsInfo) |
| 38 | return rootsInfo // ← ignores `entry` |
| 39 | if (!entry) |
| 40 | throw new Error('[slidev] Cannot find roots without entry') |
| 41 | const userRoot = dirname(entry) |
| 42 | isInstalledGlobally.value = /* … computed from userRoot/argv/invocationNodeModules … */ |
| 43 | const clientRoot = await findPkgRoot('@slidev/client', cliRoot, true) |
| 44 | const closestPkgRoot = dirname(await findClosestPkgJsonPath(userRoot) || userRoot) |
| 45 | const userPkgJson = await getUserPkgJson(closestPkgRoot) |
| 46 | const userWorkspaceRoot = await searchForWorkspaceRoot(closestPkgRoot) |
| 47 | rootsInfo = { cliRoot, clientRoot, userRoot, userPkgJson, userWorkspaceRoot } |
| 48 | return rootsInfo |
| 49 | } |
| 50 | ``` |
| 51 | Callers: |
| 52 | - With entry: `options.ts:24` (`await getRoots(entry)`), once per deck. |
| 53 | - No-arg (rely on the singleton within the current deck's processing): |
| 54 | `resolver.ts:258` (createResolver), `integrations/addons.ts:8`, |
| 55 | `commands/export.ts:627` (importPlaywright). |
| 56 | - Multi-entry loops: `cli.ts:382` (build), `cli.ts:482` (export), `cli.ts:555` |
| 57 | (export-notes) — each calls `resolveOptions({ entry: entryFile }, …)`. |
| 58 | - Test: `resolver.test.ts:83-85` primes `await getRoots('/user/project')` in |
| 59 | `beforeEach`; all its cases use the same entry. |
| 60 | |
| 61 | ## Commands you will need |
| 62 | |
| 63 | | Purpose | Command | Expected | |
| 64 | |---------|---------|----------| |
| 65 | | Install | `pnpm install` | exit 0 | |
| 66 | | Build | `pnpm build` | exit 0 | |
| 67 | | Test | `pnpm test -- resolver` | all pass | |
| 68 | | Typecheck | `pnpm typecheck` | exit 0 | |
| 69 | |
| 70 | ## Scope |
| 71 | |
| 72 | **In scope**: |
| 73 | - `packages/slidev/node/resolver.ts` (`getRoots` caching) |
| 74 | |
| 75 | **Out of scope**: |
| 76 | - The no-arg call sites (they keep working via the "last roots" fallback). |
| 77 | - Any change to how roots are *computed* (only how they're cached). |
| 78 | |
| 79 | ## Git workflow |
| 80 | |
| 81 | - Branch: `fix/getroots-per-entry`. |
| 82 | - Conventional commit: `fix(resolver): cache roots per entry for multi-deck builds`. |
| 83 | - Do NOT push/PR unless instructed. |
| 84 | |
| 85 | ## Steps |
| 86 | |
| 87 | ### Step 1: Replace the single-slot cache with a per-entry Map + last-roots pointer |
| 88 | |
| 89 | ```ts |
| 90 | const rootsCache = new Map<string, RootsInfo>() |
| 91 | let lastRoots: RootsInfo | null = null |
| 92 | |
| 93 | export async function getRoots(entry?: string): Promise<RootsInfo> { |
| 94 | if (!entry) { |
| 95 | if (lastRoots) |
| 96 | return lastRoots |
| 97 | throw new Error('[slidev] Cannot find roots without entry') |
| 98 | } |
| 99 | const cached = rootsCache.get(entry) |
| 100 | if (cached) { |
| 101 | lastRoots = cached |
| 102 | return cached |
| 103 | } |
| 104 | const userRoot = dirname(entry) |
| 105 | isInstalledGlobally.value = /* …unchanged… */ |
| 106 | const clientRoot = await findPkgRoot('@slidev/client', cliRoot, true) |
| 107 | const closestPkgRoot = dirname(await findClosestPkgJsonPath(userRoot) || userRoot) |
| 108 | const userPkgJson = await getUserPkgJson(closestPkgRoot) |
| 109 | const userWorkspaceRoot = await searchForWorkspaceRoot(closestPkgRoot) |
| 110 | const info: RootsInfo = { cliRoot, clientRoot, userRoot, userPkgJson, userWorkspaceRoot } |
| 111 | rootsCache.set(entry, info) |
| 112 | lastRoots = info |
| 113 | return info |
| 114 | } |
| 115 | ``` |
| 116 | Rationale: the CLI processes each deck **sequentially** (resolveOptions → build/ |
| 117 | export for one entry, then the next), so no-arg callers during a deck's |
| 118 | processing correctly see that deck's roots via `lastRoots`, and re-processing a |
| 119 | different entry recomputes instead of returning stale roots. |
| 120 | |
| 121 | **Verify**: reading the function, a second `getRoots(entryB)` with a different |
| 122 | `entryB` returns roots whose `userRoot === dirname(entryB)`, not the first deck's. |
| 123 | |
| 124 | ### Step 2: Run the resolver tests |
| 125 | |
| 126 | Because `resolver.test.ts` primes the same `/user/project` entry in every |
| 127 | `beforeEach`, the Map returns the cached value — behavior is unchanged for a |
| 128 | single entry. |
| 129 | |
| 130 | **Verify**: `pnpm build && pnpm test -- resolver` → all existing tests pass. |
| 131 | |
| 132 | ### Step 3 (optional): add a multi-entry regression test |
| 133 | |
| 134 | If feasible with the existing `node:fs` mock in `resolver.test.ts`, add a test |
| 135 | that `getRoots('/user/a/slides.md')` then `getRoots('/user/b/slides.md')` yields |
| 136 | distinct `userRoot`s. If the fs mock makes this awkward, skip and rely on Step 1 |
| 137 | review + the existing suite. |
| 138 | |
| 139 | ## Test plan |
| 140 | |
| 141 | - Existing `resolver.test.ts` must stay green (single-entry behavior unchanged). |
| 142 | - Optional new case: two different entries → two different `userRoot`s (the |
| 143 | regression guard for the multi-entry bug). |
| 144 | |
| 145 | ## Done criteria |
| 146 | |
| 147 | - [ ] `getRoots(entry)` returns roots computed for **that** entry, even after a prior different entry |
| 148 | - [ ] No-arg `getRoots()` returns the most-recently-resolved roots (throws only if none yet) |
| 149 | - [ ] `pnpm build && pnpm test -- resolver && pnpm typecheck` all pass |
| 150 | - [ ] Only `resolver.ts` (+ optional test) modified (`git status`) |
| 151 | - [ ] `plans/README.md` status row updated |
| 152 | |
| 153 | ## STOP conditions |
| 154 | |
| 155 | Stop and report if: |
| 156 | |
| 157 | - Any code path calls `getRoots()` (no-arg) **before** any `getRoots(entry)` in a |
| 158 | real flow (would now throw where it previously returned the singleton) — search |
| 159 | usages; the CLI always resolves an entry first, but confirm. |
| 160 | - Decks are ever processed **concurrently** in one process (the `lastRoots` |
| 161 | pointer assumes sequential processing) — if so, roots must be threaded |
| 162 | explicitly instead of via module state; report before proceeding. |
| 163 | |
| 164 | ## Maintenance notes |
| 165 | |
| 166 | - Long-term, prefer threading `RootsInfo` explicitly to the no-arg callers rather |
| 167 | than relying on `lastRoots`; this plan is the minimal fix that preserves the |
| 168 | current call sites. |
| 169 | - Reviewer: confirm `isInstalledGlobally` is still set correctly per entry. |
| 170 |