From b804ae91265fd816919594290ee0ebe21ba206a0 Mon Sep 17 00:00:00 2001 From: Pat Capulong Date: Fri, 17 Jul 2026 01:33:34 -0700 Subject: [PATCH 1/3] Fix multi-second nav freeze caused by html:has() CSS amplification 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 --- mintlify/docs.json | 2 +- mintlify/sidebar-toggle.js | 34 ++++++++++++++++++++++++---------- mintlify/style.css | 37 +++++++++++++++++++++++-------------- 3 files changed, 48 insertions(+), 25 deletions(-) diff --git a/mintlify/docs.json b/mintlify/docs.json index e7296c730..41d5b8ec8 100644 --- a/mintlify/docs.json +++ b/mintlify/docs.json @@ -457,7 +457,7 @@ }, "footer": {}, "head": { - "raw": "", + "raw": "", "links": [ { "rel": "preload", diff --git a/mintlify/sidebar-toggle.js b/mintlify/sidebar-toggle.js index 342444608..9a414d4f0 100644 --- a/mintlify/sidebar-toggle.js +++ b/mintlify/sidebar-toggle.js @@ -226,8 +226,25 @@ }); } + // Page-scoped classes on — the cheap replacement for html:has(...) + // selectors in style.css / head.raw. A :has() anchored at the root gets + // re-evaluated on DOM mutations anywhere in the document, which froze + // navigation for seconds on pages that build DOM incrementally (mermaid + // diagrams). head.raw sets these classes pre-paint on full loads; this + // keeps them in sync across SPA navigations. + function updatePageClasses() { + var root = document.documentElement; + var path = location.pathname.replace(/\/+$/, '') || '/'; + root.classList.toggle('ls-page-flow-builder', path === '/flow-builder'); + root.classList.toggle('ls-page-wallet-demo', path === '/global-accounts/demo'); + // Custom-layout pages (frontmatter mode: "custom") — detected from the + // DOM so future custom pages are covered without listing paths here. + root.classList.toggle('ls-page-custom', !!document.querySelector('.is-custom')); + } + function sync() { var root = document.documentElement; + updatePageClasses(); // Navigation/first paint must never animate (only deliberate toggles do — // see the click handler). Clear the animate flag, and snap the rail button // for this navigation-driven state change so its icon doesn't ghost in/out @@ -249,10 +266,11 @@ } // SPA navigation: Mintlify swaps content without a full reload. On a path - // change, re-sync. Otherwise only re-add the rail if Mintlify wiped it — a - // cheap guard so we don't read layout every frame. Custom-layout pages are - // handled by sync() on navigation plus the CSS :has(.is-custom) rule, so the - // rail never lingers visibly even without per-frame polling here. + // change, re-sync. Otherwise schedule a rAF-debounced ensure pass (one per + // mutation burst) that refreshes the page classes and re-adds the rail if + // Mintlify wiped it. The class refresh must run on content mutations too, + // not just path changes, because the new page's DOM (e.g. .is-custom) + // mounts after the pathname flips. var lastPath = location.pathname; var ensureScheduled = false; function scheduleEnsure() { @@ -260,6 +278,7 @@ ensureScheduled = true; requestAnimationFrame(function () { ensureScheduled = false; + updatePageClasses(); ensureRail(); ensureFooterBtn(); }); @@ -268,12 +287,7 @@ if (location.pathname !== lastPath) { lastPath = location.pathname; sync(); - } else if ( - !rail || - !document.body.contains(rail) || - !footerBtn || - !document.body.contains(footerBtn) - ) { + } else { scheduleEnsure(); } }); diff --git a/mintlify/style.css b/mintlify/style.css index e33a33c08..67873c362 100644 --- a/mintlify/style.css +++ b/mintlify/style.css @@ -4096,18 +4096,25 @@ html.dark #flow-builder-container { } } -/* Prevent outer page scroll when flow-builder is present */ -html:has(#flow-builder-container), -html:has(#flow-builder-container) body { +/* Prevent outer page scroll when flow-builder is present. + + PERF: these page-scoped rules key off html.ls-page-flow-builder — a class + set from the pathname by head.raw (first paint) and sidebar-toggle.js (SPA + navigation) — NOT html:has(#flow-builder-container). A :has() anchored at + the root is re-evaluated on DOM mutations anywhere in the document; pages + that build DOM incrementally (e.g. mermaid diagrams) made each of those + re-evaluations fire thousands of times, freezing navigation for seconds. */ +html.ls-page-flow-builder, +html.ls-page-flow-builder body { overflow: hidden !important; } /* Flow Builder mobile breadcrumb — hide text + chevron, keep hamburger, show (0, 0, 0) */ -html:has(#flow-builder-container) #navbar button[class*="h-14"][class*="text-left"] > *:not(:first-child) { +html.ls-page-flow-builder #navbar button[class*="h-14"][class*="text-left"] > *:not(:first-child) { display: none !important; } -html:has(#flow-builder-container) #navbar button[class*="h-14"][class*="text-left"]::after { +html.ls-page-flow-builder #navbar button[class*="h-14"][class*="text-left"]::after { content: "(0, 0, 0)"; margin-left: auto; font-family: "Suisse Intl Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; @@ -4116,7 +4123,7 @@ html:has(#flow-builder-container) #navbar button[class*="h-14"][class*="text-lef color: var(--ls-gray-400); } -html.dark:has(#flow-builder-container) #navbar button[class*="h-14"][class*="text-left"]::after { +html.dark.ls-page-flow-builder #navbar button[class*="h-14"][class*="text-left"]::after { color: var(--ls-gray-700); } @@ -4252,19 +4259,20 @@ html.dark #wallet-demo-host { } } -/* Prevent outer page scroll when the wallet demo is present */ -html:has(#wallet-demo-container), -html:has(#wallet-demo-container) body { +/* Prevent outer page scroll when the wallet demo is present. + PERF: class-keyed, not html:has() — see the flow-builder note above. */ +html.ls-page-wallet-demo, +html.ls-page-wallet-demo body { overflow: hidden !important; } /* Wallet demo mobile breadcrumb — hide text + chevron, keep hamburger, show (0, 0, 0) on the right (matches the flow-builder tab). */ -html:has(#wallet-demo-container) #navbar button[class*="h-14"][class*="text-left"] > *:not(:first-child) { +html.ls-page-wallet-demo #navbar button[class*="h-14"][class*="text-left"] > *:not(:first-child) { display: none !important; } -html:has(#wallet-demo-container) #navbar button[class*="h-14"][class*="text-left"]::after { +html.ls-page-wallet-demo #navbar button[class*="h-14"][class*="text-left"]::after { content: "(0, 0, 0)"; margin-left: auto; font-family: "Suisse Intl Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; @@ -4273,7 +4281,7 @@ html:has(#wallet-demo-container) #navbar button[class*="h-14"][class*="text-left color: var(--ls-gray-400); } -html.dark:has(#wallet-demo-container) #navbar button[class*="h-14"][class*="text-left"]::after { +html.dark.ls-page-wallet-demo #navbar button[class*="h-14"][class*="text-left"]::after { color: var(--ls-gray-700); } @@ -4823,8 +4831,9 @@ html.ls-nav-collapsed .ls-nav-rail::after { /* Custom-layout pages (frontmatter mode: "custom", e.g. the flow builder) have no docs sidebar — Mintlify tags them with .is-custom — so there's nothing to toggle. The JS won't inject the rail there either; this is the no-flash guard - during SPA navigation. */ -html:has(.is-custom) .ls-nav-rail { + during SPA navigation. PERF: class-keyed, not html:has() — the class is set + by head.raw (first paint) and sidebar-toggle.js (SPA navigation). */ +html.ls-page-custom .ls-nav-rail { display: none !important; } From ca65e3e381fe24ed4ce5b981b1c7413d85857886 Mon Sep 17 00:00:00 2001 From: Pat Capulong Date: Fri, 17 Jul 2026 01:50:40 -0700 Subject: [PATCH 2/3] Address Greptile review: restore guarded MutationObserver, fix comment - 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 --- mintlify/sidebar-toggle.js | 23 +++++++++++++++++------ mintlify/style.css | 6 +++++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/mintlify/sidebar-toggle.js b/mintlify/sidebar-toggle.js index 9a414d4f0..9f335971d 100644 --- a/mintlify/sidebar-toggle.js +++ b/mintlify/sidebar-toggle.js @@ -266,11 +266,11 @@ } // SPA navigation: Mintlify swaps content without a full reload. On a path - // change, re-sync. Otherwise schedule a rAF-debounced ensure pass (one per - // mutation burst) that refreshes the page classes and re-adds the rail if - // Mintlify wiped it. The class refresh must run on content mutations too, - // not just path changes, because the new page's DOM (e.g. .is-custom) - // mounts after the pathname flips. + // change, re-sync. Otherwise only schedule the rAF-debounced ensure pass + // when something is actually out of sync: the rail/footer button got wiped, + // or the .is-custom marker (which mounts after the pathname flips) doesn't + // match the ls-page-custom class yet — a cheap guard so we don't read + // layout every frame. var lastPath = location.pathname; var ensureScheduled = false; function scheduleEnsure() { @@ -287,7 +287,18 @@ if (location.pathname !== lastPath) { lastPath = location.pathname; sync(); - } else { + return; + } + var customStale = + !!document.querySelector('.is-custom') !== + document.documentElement.classList.contains('ls-page-custom'); + if ( + customStale || + !rail || + !document.body.contains(rail) || + !footerBtn || + !document.body.contains(footerBtn) + ) { scheduleEnsure(); } }); diff --git a/mintlify/style.css b/mintlify/style.css index 67873c362..86944c2ac 100644 --- a/mintlify/style.css +++ b/mintlify/style.css @@ -4832,7 +4832,11 @@ html.ls-nav-collapsed .ls-nav-rail::after { no docs sidebar — Mintlify tags them with .is-custom — so there's nothing to toggle. The JS won't inject the rail there either; this is the no-flash guard during SPA navigation. PERF: class-keyed, not html:has() — the class is set - by head.raw (first paint) and sidebar-toggle.js (SPA navigation). */ + only by sidebar-toggle.js, from the DOM, once the body exists (unlike the + path-based ls-page-* classes, head.raw can't set it pre-paint). That's + sufficient: on a full load of a custom page the rail is never injected in + the first place (hasVisibleSidebar() is false), so this rule only matters + for SPA navigation, where the JS is already running. */ html.ls-page-custom .ls-nav-rail { display: none !important; } From 248b993b7129dddb4a77a1035feac93ba0770569 Mon Sep 17 00:00:00 2001 From: Pat Capulong Date: Fri, 17 Jul 2026 13:21:19 -0700 Subject: [PATCH 3/3] Neutralize Mintlify's bare-:has() code-block rules (second freeze source) 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 --- mintlify/css-perf-patch.js | 109 +++++++++++++++++++++++++++++++++++++ mintlify/sidebar-toggle.js | 5 +- mintlify/style.css | 17 +++--- 3 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 mintlify/css-perf-patch.js diff --git a/mintlify/css-perf-patch.js b/mintlify/css-perf-patch.js new file mode 100644 index 000000000..07cf16332 --- /dev/null +++ b/mintlify/css-perf-patch.js @@ -0,0 +1,109 @@ +// Rewrites three of Mintlify's code-block CSS rules whose selectors start +// with a bare `:has(` — an unanchored subject that, in Chrome/Blink, +// permanently flags the document root as ":has-affected" the first time a +// matching code block renders. From then on, every forced style recalc in +// the tab walks the entire document (~30x slower), which turns mermaid's +// ~280 getBBox measurements during navigation into a multi-second freeze +// (hover effects dead; scroll survives on the compositor thread). +// +// In Mintlify's DOM the ":has() parent" is always expressible as a preceding +// sibling instead, which Blink matches cheaply. Verified pixel-identical and +// computed-style-identical across all Global Accounts pages, light + dark. +// +// Rules are only touched on an exact selector match, so if Mintlify ships +// different CSS this becomes a no-op — the look can never break; worst case +// the perf fix silently stops applying. +// +// Coverage caveat: custom JS runs after first paint, so a tab whose FIRST +// page is one that renders standalone code blocks (e.g. implementation- +// overview) is poisoned before this runs, and that can't be undone. All +// pages rendered after this script runs (every SPA navigation) are safe. +// The complete fix is Mintlify correcting these selectors upstream — remove +// this file when they do. +(function () { + var MAP = { + ':has([data-floating-buttons]) > [data-component-part="code-block-root"] pre > code': + '[data-floating-buttons] ~ [data-component-part="code-block-root"] pre > code', + ':has([data-component-part="code-block-header"]) > [data-component-part="code-block-root"] pre > code': + '[data-component-part="code-block-header"] ~ [data-component-part="code-block-root"] pre > code', + ':has([data-component-part="code-group-tab-bar"]) [data-component-part="code-block-root"] pre > code': + '[data-component-part="code-group-tab-bar"] ~ [data-component-part="code-block-root"] pre > code, ' + + '[data-component-part="code-group-tab-bar"] ~ * [data-component-part="code-block-root"] pre > code', + }; + + function patchRules(owner) { + var list; + try { + list = owner.cssRules; + } catch (e) { + return; // cross-origin sheet + } + if (!list) return; + for (var i = list.length - 1; i >= 0; i--) { + var rule = list[i]; + if (rule.cssRules && rule.cssRules.length) { + patchRules(rule); // @media / @layer / @supports groups + continue; + } + var sel = rule.selectorText; + if (!sel || sel.indexOf(':has(') === -1) continue; + var parts = sel.split(','); + var out = []; + var hit = false; + for (var j = 0; j < parts.length; j++) { + var trimmed = parts[j].trim(); + if (MAP[trimmed]) { + out.push(MAP[trimmed]); + hit = true; + } else { + out.push(parts[j]); + } + } + if (!hit) continue; + try { + var css = rule.cssText; + var body = css.slice(css.indexOf('{')); + owner.deleteRule(i); + owner.insertRule(out.join(',') + ' ' + body, i); + } catch (e) { + /* leave the rule as-is */ + } + } + } + + function scanAll() { + for (var i = 0; i < document.styleSheets.length; i++) { + patchRules(document.styleSheets[i]); + } + } + + // Scan every frame while the page is still settling (idempotent: once a + // rule is rewritten its selector no longer matches the map)... + var settleAt = 0; + function loop(now) { + scanAll(); + if (document.readyState === 'complete') { + if (!settleAt) settleAt = now; + if (now - settleAt > 2000) return watch(); + } + requestAnimationFrame(loop); + } + + // ...then only re-scan when new stylesheets appear (SPA navigations). + function watch() { + new MutationObserver(function (muts) { + for (var m = 0; m < muts.length; m++) { + var added = muts[m].addedNodes; + for (var n = 0; n < added.length; n++) { + var tag = added[n].tagName; + if (tag === 'STYLE' || tag === 'LINK') { + scanAll(); + return; + } + } + } + }).observe(document.documentElement, { childList: true, subtree: true }); + } + + loop(performance.now()); +})(); diff --git a/mintlify/sidebar-toggle.js b/mintlify/sidebar-toggle.js index 9f335971d..28a014596 100644 --- a/mintlify/sidebar-toggle.js +++ b/mintlify/sidebar-toggle.js @@ -230,8 +230,9 @@ // selectors in style.css / head.raw. A :has() anchored at the root gets // re-evaluated on DOM mutations anywhere in the document, which froze // navigation for seconds on pages that build DOM incrementally (mermaid - // diagrams). head.raw sets these classes pre-paint on full loads; this - // keeps them in sync across SPA navigations. + // diagrams). This is the effective setter on both full loads and SPA + // navigations (head.raw carries a pre-paint copy, but the hosted renderer + // currently strips head.raw scripts). function updatePageClasses() { var root = document.documentElement; var path = location.pathname.replace(/\/+$/, '') || '/'; diff --git a/mintlify/style.css b/mintlify/style.css index 86944c2ac..191d93b83 100644 --- a/mintlify/style.css +++ b/mintlify/style.css @@ -4099,11 +4099,15 @@ html.dark #flow-builder-container { /* Prevent outer page scroll when flow-builder is present. PERF: these page-scoped rules key off html.ls-page-flow-builder — a class - set from the pathname by head.raw (first paint) and sidebar-toggle.js (SPA - navigation) — NOT html:has(#flow-builder-container). A :has() anchored at - the root is re-evaluated on DOM mutations anywhere in the document; pages - that build DOM incrementally (e.g. mermaid diagrams) made each of those - re-evaluations fire thousands of times, freezing navigation for seconds. */ + set from the pathname by sidebar-toggle.js (on load and on SPA navigation) + — NOT html:has(#flow-builder-container). A root-anchored :has() marks the + document root ":has-affected" in Blink, after which every forced style + recalc walks the whole document; pages that build DOM incrementally (e.g. + mermaid diagrams) then freeze navigation for seconds. (head.raw also + carries a pre-paint class setter, but the hosted renderer currently strips + head.raw scripts, so the JS is the effective mechanism; the sub-second + window before it runs on a full load of these two embed pages is not + noticeable.) */ html.ls-page-flow-builder, html.ls-page-flow-builder body { overflow: hidden !important; @@ -4832,8 +4836,7 @@ html.ls-nav-collapsed .ls-nav-rail::after { no docs sidebar — Mintlify tags them with .is-custom — so there's nothing to toggle. The JS won't inject the rail there either; this is the no-flash guard during SPA navigation. PERF: class-keyed, not html:has() — the class is set - only by sidebar-toggle.js, from the DOM, once the body exists (unlike the - path-based ls-page-* classes, head.raw can't set it pre-paint). That's + only by sidebar-toggle.js, from the DOM, once the body exists. That's sufficient: on a full load of a custom page the rail is never injected in the first place (hasVisibleSidebar() is false), so this rule only matters for SPA navigation, where the JS is already running. */