Evidence — TOC scroll spy tracks the section being read¶
Bug: the bolded entry in the right-hand Table of Contents jumped around and pointed at the wrong section. On /docs/features/toc/ at 1280×820 the pre-fix theme highlighted the wrong entry at 19–23 of 25 scroll positions, depending on the run — usually one to three sections ahead of what was actually on screen. metrics.json records the run that produced the committed montage (19/25); the fix measures 0/25 in every run.
Cause: three separate implementations were toggling .active on the same #TableOfContents a links, and the one that was supposed to own it was picking its winner from an unreliable signal.
- Bootstrap's native ScrollSpy was wired up twice —
data-bs-spy="scroll" data-bs-target="#TableOfContents"on<body>(_layouts/root.html) and on.bd-main(_layouts/default.html). Itsdata-bs-offset="100"was also dead: Bootstrap 5.2 replaced that option withdata-bs-root-margin. assets/js/ui-enhancements.jsran a third observer over everya[href^="#"]on the page — TOC links included — clearing.activefrom all of them each time asection[id]intersected.- The theme's own
assets/js/modules/navigation/scroll-spy.jsasked IntersectionObserver for the "most visible" heading. Headings are a few pixels tall, so every heading inside the observer band reports the sameintersectionRatio; the winner was whichever entry happened to be in that callback's batch, and a heading scrolling out of the band triggered no re-evaluation at all.
Fix: Bootstrap's ScrollSpy hooks removed from both layouts, the ui-enhancements.js observer scoped away from TOC links, and the theme's spy rewritten around a positional rule — the active heading is the last one whose top has crossed the reading line (the document's scroll-padding-top, the same offset anchor navigation uses), with the last heading winning once the page is scrolled to the bottom. It is recomputed from scratch on every scroll frame.
What the images show¶
| File | Shows |
|---|---|
01-before-after.png |
Three scroll positions, before and after. The dashed red line is the reading line; the section whose heading last crossed it is the one being read. In each BEFORE panel the bolded TOC entry names a section further down the page than the one on screen; in each AFTER panel they match. |
Numbers¶
metrics.json, from test/visual/scroll-spy-evidence.mjs on /docs/features/toc/ at 1280×820, 25 evenly spaced scroll positions:
| Metric | Before | After |
|---|---|---|
| Scroll positions bolding the wrong entry | 19 / 25 (19–23 across runs) | 0 / 25 |
| TOC entries active on page load | 0 | 1 |
The pre-fix highlight is non-deterministic — which of the three implementations wins depends on callback timing, so both the count and the exact set of wrong positions move between runs (19, 22 and 23 of 25 were all observed). It can also jump backwards while scrolling down: the highlight never jumps backwards while scrolling down test caught 3 such moves in one run against the pre-fix build, while the runs recorded in metrics.json caught none. That instability is the bug as users experience it — and it is why the regression test asserts properties (one active entry, monotonic, matches the reading line) rather than a fixed list of positions.
Reproduce¶
docker compose up # serves :4000
BASE_URL=http://localhost:4000 node test/visual/scroll-spy-evidence.mjs
The BEFORE state is reproduced on the same server: the script serves the pre-fix JavaScript straight out of git (REF, default HEAD) and restores the data-bs-spy attributes the fix removed, so no second build is needed. Point REF at the commit before the fix once it has landed.
Regression test¶
test/visual/features/scroll-spy.spec.js (smoke tier) pins the behaviour: the active entry matches the section under the reading line at every sampled scroll position, exactly one entry is active, the highlight never moves backwards while scrolling down, the last entry wins at the bottom of the page, a clicked entry stays active, and nothing else claims #TableOfContents as a scroll-spy target. Five of its six tests fail against the pre-fix theme.