Fix multi-second nav freeze caused by html:has() CSS amplification#697
Fix multi-second nav freeze caused by html:has() CSS amplification#697patcapulong wants to merge 2 commits into
Conversation
Clicking into diagram-heavy pages (e.g. Global Accounts > Authentication) froze the main thread for up to ~13s on production. Profiling + A/B testing on prod isolated the cause: mermaid's layout engine forces thousands of synchronous style recalcs via getBBox, and each recalc re-evaluated our root-anchored :has() selectors against the whole document. Bisecting style.css found 9 blocks responsible for ~95% of the cost - all html:has(#flow-builder-container / #wallet-demo-container / .is-custom) rules. With them removed, the same navigation runs in ~270ms locally (was ~5.7s); dropping ALL other :has()/[class*=] rules saved nothing further. Replace them with class-keyed selectors (html.ls-page-flow-builder, html.ls-page-wallet-demo, html.ls-page-custom): - head.raw sets the path-based classes pre-paint on full loads - sidebar-toggle.js keeps them in sync across SPA navigations, detecting .is-custom from the DOM in its existing rAF-debounced mutation pass Verified pixel-identical before/after (18 full-page screenshots across 6 docs pages + flow-builder/demo, light+dark, desktop+mobile widths, zero differing pixels) plus functional witnesses (overflow lock, breadcrumb ::after, rail visibility) and SPA round-trips in/out of both special pages. Co-authored-by: Cursor <cursoragent@cursor.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
|
Preview deployment for your docs. Learn more about Mintlify Previews.
|
|
Preview deployment for your docs. Learn more about Mintlify Previews.
|
Greptile SummaryThis PR fixes a multi-second navigation freeze caused by 9 root-anchored
Confidence Score: 4/5Safe to merge — the CSS rule replacements are semantically equivalent and the class-toggling plumbing in sidebar-toggle.js is well-guarded by rAF debouncing and early-return checks in ensureRail/ensureFooterBtn. The core fix is correct and well-tested. Two minor observations: a code comment in style.css overstates first-paint coverage for ls-page-custom (head.raw does not set it, only sidebar-toggle.js does), and the MutationObserver else-branch is widened to always call scheduleEnsure rather than only when elements are missing — intentional for .is-custom detection, cheap in practice, but a behavioral change worth knowing. mintlify/sidebar-toggle.js — the widened MutationObserver else-branch is the most non-obvious change and worth a quick read-through, particularly the interaction between updatePageClasses and the rAF-debounced scheduleEnsure.
|
| Filename | Overview |
|---|---|
| mintlify/style.css | Replaces 9 root-anchored html:has() selectors with html.ls-page-* class-based equivalents — the core perf fix; comment on ls-page-custom slightly inaccurate about head.raw setting it |
| mintlify/sidebar-toggle.js | Adds updatePageClasses() and widens the MutationObserver else-branch from a guard (fire only when elements missing) to always-scheduleEnsure; correct but now runs querySelector on every mutation frame |
| mintlify/docs.json | head.raw updated: old html:has(#flow-builder-container) style block removed; new pre-paint script sets ls-page-flow-builder / ls-page-wallet-demo; class-based style block added in correct order before nav-collapsed restore |
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
mintlify/style.css:4831-4838
**Comment overstates `head.raw` coverage for `ls-page-custom`**
The inline comment says "the class is set by head.raw (first paint) and sidebar-toggle.js (SPA navigation)", but `head.raw` only sets `ls-page-flow-builder` and `ls-page-wallet-demo`. `ls-page-custom` is exclusively set by `sidebar-toggle.js` (via `document.querySelector('.is-custom')`, which requires the body to be parsed). On first paint of a custom page there is no actual regression — `hasVisibleSidebar()` already returns false so the rail is never injected — but the comment will mislead future maintainers who read it as a guarantee of pre-paint coverage for this class.
### Issue 2 of 2
mintlify/sidebar-toggle.js:287-293
**`scheduleEnsure` now fires on every mutation burst, not just when elements are missing**
The old `else if (!rail || !document.body.contains(rail) || ...)` guard fired only when an element needed to be (re)created. The new plain `else` fires `scheduleEnsure` for *every* non-path-change mutation, which is correct for keeping `ls-page-custom` in sync via `document.querySelector('.is-custom')`. Worth being aware of: on DOM-heavy pages (e.g. the mermaid ones this PR is fixing), `updatePageClasses()` + early-return calls to `ensureRail`/`ensureFooterBtn` now run approximately once per animation frame for the full duration of incremental rendering. The rAF debounce keeps it off the critical path and the work is O(1) per frame, so this is not a correctness or observable performance issue — just a design trade-off to be conscious of when reasoning about future changes to the mutation observer.
Reviews (1): Last reviewed commit: "Fix multi-second nav freeze caused by ht..." | Re-trigger Greptile
- Observer now schedules the ensure pass only when the rail/footer button are missing or the .is-custom marker disagrees with the ls-page-custom class, instead of on every mutation burst - keeps the original 'don't read layout every frame' guard while still syncing the custom-page class. - style.css comment no longer claims head.raw sets ls-page-custom pre-paint (it can't - the class is DOM-derived; full loads of custom pages never inject the rail anyway, so SPA-time coverage suffices). Co-authored-by: Cursor <cursoragent@cursor.com>
|
Addressed both review comments in the latest commit:
Re-verified after the change: SPA round-trips in/out of both special pages still toggle classes/overflow/rail correctly in both directions, and the nav benchmark is unchanged (~277 ms avg). |
Problem
Clicking into diagram-heavy pages in the sidebar — e.g. Global Accounts → Authentication — froze the entire page for up to ~13 seconds on production. No hovers, no clicks, nothing, until the freeze ended.
Root cause (measured, not guessed)
Profiled production with headless Chrome and confirmed causally with A/B tests:
getBBoxcalls, each forcing a style recalc. (Blocking the mermaid chunks at network level: 13.3 s → 79 ms.):has()selectors against the whole document. With our custom CSS genuinely disabled (synchronous interception, verified with computed-style witnesses), the same mermaid render costs ~0.5 s instead of ~12.4 s.Bisecting
style.cssrule-by-rule found that 9 blocks carry ~95% of the cost — all of the formhtml:has(#flow-builder-container),html:has(#wallet-demo-container),html:has(.is-custom). A:has()anchored at the document root is re-checked on DOM mutations anywhere in the page. The other ~200:has()/[class*=]rules are innocent: dropping them all saved nothing beyond dropping these 9.Fix
Key those 9 rules off page classes instead of root-anchored
:has():html.ls-page-flow-builder/html.ls-page-wallet-demo/html.ls-page-customhead.rawsets the path-based classes pre-paint on full page loadssidebar-toggle.jskeeps them in sync across SPA navigations (.is-customdetected from the DOM in the existing rAF-debounced mutation pass)Same semantics, evaluated in constant time.
Verification
Look (pixel-identical): 18 full-page before/after screenshots — 6 docs pages + flow-builder + wallet demo, light + dark, desktop + mobile widths — compared pixel-by-pixel: zero differing pixels. Functional witnesses on the special pages (scroll lock, mobile breadcrumb
::after, rail visibility) identical, and SPA round-trips in/out of both special pages toggle the classes correctly in both directions.Speed: local click benchmark overview → authentication, 3 reps: ~5.7 s → ~270 ms — identical to the empty-stylesheet floor (the remainder is Mintlify's own render work).
Reviewer note
Worth a quick manual pass on
/flow-builderand/global-accounts/demoin the preview deploy — they're the two pages whose scoping plumbing changed (screenshots + functional checks cover them, but interactions like sidebar drag are best eyeballed).Made with Cursor