diff --git a/mintlify/css-perf-patch.js b/mintlify/css-perf-patch.js new file mode 100644 index 00000000..07cf1633 --- /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/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; }