Skip to content

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

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

Fix multi-second nav freeze caused by html:has() CSS amplification#697
patcapulong wants to merge 5 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 18, 2026 3:49am
grid-wallet-demo Ignored Ignored Preview Jul 18, 2026 3:49am

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

…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>
@patcapulong

Copy link
Copy Markdown
Contributor Author

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:

  • Three of their code-block rules 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 whole document (measured 0.7 ms → ~20 ms per recalc, persisting across SPA navigations for the life of the tab).
  • Five pages currently trigger this on their own initial render: implementation-overview, managing-sessions, exporting-wallet, webhooks, sandbox-testing.
  • Consistency note: before this PR's first fix, our `html:has()` rules did the same thing on every page — which is why production freezes from any origin.

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:

Path Before After
land index → SPA to impl-overview → auth (2 runs) 7,700 ms 482 / 472 ms
land client-keys → auth ~450 ms 453 ms
land directly on a poisoning page → auth ~7,900 ms ~7,900 ms (known limitation, below)

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

Copy link
Copy Markdown
Contributor Author

Cure mode added: the first-load gap is now closed too

Follow-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:

  1. Prevention (unchanged): rewrite the three bare-`:has` selectors on sight → tabs that start on non-poisoning pages 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 those tabs only, each navigation stash-deletes ALL :has rules for a 4 s window (re-sweeping as re-injected sheets arrive), then restores them into the same owner sheet at the same index (cascade/@layer order preserved); bare originals are rewritten on restore.

Verified on the deployed preview (real mouse clicks, Chrome):

Scenario Before Now
land impl-overview (worst case) → auth 7,909 ms 474 ms
land webhooks (poisoner) → auth ~7,900 ms 433 ms
land index → impl → auth (healthy flow) 482 ms 534 ms
land impl → auth → back → auth again 505 ms

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

2 participants