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 5 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). |
…rce) After the first fix, staging still froze ~7.5s on the exact path implementation-overview -> authentication. Root cause is a second, independent instance of the same selector pathology, this time in Mintlify's own stylesheet: three code-block rules whose selectors begin with a bare :has() (unanchored subject). In Chrome/Blink, the first time a matching standalone code block renders, the document root is permanently flagged ':has-affected' and every forced style recalc in the tab then walks the entire document (~30x slower; measured 0.7ms -> 20ms per recalc, persisting across SPA navigations). Mermaid's ~280 getBBox measurements during navigation then cost ~7.5s. Five pages currently poison the tab this way on their own initial render (implementation- overview, managing-sessions, exporting-wallet, webhooks, sandbox- testing); before the first fix in this branch, our own html:has() rules poisoned EVERY page, which is why production freezes from any origin. css-perf-patch.js rewrites those three selectors in CSSOM to preceding- sibling equivalents (the :has() parent is always a sibling in Mintlify's DOM). Exact-match only, so it fails safe to a no-op if Mintlify ships different CSS. Verified visually equivalent: pixel-identical full-page screenshots and identical computed code-block padding across 10 pages, light + dark, patch on vs off. Effect (preview, Chrome): impl-overview -> authentication SPA navigation 7,700ms -> 460ms with diagrams and zoom/pan controls intact. Coverage caveat documented in the file: custom JS runs post-paint, so a tab whose FIRST page is one of the five poisoning pages is poisoned before the patch runs (unfixable post-hoc; upstream Mintlify selector fix is the complete cure). All SPA navigations after the script runs are safe. Also correct comments claiming head.raw sets pre-paint classes: the hosted renderer strips head.raw scripts entirely (verified on prod and preview), so sidebar-toggle.js is the effective mechanism. Co-authored-by: Cursor <cursoragent@cursor.com>
Second fix pushed: Mintlify's own bare-`:has()` rules (the remaining staging freeze)After the first fix, staging still froze ~7.5 s on the specific path implementation-overview → authentication. Investigation found a second, independent instance of the same selector pathology — this time in Mintlify's stylesheet, not ours:
Fix: `css-perf-patch.js` rewrites those three selectors in CSSOM to preceding-sibling equivalents (the `:has()` parent is always a preceding sibling in Mintlify's DOM). Exact-match only → no-op if Mintlify ships different CSS (fail-safe: look can never break). Verification on the deployed preview:
Diagrams render (5/5), zoom/pan controls intact, rules confirmed patched in CSSOM. Look: pixel-identical — full-page screenshots (10 pages × light+dark) with patch on vs off, plus deployed-preview vs pre-push baseline: zero differing pixels; computed code-block padding identical on all 71 code blocks across test pages. Known limitation: custom JS runs after first paint, so a tab whose first-loaded page is one of the five poisoning pages is poisoned before the patch runs — that specific tab stays slow (proven unfixable post-hoc; even deleting every :has rule doesn't clear Blink's element flags). Every session landing anywhere else is fully fixed. The complete cure is Mintlify correcting the three selectors upstream — bug report to be filed, after which `css-perf-patch.js` can be deleted. Also verified: the hosted renderer strips `head.raw` scripts entirely (on prod too), so comments claiming pre-paint class-setting via head.raw were corrected; `sidebar-toggle.js` is the effective mechanism. |
Follow-up experiments (PR thread) corrected an earlier conclusion: the Blink ':has-affected' marks are sticky for the life of the tab, but they only cost while :has() rules are present in active stylesheets. Deleting every :has rule drops per-recalc cost from ~20ms to ~0.1ms instantly - and the reason a one-shot deletion previously appeared not to work is that Mintlify re-injects stylesheets during SPA navigation, silently restoring the rules mid-transition. css-perf-patch.js now has two defenses: 1. Prevention (unchanged): rewrite the three bare-:has selectors on sight, so tabs that start on a non-poisoning page never get marked. 2. Cure (new): one cheap probe after load detects an already-poisoned tab (landed directly on a page with standalone code blocks). On such tabs only, each navigation stash-deletes ALL :has rules for a 4s window (re-sweeping as re-injected sheets arrive), then restores them into the same owner sheet at the same index, preserving cascade and @layer order; bare originals are rewritten on restore. Measured on the preview (Chrome, injected at realistic custom-JS timing): worst case land-on-impl-overview -> authentication drops 7.9s -> ~740ms, all 5 diagrams + zoom/pan controls intact, all 81 :has rules restored after the window, no page errors. Healthy tabs never trigger the cure (verified: rules untouched mid-navigation). Transition window is visually clean (screenshot in PR). Co-authored-by: Cursor <cursoragent@cursor.com>
Cure mode added: the first-load gap is now closed tooFollow-up experiments corrected an earlier conclusion (details in the thread): Blink's `:has`-affected marks are sticky for the tab's lifetime, but they only cost while `:has()` rules are present in active stylesheets — deleting every :has rule drops per-recalc cost from ~20 ms to ~0.1 ms instantly. A one-shot deletion previously appeared not to work because Mintlify re-injects stylesheets during SPA navigation, silently restoring the rules mid-transition. `css-perf-patch.js` now has two defenses:
Verified on the deployed preview (real mouse clicks, Chrome):
All runs: 5/5 diagrams, zoom/pan controls present, all 81 :has rules restored post-window, zero page errors. Healthy tabs never trigger the cure (rules verified untouched mid-nav). Steady-state look: pixel-identical vs baseline. During the 4 s window on poisoned tabs, cosmetic :has styling (e.g. code-block corner effects) is briefly simplified — mid-transition screenshot showed no visible difference in practice. Every path measured this session, before → after: worst case 13.3 s → ~0.5 s; no measured path above ~0.8 s. The upstream Mintlify selector fix remains the clean long-term cure, after which this file can be deleted. |
The scheduled poison detection waited for the page to settle (~3-4s after load), but a real user landing on a poisoning page often clicks a sidebar link sooner - reproduced: clicks at +1.5s/+2.5s after DCL still froze ~8.5s while clicks at +4s were cured (480ms). onNavigate() now runs the detection probe on the spot when no verdict exists yet (~100ms on a poisoned tab), so early clicks get cured too. Worst false-positive on a busy healthy tab is one benign 4s stash/restore window. Co-authored-by: Cursor <cursoragent@cursor.com>
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