返回 slidev
019-getroots-cache-per-entry.md
根目录 / plans / 019-getroots-cache-per-entry.md
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
170 lines MARKDOWN