Skip to content

Commit fb5cc92

Browse files
committed
Document book list scroll perf investigation
1 parent a6045c1 commit fb5cc92

1 file changed

Lines changed: 205 additions & 0 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# 2026-05-04 Book list scroll performance investigation
2+
3+
## Context
4+
5+
Scrolling through the home book list (`#main-pane``.books`) felt
6+
janky on a library of ~2777 books. This note records the profiling
7+
work, the changes that landed, and what was deliberately left for
8+
later.
9+
10+
Profiles were taken with Safari Web Inspector (Timeline: Layout &
11+
Rendering / JavaScript & Events / CPU) and Samply (Rust side). All
12+
runs cover a 15s window of the same scroll gesture on the home view.
13+
14+
## Initial profile (baseline)
15+
16+
Safari main-thread breakdown across 15s of scrolling:
17+
18+
- Total: 9457 ms
19+
- Paint: **81.1 %** (7671 ms)
20+
- Layout: 16 % (1511 ms)
21+
- JavaScript: 2.9 % (275 ms)
22+
- Style: 0 %
23+
- Avg CPU 58.2 %, peak 84.6 %
24+
- Active IntersectionObservers: **87**
25+
- Network requests during the window: **644**
26+
27+
Top JS samples were dominated by IntersectionObserver callbacks at
28+
[main.ts](../../src/main.ts) line 1188 (`ensureThumbnailObserver`).
29+
30+
Samply (Rust) showed `book_thumbnail` IPC fan-out:
31+
32+
- `book_thumbnail`: 73 samples
33+
- `compile_exclude_patterns`: 26 ← **recompiled per call**
34+
- `matches_excluded_pattern`: 15
35+
- `std::sys::fs::metadata`: 29
36+
37+
`library_snapshot` (110 samples) was a one-time startup cost, not a
38+
per-scroll repeat.
39+
40+
### Hypotheses ranked by expected impact
41+
42+
| # | Change | Expected effect | Difficulty |
43+
| --- | -------------------------------------------------------------- | ---------------------------------------------------------- | ---------- |
44+
| 1 | `content-visibility: auto` + `contain-intrinsic-size` on rows | Off-screen layout/paint goes to ~0 → big paint reduction | Low |
45+
| 2 | `<img.book-thumb>` with explicit `width`/`height` | Bound image-load reflow | Low |
46+
| 3 | Batch the per-book `book_thumbnail` IPC | Cut 644 network requests to ~10 | Medium |
47+
| 4 | True virtual scrolling | Bound DOM size | High |
48+
49+
## Round 1: `content-visibility: auto` + `<img>` size attrs
50+
51+
Changes:
52+
53+
- [styles.css](../../src/styles.css) `.book-item` (list view): added
54+
`content-visibility: auto; contain-intrinsic-size: auto 150px;`.
55+
- [styles.css](../../src/styles.css) `.books.grid-view .book-item`:
56+
added `contain-intrinsic-size: auto 220px;` (inherits the `auto`
57+
declaration).
58+
- [main.ts](../../src/main.ts) `<img.book-thumb>`: added `width=72`,
59+
`height=102`, `decoding="async"`, `loading="lazy"`.
60+
61+
Result:
62+
63+
- Total: 9718 ms (≈unchanged)
64+
- Paint: **41.5 %** (4029 ms) — **−47 %**
65+
- Layout: **52.3 %** (5082 ms) — +236 %
66+
- JavaScript: 0.4 % (35 ms) — −87 %
67+
- Style: 5.9 % (572 ms) — new line item
68+
- Avg CPU 55.3 %, peak 69.4 %
69+
- Active IntersectionObservers: 87 → **15**
70+
- Network requests during the window: 644 → **31** (`loading="lazy"`)
71+
72+
Reading: paint won big. The new layout cost is `content-visibility`'s
73+
inherent trade — viewport entries trigger real layout per row. Style
74+
appeared because `transitionend` started firing.
75+
76+
## Round 2: scope hover transitions during scroll
77+
78+
Investigation: Style 572 ms + `transitionend` 59 came from
79+
`.book-reveal-btn` (opacity + color + background, with permanent
80+
`backdrop-filter: blur(4px)`) and `.book-select-checkbox` (opacity)
81+
firing as rows scroll under the pointer.
82+
83+
Change:
84+
85+
- [styles.css](../../src/styles.css): added
86+
`html.is-scrolling .book-item .book-reveal-btn,
87+
html.is-scrolling .book-item .book-select-checkbox { transition: none; }`.
88+
- [main.ts](../../src/main.ts): scroll listener on `#main-pane`
89+
toggles `<html>.is-scrolling` with a 160 ms idle debounce.
90+
91+
Result:
92+
93+
- Total: 9886 ms
94+
- Paint: 42.8 % (4232 ms)
95+
- Layout: 52.8 % (5217 ms)
96+
- Style: 4.0 % (400 ms) — −30 %
97+
- `transitionend`: 59 → **45**
98+
- 4 GC samples appeared in the top entries (≈8.2 ms total) — new
99+
signal: short-lived allocations during scroll.
100+
101+
Layout/Paint moved within noise; the transition suppression delivered
102+
its piece (style) but the next bottleneck moved.
103+
104+
## Round 3: `contain` boundary + WeakMap lookup
105+
106+
Changes:
107+
108+
- [styles.css](../../src/styles.css) `.book-item`: added
109+
`contain: layout paint style;` so realize-time work cannot escape
110+
a row's box.
111+
- [main.ts](../../src/main.ts): introduced
112+
`const thumbnailBookByImage = new WeakMap<HTMLImageElement, BookSummary>()`.
113+
- IO callback no longer calls `viewerState.books.find(...)` — it
114+
reads the row's book directly from the WeakMap. With ~2777 books
115+
and 18 IO entries per batch, this removed ≈50 000 string
116+
comparisons per batch and the per-find arrow-function allocation.
117+
- `renderBookList` registers each `<img>` in the WeakMap right before
118+
`observe()`. The img → book entry is auto-released when the img is
119+
GC'd.
120+
121+
Result:
122+
123+
- Total: 10586 ms
124+
- Paint: 44.3 % (4687 ms)
125+
- Layout: 50.5 % (5351 ms)
126+
- Style: 5.0 % (533 ms)
127+
- JavaScript: 0.1 % (15 ms) — −57 %
128+
- `transitionend`: 45 → **36**
129+
- Active IntersectionObservers: 18 → **8**
130+
- **GC samples: 4 → 0 in the top entries**
131+
- **Max IO callback time: 4.438 ms → 0.807 ms (−82 %)**
132+
133+
Layout/Paint were within run-to-run noise; the JS-side and GC-side
134+
costs collapsed cleanly.
135+
136+
## Net change: baseline → final
137+
138+
| Metric | Baseline | Final | Change |
139+
| ------------------- | -------: | ------: | --------------- |
140+
| Paint | 7671 ms | 4687 ms | **−39 %** |
141+
| Layout | 1511 ms | 5351 ms | +254 % (CV cost)|
142+
| JavaScript | 275 ms | 15 ms | **−95 %** |
143+
| Active IO observers | 87 | 8 | **−91 %** |
144+
| Network requests | 644 | 16 | **−98 %** |
145+
| Max IO callback | 5.2 ms | 0.8 ms | **−85 %** |
146+
| GC top samples | n/a | none | clean |
147+
148+
Total main-thread time crept up (9457 → 10586 ms) but the composition
149+
shifted from "wide paint over the whole list" to "narrow layout
150+
realize cost on rows entering the viewport." Subjective scroll
151+
smoothness is the right success criterion from here, not the totals.
152+
153+
## What was deliberately not done
154+
155+
These were considered and parked. Each is a real lever but adds
156+
either risk or scope, and the user-visible cost of stopping here was
157+
judged acceptable.
158+
159+
- **Batched `book_thumbnail` IPC.** The `loading="lazy"` change
160+
already cut network requests 644 → 16 because WebKit suppresses
161+
off-screen image fetches under CV. A `book_thumbnails(file_paths[])`
162+
command would still help if the visible-set-on-arrival case becomes
163+
hot, but it is no longer obviously necessary.
164+
- **Cache `CompiledExcludePatterns` in `ConfigState`.** Samply showed
165+
`compile_exclude_patterns` recompiling per `book_thumbnail` call.
166+
With per-scroll IPC volume now low, the absolute waste is small.
167+
Worth picking up the next time we touch `ConfigState`, especially
168+
since the watcher path also recompiles eagerly.
169+
- **`.book-item` internal DOM/CSS slimming.** Each row still builds
170+
the `book-tags-row`, `book-tag-list`, `book-action-list`, and tag
171+
edit button even when `book.tags.length === 0`, and `.book-tag` uses
172+
a per-tag `<i class="fa-solid fa-tag">`. These are realize-time
173+
costs under CV. Not pursued because (a) layout/paint numbers are
174+
within noise of CV's floor, (b) it would entail real DOM-shape
175+
changes, (c) the user asked to stop here.
176+
- **True virtual scrolling.** The biggest hammer left, but it would
177+
invalidate the IntersectionObserver assumption and reshape
178+
`renderBookList`. Reserved for if the library grows past the point
179+
where CV alone is enough.
180+
- **`.book-thumb` `box-shadow` softening.** `box-shadow: 0 10px 24px`
181+
on every thumb is paint-expensive on realize. Hover-only or a
182+
cheaper shadow would help; not done because paint is no longer the
183+
dominant cost.
184+
185+
## Files touched
186+
187+
- [src/styles.css](../../src/styles.css)
188+
- `.book-item`: `content-visibility: auto`, `contain-intrinsic-size`,
189+
`contain: layout paint style`.
190+
- `.books.grid-view .book-item`: `contain-intrinsic-size`.
191+
- Added `html.is-scrolling .book-item .book-reveal-btn,
192+
html.is-scrolling .book-item .book-select-checkbox { transition: none; }`.
193+
- [src/main.ts](../../src/main.ts)
194+
- `<img.book-thumb>` gets `width`/`height`/`decoding="async"`/`loading="lazy"`.
195+
- Module-level `thumbnailBookByImage = new WeakMap<...>()`.
196+
- IO callback uses the WeakMap instead of `viewerState.books.find`.
197+
- `renderBookList` writes the WeakMap entry before `observe()`.
198+
- `#main-pane` scroll listener toggles `<html>.is-scrolling` with a
199+
160 ms idle debounce.
200+
201+
## Verification
202+
203+
`npm run lint`, `npm run fmt:check`, and `npm test` all green after
204+
each round. Tauri-side runtime verification was done by re-recording
205+
Safari Timeline + CPU profiles in the actual app between rounds.

0 commit comments

Comments
 (0)