返回 slidev
020-getslide-lookup-map.md
根目录 / plans / 020-getslide-lookup-map.md
1 # Plan 020: Replace the linear `getSlide` scan (and O(n²) TOC) with a lookup Map
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/client/logic/slides.ts packages/client/composables/useNav.ts packages/client/composables/useTocTree.ts`
9 > On a mismatch with the excerpts below, treat it as a STOP condition.
10
11 ## Status
12
13 - **Priority**: P2
14 - **Effort**: S
15 - **Risk**: LOW
16 - **Depends on**: none (compatible with 010)
17 - **Category**: perf
18 - **Planned at**: commit `c63cb120`, 2026-07-10
19
20 ## Why this matters
21
22 `getSlide(no)` does an O(n) `Array.find` over all slides on every call. It's
23 called on every navigation (`useNav`) and — critically — once per titled slide
24 inside the TOC reducer (`useTocTree`), making TOC construction O(n²). Negligible
25 for a 20-slide deck, but quadratic for 200+ slide decks and on every nav change.
26 A precomputed `Map` keyed by slide number and by `routeAlias` makes lookups O(1).
27
28 ## Current state
29
30 - `packages/client/logic/slides.ts:10-14`:
31 ```ts
32 export function getSlide(no: number | string) {
33 return slides.value.find(
34 s => (s.no === +no || s.meta.slide?.frontmatter.routeAlias === no),
35 )
36 }
37 ```
38 - `slides` is a `Ref<SlideRoute[]>` from the `#slidev/slides` virtual module
39 (imported at `slides.ts:4`).
40 - Hot callers: `packages/client/composables/useTocTree.ts:17` (inside the
41 per-slide `addToTree`), and `packages/client/composables/useNav.ts` (nav paths,
42 e.g. `getSlide`/`getSlidePath` around `:190`, `:291`, `:382`).
43 - `SlideRoute` has `.no` (number) and `.meta.slide?.frontmatter.routeAlias`.
44
45 ## Commands you will need
46
47 | Purpose | Command | Expected |
48 |---------|---------|----------|
49 | Install | `pnpm install` | exit 0 |
50 | Build | `pnpm build` | exit 0 |
51 | Test | `pnpm test -- slides` (new helper test) | pass |
52 | Typecheck | `pnpm typecheck` | exit 0 |
53
54 ## Scope
55
56 **In scope**:
57 - `packages/client/logic/slides.ts` (add a memoized lookup; keep `getSlide` signature)
58 - A unit test for the pure lookup builder
59
60 **Out of scope**:
61 - `useNav.ts` / `useTocTree.ts` call sites (they call `getSlide` unchanged).
62 - The `getSlidePath` undefined-guard (plan 010) — compatible; don't undo it.
63
64 ## Git workflow
65
66 - Branch: `perf/getslide-lookup-map`.
67 - Conventional commit: `perf(client): O(1) slide lookup by no/alias`.
68 - Do NOT push/PR unless instructed.
69
70 ## Steps
71
72 ### Step 1: Extract a pure lookup builder
73
74 Add an exported pure function (testable without the virtual module):
75 ```ts
76 export function buildSlideLookup(list: SlideRoute[]) {
77 const byNo = new Map<number, SlideRoute>()
78 const byAlias = new Map<string, SlideRoute>()
79 for (const s of list) {
80 byNo.set(s.no, s)
81 const alias = s.meta.slide?.frontmatter.routeAlias
82 if (alias != null)
83 byAlias.set(String(alias), s)
84 }
85 return { byNo, byAlias }
86 }
87 ```
88
89 ### Step 2: Memoize it and route `getSlide` through it
90
91 ```ts
92 import { computed } from 'vue'
93 const slideLookup = computed(() => buildSlideLookup(slides.value))
94
95 export function getSlide(no: number | string) {
96 const { byNo, byAlias } = slideLookup.value
97 return byNo.get(+no) ?? byAlias.get(String(no))
98 }
99 ```
100 Preserve the original resolution precedence: number match first, then alias
101 (matches the old `s.no === +no || …routeAlias === no`). Note `+no` of a
102 non-numeric string is `NaN`, which won't match `byNo`; the alias map then handles
103 string aliases — same net behavior as before.
104
105 **Verify**: reading `getSlide`, results match the old predicate for (a) a numeric
106 `no`, (b) a string alias, (c) an unknown value (→ `undefined`).
107
108 ### Step 3: Unit-test the builder
109
110 `packages/client/logic/slides.test.ts` (model after the existing
111 `packages/client/logic/slidePath.test.ts`): construct a couple of minimal
112 `SlideRoute`-shaped objects and assert `buildSlideLookup` maps by `no` and by
113 `routeAlias`, and that a missing key yields `undefined`.
114
115 **Verify**: `pnpm build && pnpm test -- slides` passes.
116
117 ## Test plan
118
119 - Unit-test `buildSlideLookup` (pure, deterministic): by-number, by-alias, and
120 missing-key cases. This is the regression guard that lookup semantics are
121 preserved.
122 - `getSlide` itself depends on the virtual `slides` ref, so it's covered
123 indirectly (the builder is the logic; `getSlide` is a thin memoized wrapper).
124
125 ## Done criteria
126
127 - [ ] `getSlide` no longer does a linear `Array.find`; it uses a memoized Map
128 - [ ] Resolution precedence (number, then alias) is preserved
129 - [ ] `buildSlideLookup` is unit-tested
130 - [ ] TOC/nav still resolve the same slides (typecheck + existing E2E unaffected)
131 - [ ] `pnpm build && pnpm typecheck` exit 0
132 - [ ] Only in-scope files modified (`git status`)
133 - [ ] `plans/README.md` status row updated
134
135 ## STOP conditions
136
137 Stop and report if:
138
139 - `SlideRoute` shape differs from the excerpt (`.no`, `.meta.slide?.frontmatter.routeAlias`).
140 - Aliases can legitimately collide with numeric `no` values in a way the old
141 `||` precedence handled differently than the Map — preserve the old order and
142 note the case.
143
144 ## Maintenance notes
145
146 - The `computed` recomputes only when `slides.value` changes (add/remove/reorder),
147 so nav no longer rescans.
148 - Reviewer: confirm alias keys are stringified consistently on both write and read.
149
149 lines MARKDOWN