diff --git a/mintlify/css-perf-patch.js b/mintlify/css-perf-patch.js new file mode 100644 index 00000000..a85f2dc8 --- /dev/null +++ b/mintlify/css-perf-patch.js @@ -0,0 +1,276 @@ +// Neutralizes a Chrome/Blink pathology triggered by Mintlify's `:has()` CSS. +// +// 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). +// +// 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. +// +// 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': + '[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', + }; + + // 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 { + 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.selectorText && rule.cssRules && rule.cssRules.length) { + patchRules(rule); // @media / @layer / @supports groups + 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(mapped + ' ' + body, i); + } catch (e) { + /* leave the rule as-is */ + } + } + } + + /* --------------------------- 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; + + 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 + poisonKnown = true; + } 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++) { + 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); + } + + function extendCure() { + // another navigation while a window is open: re-sweep + push restore out + stashDeleteAll(); + clearTimeout(restoreTimer); + restoreTimer = setTimeout(restoreStash, 4000); + } + + 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(); + } + + // 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) { + sweep(); + if (document.readyState === 'complete') { + if (!settleAt) settleAt = now; + if (now - settleAt > 2000) { + detectPoison(); + return watch(); + } + } + requestAnimationFrame(loop); + } + + 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') { + sweep(); + return; + } + } + } + if (location.pathname !== lastPath) { + lastPath = location.pathname; + onNavigate(); + } + }).observe(document.documentElement, { childList: true, subtree: true }); + } + + loop(performance.now()); +})(); diff --git a/mintlify/docs.json b/mintlify/docs.json index e7296c73..41d5b8ec 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 34244460..28a01459 100644 --- a/mintlify/sidebar-toggle.js +++ b/mintlify/sidebar-toggle.js @@ -226,8 +226,26 @@ }); } + // 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). 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(/\/+$/, '') || '/'; + 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 +267,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 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() { @@ -260,6 +279,7 @@ ensureScheduled = true; requestAnimationFrame(function () { ensureScheduled = false; + updatePageClasses(); ensureRail(); ensureFooterBtn(); }); @@ -268,7 +288,13 @@ if (location.pathname !== lastPath) { lastPath = location.pathname; sync(); - } else if ( + return; + } + var customStale = + !!document.querySelector('.is-custom') !== + document.documentElement.classList.contains('ls-page-custom'); + if ( + customStale || !rail || !document.body.contains(rail) || !footerBtn || diff --git a/mintlify/style.css b/mintlify/style.css index e33a33c0..191d93b8 100644 --- a/mintlify/style.css +++ b/mintlify/style.css @@ -4096,18 +4096,29 @@ 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 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; } /* 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 +4127,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 +4263,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 +4285,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 +4835,12 @@ 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 + 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. */ +html.ls-page-custom .ls-nav-rail { display: none !important; }