From b804ae91265fd816919594290ee0ebe21ba206a0 Mon Sep 17 00:00:00 2001 From: Pat Capulong Date: Fri, 17 Jul 2026 01:33:34 -0700 Subject: [PATCH 1/5] 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/5] 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/5] 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. */ From 3e35deb7d5cc5a15547a79bc22a85de851c3d82d Mon Sep 17 00:00:00 2001 From: Pat Capulong Date: Fri, 17 Jul 2026 16:49:52 -0700 Subject: [PATCH 4/5] Add cure mode for tabs poisoned before the patch can run 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 --- mintlify/css-perf-patch.js | 247 ++++++++++++++++++++++++++++++------- 1 file changed, 203 insertions(+), 44 deletions(-) diff --git a/mintlify/css-perf-patch.js b/mintlify/css-perf-patch.js index 07cf16332..ce1ad79bd 100644 --- a/mintlify/css-perf-patch.js +++ b/mintlify/css-perf-patch.js @@ -1,25 +1,43 @@ -// 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). +// Neutralizes a Chrome/Blink pathology triggered by Mintlify's `:has()` CSS. // -// 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. +// THE BUG: three of Mintlify's code-block rules have selectors that start +// with a bare `:has(` (unanchored subject). The first time a matching +// standalone code block renders, Blink permanently marks the document root +// ":has-affected". While such marks exist AND `:has()` rules are present in +// active stylesheets, every forced style recalc walks the entire document +// (~30x slower: ~0.1ms -> ~20ms per recalc). Mermaid's ~280 getBBox +// measurements during navigation then freeze the page for many seconds +// (hovers dead; scroll survives on the compositor thread). // -// 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. +// Verified properties (see PR #697 for the experiments): +// - the marks survive rule removal, content replacement, and forced +// recalcs — they last for the life of the tab; +// - but they only COST while `:has()` rules are present: with every +// :has rule removed, recalcs drop back to ~0.1ms instantly; +// - partial removal does not help — any remaining :has rule keeps the +// expensive path armed. // -// 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. +// TWO DEFENSES, both in this file: +// +// 1. PREVENTION (always on): rewrite the three bare-:has selectors to +// preceding-sibling equivalents the moment stylesheets appear. In +// Mintlify's DOM the ":has() parent" is always a preceding sibling, so +// this is visually identical (verified pixel-identical and computed- +// style-identical across all Global Accounts pages, light + dark). +// Exact-match only: if Mintlify ships different CSS this is a no-op. +// +// 2. CURE (poisoned tabs only): custom JS runs after first paint, so a tab +// whose FIRST page renders a matching code block is marked before we can +// prevent it. For those tabs we detect the poisoned state (one cheap +// probe) and, around each SPA navigation, temporarily stash-and-delete +// ALL :has rules (fighting mid-navigation stylesheet re-injection), then +// restore them in place — same owner sheet, same index, so cascade and +// @layer order are preserved. Cosmetic :has styling (sidebar chevron +// hiding, code-block corner effects) is simplified for the few seconds +// of the transition on those rare tabs; steady-state look is untouched. +// +// The complete fix is Mintlify correcting the three selectors upstream — +// delete this file when they do. (function () { var MAP = { ':has([data-floating-buttons]) > [data-component-part="code-block-root"] pre > code': @@ -31,6 +49,26 @@ '[data-component-part="code-group-tab-bar"] ~ * [data-component-part="code-block-root"] pre > code', }; + // Rewrites a selector list via MAP. Returns null when nothing matched. + function mapSelectorList(sel) { + if (!sel || sel.indexOf(':has(') === -1) return null; + 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]); + } + } + return hit ? out.join(',') : null; + } + + /* ------------------------- defense 1: prevention ------------------------ */ + function patchRules(owner) { var list; try { @@ -41,55 +79,172 @@ if (!list) return; for (var i = list.length - 1; i >= 0; i--) { var rule = list[i]; - if (rule.cssRules && rule.cssRules.length) { + if (!rule.selectorText && 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; + var mapped = mapSelectorList(rule.selectorText); + if (!mapped) continue; try { var css = rule.cssText; var body = css.slice(css.indexOf('{')); owner.deleteRule(i); - owner.insertRule(out.join(',') + ' ' + body, i); + owner.insertRule(mapped + ' ' + body, i); } catch (e) { /* leave the rule as-is */ } } } - function scanAll() { + /* --------------------------- defense 2: cure ---------------------------- */ + + var poisoned = false; + var curing = false; + var stash = []; // { owner, index, cssText } in deletion order (desc index per owner) + var restoreTimer = 0; + + function probeCost() { + var root = document.documentElement; + void root.offsetHeight; + var d = document.createElement('div'); + document.body.appendChild(d); + var t0 = performance.now(); + void root.offsetHeight; + var cost = performance.now() - t0; + d.remove(); + void root.offsetHeight; + return cost; + } + + function detectPoison() { + if (!document.body) return; + try { + var runs = [probeCost(), probeCost(), probeCost()].sort(function (a, b) { return a - b; }); + poisoned = runs[1] > 4; // healthy ~0.1-1ms, poisoned ~9-20ms + } catch (e) { + poisoned = false; + } + } + + function stashDeleteIn(owner) { + var list; + try { + list = owner.cssRules; + } catch (e) { + return; + } + if (!list) return; + for (var i = list.length - 1; i >= 0; i--) { + var rule = list[i]; + var sel = rule.selectorText; + if (sel && sel.indexOf(':has(') !== -1) { + try { + stash.push({ owner: owner, index: i, cssText: rule.cssText }); + owner.deleteRule(i); + continue; + } catch (e) { + /* keep rule */ + } + } + if (rule.cssRules && rule.cssRules.length) stashDeleteIn(rule); + } + } + + function stashDeleteAll() { for (var i = 0; i < document.styleSheets.length; i++) { - patchRules(document.styleSheets[i]); + stashDeleteIn(document.styleSheets[i]); + } + } + + function restoreStash() { + // Reinsert in reverse deletion order: per owner that yields ascending + // indexes, so each recorded index is valid again at insertion time. + for (var i = stash.length - 1; i >= 0; i--) { + var entry = stash[i]; + var cssText = entry.cssText; + // Never restore a bare-:has original — apply the prevention rewrite. + var brace = cssText.indexOf('{'); + var mapped = mapSelectorList(cssText.slice(0, brace).trim()); + if (mapped) cssText = mapped + ' ' + cssText.slice(brace); + try { + var max = entry.owner.cssRules ? entry.owner.cssRules.length : 0; + entry.owner.insertRule(cssText, Math.min(entry.index, max)); + } catch (e) { + /* owner sheet gone or rule now invalid — skip */ + } } + stash = []; + curing = false; + } + + function startCure() { + if (!poisoned || curing) return; + curing = true; + stashDeleteAll(); + clearTimeout(restoreTimer); + restoreTimer = setTimeout(restoreStash, 4000); } - // Scan every frame while the page is still settling (idempotent: once a - // rule is rewritten its selector no longer matches the map)... + function extendCure() { + // another navigation while a window is open: re-sweep + push restore out + stashDeleteAll(); + clearTimeout(restoreTimer); + restoreTimer = setTimeout(restoreStash, 4000); + } + + function onNavigate() { + if (!poisoned) return; + if (curing) extendCure(); + else startCure(); + } + + // Navigation triggers: link clicks (earliest), history traversal, and a + // pathname watcher as fallback for programmatic navigation. + document.addEventListener( + 'click', + function (e) { + var a = e.target && e.target.closest && e.target.closest('a[href]'); + if (!a) return; + var href = a.getAttribute('href') || ''; + if (href.charAt(0) === '/' && href.indexOf('//') !== 0) onNavigate(); + }, + true + ); + window.addEventListener('popstate', onNavigate); + var lastPath = location.pathname; + + /* ------------------------------ scheduling ------------------------------ */ + + function sweep() { + if (curing) { + stashDeleteAll(); // mid-navigation sheet re-injection brings rules back + } else { + for (var i = 0; i < document.styleSheets.length; i++) { + patchRules(document.styleSheets[i]); + } + } + if (location.pathname !== lastPath) { + lastPath = location.pathname; + onNavigate(); + } + } + + // Sweep every frame while the page settles (idempotent), then only when + // new stylesheets appear (SPA navigations). Poison detection runs once, + // after the page has settled. var settleAt = 0; function loop(now) { - scanAll(); + sweep(); if (document.readyState === 'complete') { if (!settleAt) settleAt = now; - if (now - settleAt > 2000) return watch(); + if (now - settleAt > 2000) { + detectPoison(); + 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++) { @@ -97,11 +252,15 @@ for (var n = 0; n < added.length; n++) { var tag = added[n].tagName; if (tag === 'STYLE' || tag === 'LINK') { - scanAll(); + sweep(); return; } } } + if (location.pathname !== lastPath) { + lastPath = location.pathname; + onNavigate(); + } }).observe(document.documentElement, { childList: true, subtree: true }); } From 8020efaadb68e9ebd8a8ef5c5258123ee68c7aab Mon Sep 17 00:00:00 2001 From: Pat Capulong Date: Fri, 17 Jul 2026 20:49:05 -0700 Subject: [PATCH 5/5] Fix cure-mode race: detect poison at navigation time if needed 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 --- mintlify/css-perf-patch.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mintlify/css-perf-patch.js b/mintlify/css-perf-patch.js index ce1ad79bd..a85f2dc8b 100644 --- a/mintlify/css-perf-patch.js +++ b/mintlify/css-perf-patch.js @@ -99,6 +99,7 @@ /* --------------------------- defense 2: cure ---------------------------- */ var poisoned = false; + var poisonKnown = false; // verdict is only trustworthy once the page has rendered var curing = false; var stash = []; // { owner, index, cssText } in deletion order (desc index per owner) var restoreTimer = 0; @@ -121,6 +122,7 @@ try { var runs = [probeCost(), probeCost(), probeCost()].sort(function (a, b) { return a - b; }); poisoned = runs[1] > 4; // healthy ~0.1-1ms, poisoned ~9-20ms + poisonKnown = true; } catch (e) { poisoned = false; } @@ -193,6 +195,12 @@ } function onNavigate() { + // The scheduled detection waits for the page to settle, but a user can + // navigate before that (land on a poisoning page, click within ~2s). + // By navigation time the landing page has rendered, so a probe taken + // right now is trustworthy — spend ~100ms to check rather than risk a + // multi-second freeze. + if (!poisonKnown) detectPoison(); if (!poisoned) return; if (curing) extendCure(); else startCure();