Skip to content

Fix multi-second nav freeze caused by html:has() CSS amplification#697

Open
patcapulong wants to merge 2 commits into
mainfrom
pat/authentication-page-freeze
Open

Fix multi-second nav freeze caused by html:has() CSS amplification#697
patcapulong wants to merge 2 commits into
mainfrom
pat/authentication-page-freeze

Conversation

@patcapulong

Copy link
Copy Markdown
Contributor

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:

  1. The trigger: Mintlify renders mermaid diagrams client-side after navigation. Mermaid's layout engine issues thousands of synchronous getBBox calls, each forcing a style recalc. (Blocking the mermaid chunks at network level: 13.3 s → 79 ms.)
  2. The amplifier (~26×): each of those recalcs re-evaluated our root-anchored :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.css rule-by-rule found that 9 blocks carry ~95% of the cost — all of the form html: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-custom
  • head.raw sets the path-based classes pre-paint on full page loads
  • sidebar-toggle.js keeps them in sync across SPA navigations (.is-custom detected 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-builder and /global-accounts/demo in 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

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>
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
grid-flow-builder Ignored Ignored Preview Jul 17, 2026 8:50am
grid-wallet-demo Ignored Ignored Preview Jul 17, 2026 8:50am

Request Review

@mintlify

mintlify Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
Grid 🟢 Ready View Preview Jul 17, 2026, 8:41 AM

@mintlify

mintlify Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
Grid 🟡 Building Jul 17, 2026, 8:39 AM

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a multi-second navigation freeze caused by 9 root-anchored html:has(...) CSS selectors being re-evaluated synchronously on every DOM mutation during mermaid diagram rendering. The fix replaces them with equivalent html.ls-page-* class-based selectors that are evaluated in constant time.

  • style.css: Nine :has(#flow-builder-container), :has(#wallet-demo-container), and :has(.is-custom) rules replaced with html.ls-page-flow-builder, html.ls-page-wallet-demo, and html.ls-page-custom class selectors — same visual behavior, no style-recalc amplification.
  • sidebar-toggle.js: New updatePageClasses() sets the path-based classes on navigation and via a rAF-debounced MutationObserver pass (needed because .is-custom mounts after the path flip); the else if (rail missing) guard is widened to a plain else to catch .is-custom DOM arrival.
  • docs.json head.raw: A pre-paint inline script sets ls-page-flow-builder/ls-page-wallet-demo from the pathname before the class-dependent <style> block, replacing the removed html:has(#flow-builder-container) inline style.

Confidence Score: 4/5

Safe 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.

Important Files Changed

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

Comment thread mintlify/style.css
Comment thread mintlify/sidebar-toggle.js
- 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>
@patcapulong

Copy link
Copy Markdown
Contributor Author

Addressed both review comments in the latest commit:

  1. style.css comment — no longer claims head.raw sets ls-page-custom pre-paint; documents why JS-only coverage is sufficient (full loads of custom pages never inject the rail, so the rule only matters during SPA navigation, where the JS is running).
  2. Observer guard restoredscheduleEnsure fires only when the rail/footer button are missing or the .is-custom marker disagrees with the ls-page-custom class, rather than on every mutation burst. The staleness check is one querySelector + one classList.contains (no layout reads) per mutation batch; the rAF pass with layout reads only runs when something is actually out of sync.

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant