Studio: scope two :has() rules to direct children so they stop walking the whole thread - #9669
Conversation
Scrolling a long chat drops to a few frames a second, and the cost is two CSS
selectors.
A `:has()` whose argument is a DESCENDANT selector has to be re-evaluated
whenever anything is inserted or removed anywhere inside its subject. Two of
them sit on ancestors of every message: the SidebarProvider wrapper, which wraps
the whole application, and the chat wrapper that declares
--studio-chat-notice-height. So every message that mounts, every token that
streams and every deferred code fence that upgrades restyles the entire thread.
Measured at the 500K rung, corpus 23cd2464, on a 357,843-element thread, as the
cost of appending ONE EMPTY span inside a message:
every rule in place 17.5 / 18.6 ms
the sidebar wrapper's rule alone deleted 8.7 / 9.0 ms
the chat wrapper's rule alone deleted 9.8 / 9.3 ms
both deleted 0.10 / 0.10 ms
the other eleven :has() rules the bisect kept, deleted 17.2 / 19.2 ms
all 142 :has() rules in the bundle deleted 0.10 / 0.10 ms
the same span appended to <body> instead 0.10 / 0.10 ms
Both selectors become their child form. That is not a weakening: `data-variant`
is on the root element of Sidebar and `data-chat-model-notice` is on the root
element of ChatModelNotice, and both are direct children of the elements
carrying the rule, so the same elements match. What changes is that a mutation
deep in the thread can no longer make the engine ask the question again.
Scroll at the 500K rung, four arms per bundle, run concurrently with the ports
rotated between waves so a per-port effect cannot masquerade as the arm.
Identical DOM in all eight (357,923 elements, 335 code blocks, 247,675 highlight
spans) and an identical gesture (632,088 px commanded and 632,088 px travelled,
704 steps):
before 28.2 / 28.2 / 30.9 / 28.2 fps at 82.2 - 83.9% busy, traversal
46.7 - 51.3 s, step p95 83 - 100 ms
after 35.4 / 34.4 / 39.3 / 39.3 fps at 77.6 - 82.0% busy, traversal
36.7 - 42.0 s, step p95 50 - 67 ms
The arms do not overlap: the worst `after` beats the best `before`.
Short context does not regress. At 0K, 59.4 fps at 1.4% busy before and 59.3 at
1.5% after; at 100K, 57.5 at 42.6% before and 58.0 at 37.9% after, on identical
DOM at both rungs.
Nothing renders differently. This hides nothing, defers nothing and clips
nothing, so find-in-page, select-all, copy, scroll anchoring and scrollbar
geometry are untouched by construction, and the thread's element, code-block and
highlight-span counts are identical in every arm.
`tests/thread-ancestor-has-scope.test.ts` is the guard. It asserts both
selectors keep their child form, and, because a child combinator is only
equivalent while the target really is a child, that ChatModelNotice renders a
direct child of the declaring element and that the attribute is on the
component's root. Each of the four checks was confirmed to fail on its own
deliberate breaker.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b07f909679
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| test("the sidebar wrapper, an ancestor of the whole app, has no descendant-argument :has()", () => { | ||
| const source = parse("../src/components/ui/sidebar.tsx"); | ||
| const wrappers = stringLiterals(source).filter((s) => s.includes("group/sidebar-wrapper")); |
There was a problem hiding this comment.
Aggregate all class literals before checking the wrapper
When either wrapper's className is refactored into multiple cn(...) arguments—for example, keeping group/sidebar-wrapper in one literal while adding condition && "has-[[data-x]]:..." in another—this filter retains only the marker-bearing literal, so descendantHasUtilities never examines the newly added selector. The chat-wrapper check repeats the same narrowing at lines 108–110, meaning the regression guard can pass while exactly the expensive descendant :has() it claims to prevent is restored; aggregate all literals belonging to the protected element's className before checking them.
Useful? React with 👍 / 👎.
| const walk = (node: ts.Node): void => { | ||
| if (rootHasAttribute) return; | ||
| if (ts.isReturnStatement(node) && node.expression) { |
There was a problem hiding this comment.
Restrict the root-attribute scan to ChatModelNotice
Because this walk examines every return in the entire module rather than first locating the ChatModelNotice function, any unrelated or extracted helper that returns an element carrying data-chat-model-notice makes the assertion pass. For example, wrapping ChatModelNotice in an unmarked <section> while moving the old marked <div> into a nested helper still satisfies this test even though the new child selector no longer matches and the notice height is not reserved; scope the return scan to the component declaration.
Useful? React with 👍 / 👎.
…m only Two corrections to the comments this branch added. The code and the built bundle are unchanged: the dist rebuilt from this commit hashes 7ecb601d05493238, byte-identical to the one every number in the first commit was measured on. FIRST, IT IS A TRAVERSAL AND NOT A RESTYLE. I wrote that re-checking a descendant-argument `:has()` on an ancestor "restyles the thread". Blink's own `UpdateLayoutTree.elementCount` says otherwise: for one inserted span it is 1 with both rules in their child form, 2 with one still in descendant form, and 3 with both. Only the subjects are restyled. What costs is walking the subtree to answer the question, which is O(subtree) in time and O(1) in restyled elements. That distinction is load-bearing rather than pedantic, because it explains two results that would otherwise look like contradictions. Containment cannot help, since there is no scope left to reduce, which is exactly what the note in index.css near `content-visibility: visible` recorded when it said containment on the message roots was no help. And `content-visibility: auto` on the message roots does not help either, measured at -7% on the same insertion: the argument re-check walks skipped content too. It also retires the inheritance angle. I linked this to #9328 as "the same shape" because the chat wrapper's rule declares an unregistered inheriting custom property. Measured, the same rule declaring a non-inherited `background` costs the same, so the combinator carries the cost and the property does not. Related by family, not by mechanism. SECOND, IT IS CHROMIUM ONLY, AND THAT BOUNDS THE CLAIM. On a synthetic thread carrying Studio's real ancestor chain and its built stylesheet, at 300,464 elements, one inserted span costs, for plain / child / one descendant rule / both: Chromium 1.20 / 1.29 / 5.63 / 10.30 ms WebKitGTK 4.33 / 4.58 / 4.58 / 4.33 ms Firefox 4.65 / 4.72 / 4.45 / 5.10 ms WebKitGTK and Firefox are flat across all four forms. Confirmed end to end on the real app in real WebKitGTK 2.50.4 at the 500K rung, three arms per bundle, identical gesture at 613,800 px travelled: before 14.68 / 14.18 / 14.25 fps, wall 95.6 / 98.7 / 98.4 s after 14.36 / 14.21 / 14.51 fps, wall 97.3 / 98.7 / 96.7 s The arms overlap completely. So this does nothing for Studio on Linux and Desktop, which render through WebKitGTK, and it does not regress them either. The Chromium win stands for Studio in a browser. The WebKitGTK collapse is a different mechanism and is still open.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 20a98fd543
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .split(/\s+/) | ||
| .filter((token) => { | ||
| const variant = token.startsWith("group-has-") ? token.slice("group-".length) : token; | ||
| if (!variant.startsWith("has-")) return false; |
There was a problem hiding this comment.
Strip stacked variants before classifying :has()
When a descendant :has() is combined with another Tailwind variant, such as dark:has-[[data-x]]:... or md:has-data-[x]:..., the token no longer starts with has-, so this branch treats it as harmless and both wrapper tests pass. These prefixes are already used with :has() elsewhere in the frontend, making this a likely way to reintroduce the expensive selector the guard is intended to reject; parse or strip stacked variants before checking the has-* segment.
Useful? React with 👍 / 👎.
…loading on it The permission step asserted a level it had only commanded, never one it had achieved. choose() drives this tab; the mirror to /api/chat/settings is a 400ms trailing-edge debounce whose only early flush is the beforeunload keepalive. Timed on webkit from the choose() call: choose returns at t+238ms, set_legacy_confirm at t+248ms, and the reload starts there. The debounce would not have fired until ~t+630ms, so the only PUT that goes out at all is the unload keepalive at t+252ms, against a hydrating GET that lands at t+697ms. The assertion after the reload was never testing the level the step had just chosen. It was betting that the keepalive wins that 445ms of loopback on every engine, every run. When the bet loses, hydration returns the level the migration loop left on the install, off, so the pill reads Run automatically and the step reports a migration bug that is not there. That is what it did on webkit while chromium and firefox passed the same assertion in the same job. So poll GET /api/chat/settings until the installation actually holds the level, then reload. The hydration-precedence contract this step exists to check is unchanged and is now reachable rather than conditional on a teardown race. Verified locally on webkit against a real Studio. With the persisting PUT dropped, the old code fails after the reload with actual value Run automatically, reproducing the CI failure exactly, and the new check fails first, naming the cause. Unmutated, the lane passes on webkit and chromium. This deliberately stops leaning on the unload-time flush. That flush deserves a deterministic test of its own; it was never what this step meant to assert.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 87e6865420
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Bound each settings poll request
When /api/chat/settings accepts a connection but stalls before completing the response, this page.evaluate() waits indefinitely for fetch()/res.json() because Playwright's evaluate call has no timeout here. The surrounding 15-second deadline is checked only after that promise resolves, so a transient backend hang can pin the cross-browser smoke test until its global watchdog rather than producing the intended bounded failure; add an AbortController or otherwise time-limit each request.
Useful? React with 👍 / 👎.
…g the whole thread (#9669) * Studio: stop two :has() rules restyling the whole thread on every change Scrolling a long chat drops to a few frames a second, and the cost is two CSS selectors. A `:has()` whose argument is a DESCENDANT selector has to be re-evaluated whenever anything is inserted or removed anywhere inside its subject. Two of them sit on ancestors of every message: the SidebarProvider wrapper, which wraps the whole application, and the chat wrapper that declares --studio-chat-notice-height. So every message that mounts, every token that streams and every deferred code fence that upgrades restyles the entire thread. Measured at the 500K rung, corpus 23cd2464, on a 357,843-element thread, as the cost of appending ONE EMPTY span inside a message: every rule in place 17.5 / 18.6 ms the sidebar wrapper's rule alone deleted 8.7 / 9.0 ms the chat wrapper's rule alone deleted 9.8 / 9.3 ms both deleted 0.10 / 0.10 ms the other eleven :has() rules the bisect kept, deleted 17.2 / 19.2 ms all 142 :has() rules in the bundle deleted 0.10 / 0.10 ms the same span appended to <body> instead 0.10 / 0.10 ms Both selectors become their child form. That is not a weakening: `data-variant` is on the root element of Sidebar and `data-chat-model-notice` is on the root element of ChatModelNotice, and both are direct children of the elements carrying the rule, so the same elements match. What changes is that a mutation deep in the thread can no longer make the engine ask the question again. Scroll at the 500K rung, four arms per bundle, run concurrently with the ports rotated between waves so a per-port effect cannot masquerade as the arm. Identical DOM in all eight (357,923 elements, 335 code blocks, 247,675 highlight spans) and an identical gesture (632,088 px commanded and 632,088 px travelled, 704 steps): before 28.2 / 28.2 / 30.9 / 28.2 fps at 82.2 - 83.9% busy, traversal 46.7 - 51.3 s, step p95 83 - 100 ms after 35.4 / 34.4 / 39.3 / 39.3 fps at 77.6 - 82.0% busy, traversal 36.7 - 42.0 s, step p95 50 - 67 ms The arms do not overlap: the worst `after` beats the best `before`. Short context does not regress. At 0K, 59.4 fps at 1.4% busy before and 59.3 at 1.5% after; at 100K, 57.5 at 42.6% before and 58.0 at 37.9% after, on identical DOM at both rungs. Nothing renders differently. This hides nothing, defers nothing and clips nothing, so find-in-page, select-all, copy, scroll anchoring and scrollbar geometry are untouched by construction, and the thread's element, code-block and highlight-span counts are identical in every arm. `tests/thread-ancestor-has-scope.test.ts` is the guard. It asserts both selectors keep their child form, and, because a child combinator is only equivalent while the target really is a child, that ChatModelNotice renders a direct child of the declaring element and that the attribute is on the component's root. Each of the four checks was confirmed to fail on its own deliberate breaker. * Say what the engine actually reports, and record that this is Chromium only Two corrections to the comments this branch added. The code and the built bundle are unchanged: the dist rebuilt from this commit hashes 7ecb601d05493238, byte-identical to the one every number in the first commit was measured on. FIRST, IT IS A TRAVERSAL AND NOT A RESTYLE. I wrote that re-checking a descendant-argument `:has()` on an ancestor "restyles the thread". Blink's own `UpdateLayoutTree.elementCount` says otherwise: for one inserted span it is 1 with both rules in their child form, 2 with one still in descendant form, and 3 with both. Only the subjects are restyled. What costs is walking the subtree to answer the question, which is O(subtree) in time and O(1) in restyled elements. That distinction is load-bearing rather than pedantic, because it explains two results that would otherwise look like contradictions. Containment cannot help, since there is no scope left to reduce, which is exactly what the note in index.css near `content-visibility: visible` recorded when it said containment on the message roots was no help. And `content-visibility: auto` on the message roots does not help either, measured at -7% on the same insertion: the argument re-check walks skipped content too. It also retires the inheritance angle. I linked this to #9328 as "the same shape" because the chat wrapper's rule declares an unregistered inheriting custom property. Measured, the same rule declaring a non-inherited `background` costs the same, so the combinator carries the cost and the property does not. Related by family, not by mechanism. SECOND, IT IS CHROMIUM ONLY, AND THAT BOUNDS THE CLAIM. On a synthetic thread carrying Studio's real ancestor chain and its built stylesheet, at 300,464 elements, one inserted span costs, for plain / child / one descendant rule / both: Chromium 1.20 / 1.29 / 5.63 / 10.30 ms WebKitGTK 4.33 / 4.58 / 4.58 / 4.33 ms Firefox 4.65 / 4.72 / 4.45 / 5.10 ms WebKitGTK and Firefox are flat across all four forms. Confirmed end to end on the real app in real WebKitGTK 2.50.4 at the 500K rung, three arms per bundle, identical gesture at 613,800 px travelled: before 14.68 / 14.18 / 14.25 fps, wall 95.6 / 98.7 / 98.4 s after 14.36 / 14.21 / 14.51 fps, wall 97.3 / 98.7 / 96.7 s The arms overlap completely. So this does nothing for Studio on Linux and Desktop, which render through WebKitGTK, and it does not regress them either. The Chromium win stands for Studio in a browser. The WebKitGTK collapse is a different mechanism and is still open. * Chat UI: wait for the permission level to reach the install before reloading on it The permission step asserted a level it had only commanded, never one it had achieved. choose() drives this tab; the mirror to /api/chat/settings is a 400ms trailing-edge debounce whose only early flush is the beforeunload keepalive. Timed on webkit from the choose() call: choose returns at t+238ms, set_legacy_confirm at t+248ms, and the reload starts there. The debounce would not have fired until ~t+630ms, so the only PUT that goes out at all is the unload keepalive at t+252ms, against a hydrating GET that lands at t+697ms. The assertion after the reload was never testing the level the step had just chosen. It was betting that the keepalive wins that 445ms of loopback on every engine, every run. When the bet loses, hydration returns the level the migration loop left on the install, off, so the pill reads Run automatically and the step reports a migration bug that is not there. That is what it did on webkit while chromium and firefox passed the same assertion in the same job. So poll GET /api/chat/settings until the installation actually holds the level, then reload. The hydration-precedence contract this step exists to check is unchanged and is now reachable rather than conditional on a teardown race. Verified locally on webkit against a real Studio. With the persisting PUT dropped, the old code fails after the reload with actual value Run automatically, reproducing the CI failure exactly, and the new check fails first, naming the cause. Unmutated, the lane passes on webkit and chromium. This deliberately stops leaning on the unload-time flush. That flush deserves a deterministic test of its own; it was never what this step meant to assert.
katex.css resets `katexEqnNo` on `body` and increments it in `.katex .eqn-num::before`. Style containment, which `content-visibility: auto` applies unconditionally, scopes that increment to each contained display, so numbered `equation` and `align` environments stop counting up. Measured on Chromium, three numbered displays: flag off (1) (2) (3) flag on (1) (1) (1) 173 differing pixels over the equation strip. The same fixture on WebKitGTK 2.50.4 does NOT reproduce it: 0 differing pixels, against 120 for the list-marker case photographed in the same frame, so the probe could see counter breakage and that engine simply does not do it. This is engine dependent and Chromium is the engine most of the web UI runs in. The stylesheet now names `.aui-math-display`, a class the renderer adds AFTER KaTeX has run and only to displays with no equation number. `guardEquationNumbers` also takes the block class back off any block that turns out to contain a numbered display, because `markMathBlocks` runs BEFORE KaTeX, when a display is still `<pre><code class="language-math">` and no `.eqn-num` exists to see; a blockquote holding both inline maths and a numbered display would otherwise scope the counter from above just as effectively. A `:has()` rule would express the exemption in CSS alone and is exactly what must not be used: two `:has()` rules were the measured owner of the whole 500K scroll cost on Chromium, fixed in #9669. So the renderer decides and the stylesheet names a plain class. The wiring test now asserts that neither gated rule uses `:has()`. Verified on Chromium with the fix in place: the numbered displays compute `content-visibility: visible`, the unnumbered one computes `auto`, and the frame is bit-identical to the flag-off leg. npm test: 5177 passed, 0 failed. tsc --noEmit clean.
* Studio: let maths-bearing blocks take containment, behind a flag that ships off On WebKitGTK, which is what Studio and Desktop render through on Linux, moving a scroller runs `RenderLayer::recursiveUpdateLayerPositionsAfterScroll` over every descendant RenderLayer once per scroll event, with no dirty-bit pruning and one early out. `position != static` buys a layer, and KaTeX sets it on `.katex`, `.base`, `.vlist`, `.katex-mathml` and `.vlist > span`, so a 500K-character maths-bearing thread carries about 20,700 layers under maths alone and costs 287.6 ms of blocked main thread to move one pixel. `content-visibility: auto` reaches that early out for content the user cannot see, but it needs size containment, and size containment does not apply to a non-atomic inline-level box. Inline maths is `display: inline`, so a rule naming `.katex` is inert on it, measured at 0 of 146 sampled inline roots, and a rule naming only `.katex-display` reaches about a quarter of the positioned boxes. So the declaration is hoisted to the block that holds the maths. The chat renderer marks the nearest containable ancestor of every inline maths root with `aui-math-block`, and `index.css` names that class alongside `.katex-display`. Display maths needs no marker because `.katex-display` is already block level. The marker is composed onto the maths plugin's own rehype pass rather than passed as a plugin prop: a `rehypePlugins` prop would make Streamdown skip the `allowedTags` sanitizer schema, and marking on the mdast side lands the class before `rehype-sanitize`, which permits `className` on a paragraph nowhere in its default schema and would strip it silently. The class is emitted whether or not the feature is on, so a measurement can compare the two states without the two states holding different DOM. The declaration itself is gated on `html[data-math-block-containment="on"]`, which `main.tsx` sets only when `resolveMathBlockMode` says so, and the ship default is off. `content-visibility: auto` turns on paint containment unconditionally, so KaTeX's absolutely positioned vlist children can round to a different device pixel while the block is on screen; that is why this is configuration rather than a default. * Studio: run the maths-block marker through the real markdown pipeline in a test The existing marker test feeds the transform hast trees it builds itself, which cannot catch the failure this change was one refactor away from shipping: the class landing where `rehype-sanitize` strips it. Its `defaultSchema` permits `className` on `a`, `code`, `h2`, `li`, `ol`, `section` and `ul` and nowhere else, so a class put on a paragraph before the sanitize pass disappears without a word and the change measures as doing nothing. The new test runs parse, gfm, remark-math, remark-rehype, raw and sanitize, and only then the maths plugin's rehype pass with the marker composed in front of it, then asserts on the tree that comes out. Three of its fixtures were rebuilt after a mutation sweep showed them to be non-load-bearing. A tight list is unwrapped by mdast-util-to-hast, so its item holds the maths directly and the hoist past an inline paragraph never runs; the fixture is now a loose list. A table and a display formula at the root of a document have no containable ancestor above them, so a marker that wrongly hoisted past a table cell, or wrongly treated display maths as inline, would still mark nothing and the assertion would still hold; both fixtures are now inside a blockquote, and each asserts that ancestor is present before asserting the behaviour. The startup check now looks for the call on a line of its own rather than as a substring, because commenting the call out left the substring in place. * Studio: give the two contained populations their own placeholder heights Measured on the 500K corpus, over the whole population rather than a sample: a marked block has a mean rendered height of 138.04px and a display formula 49.13px. They are not the same kind of thing. A marked block is a PARAGRAPH of prose that happens to contain a formula, and a display formula is one line of maths. One shared 7.5rem was 18px short on every paragraph and 71px too tall on every formula. Those errors do not cancel under a scroller, they queue up, and a real traversal of a 500K thread measured the difference: 3,995px of scrollbar excursion over 470 rescales, and up to 3,142px of position drift for a target element over one round trip, against 230px and 158.7px for the same traversal with the rule off. Two values cut the per-block error to about 2px and 1px. The transient is paid once either way. `contain-intrinsic-size: auto` makes the engine remember each block's real size after it has been rendered, so the fallback only ever applies to a block the user has not reached yet, and a second traversal of the same thread measured zero excursion and zero drift with the rule on. But the first traversal is the one a user actually experiences on a long thread, so the placeholder being close is worth the extra rule. * Studio: three fixes from review, each measured before it was written LIST ITEMS NO LONGER TAKE CONTAINMENT. `content-visibility: auto` applies style containment, style containment scopes the automatic `list-item` counter, and a contained item can then no longer resolve `counter(list-item)` for its own `::marker`. Photographed on WebKitGTK 2.50.4 on a five item list where only items 2 and 4 carried the class, the marker column differed by 61 and 59 pixels at full channel swing on exactly those two and by 0 on the other three: the number does not repeat, it disappears, and the list reads 1, nothing, 3, nothing, 5. The item was being marked deliberately, since Streamdown gives its paragraph `[&>p]:inline` and an inline box cannot take size containment, which left the `li` as the only containable ancestor. There is no third option, so maths in a list item is now abandoned as maths in a table cell already was, and `ol` and `ul` are uncontainable too so the walk cannot hoist past an item and lose every marker in the list instead of one. Censused rather than assumed: of the 595 marked blocks in the 500K corpus, 595 are `p` and 0 are `li`, so this gives up nothing the measurement was counting. CONTAINMENT IS GATED ON THE ENGINE. WebKit below Safari 26 cannot find skipped `content-visibility` content with native find-in-page (webkit.org/b/283846), which `index.css` already refuses to accept for code blocks and says so. The marked element here is a whole paragraph or heading, so on an affected build ordinary prose would stop being findable too, not just the formula. Nothing exposes that capability directly, so the gate is a CSS feature detect for anchor positioning, which shipped in the same release as the fix. It is a proxy for a release train and says so. An explicit runtime override still wins, because that global exists for measurement and a gate that silently refused would make a console flip look like it worked while measuring the other arm; a build flag does not win, because a build ships to engines its builder cannot see. THE RUNTIME OVERRIDE REAPPLIES. It was read once before the first render, so setting it from devtools afterwards changed nothing and the session went on measuring the arm it was already in, silently. The global is now an accessor whose setter reapplies. The logic lives in `math-block-mode.ts`, which the `node --experimental-strip-types` runner can load, because an escape hatch whose failure mode is silence needs rows run against it rather than a comment. Two review items are NOT fixed here and are recorded on the pull request instead, because both are properties of the mechanism rather than of this code: paint containment clips an inline formula wider than the column, and a block skipped across a resize keeps a height measured at the old width. The repair one would reach for on the second, dropping the attribute for a frame, was measured and makes the error worse: scrollHeight goes from 30768 against a true 61920 to 18864. Both are preconditions on ever turning this flag on, along with find-in-page. npm test: 5176 passed, 0 failed. tsc --noEmit clean. * Studio: turn maths block containment on by default, gated on the engine The three things that could have stopped this are now each measured rather than argued, so the ship default moves from "off" to "contain". The engine gate still stands above it: a browser that cannot find skipped content with find-in-page does not get containment at all. WHAT IT BUYS, at 500K on a real GPU, four sessions across two runs: 285 ms of blocked main thread per one pixel scroll and 3.2 fps becomes 18 to 19.5 ms and 35.6 to 38.2 fps. +92% on the mean and +88% on the median rAF gap, in every session. At 100K it is +80 to +85% at 62 fps. At 0K the selector matches nothing, so the rule cannot cost anything there for a structural reason rather than a statistical one. RENDERING IS UNCHANGED: two production bundles photographed at seven maths blocks after one full walk of the thread differ by 10 pixels over seven frames, four of them bit-identical, with scrollHeight matching exactly at 307,915. FIND-IN-PAGE is handled by the gate, not by this constant. Verified by driving the real WebKitFindController over four states: plain 1 match, contained 1, visibility hidden 0, absent 0. The two controls are what make the middle row mean anything. Studio Desktop wires no find-in-page on any platform, so the exposed surface is the web UI in a browser older than Safari 26, which is exactly what the detect switches off. LIST NUMBERING was fixed in the previous commit and censused at zero cost: 595 of 595 marked blocks in the 500K corpus are paragraphs. A REMEMBERED HEIGHT GOING STALE ACROSS A RESIZE is ACCEPTED, at a measured size. Narrowing the window from 1440 to 1008 px leaves scrollHeight 6,350 px short of the truth, 2.0%, against 0.0% for the same thread with the feature off, and it converges as blocks are scrolled past. A synthetic fixture in which every block is contained reads 100%, which is an upper bound and not what a real thread does. The first attempt at this number was a NON-MEASUREMENT: it set max-width on the wrong element and returned identical figures in both arms, including the control, where narrowing a column must change the wrapped height. The scene now asserts that the element got narrower and that the narrowing changed the laid-out height, so a no-op reads as void. CLIPPING is also accepted: paint containment clips an inline formula wider than the chat column instead of letting it overflow. Zero blocks overflow in the 500K corpus. `overflow-x: auto` is not the remedy, because it would make 595 marked blocks into 595 scroll containers, which is the RenderLayer population this change exists to remove. One comment in math-block-mode.ts became false with this flip and is rewritten rather than left: unset and unrecognised used to resolve to the same place, and no longer do. A mistyped build flag now lands on "off" while unset lands on the default, which is the safe direction, since the only reason to reach for a flag that is already on is to turn it off. npm test: 5177 passed, 0 failed. tsc --noEmit clean. * Studio: do not contain a display that carries an equation number katex.css resets `katexEqnNo` on `body` and increments it in `.katex .eqn-num::before`. Style containment, which `content-visibility: auto` applies unconditionally, scopes that increment to each contained display, so numbered `equation` and `align` environments stop counting up. Measured on Chromium, three numbered displays: flag off (1) (2) (3) flag on (1) (1) (1) 173 differing pixels over the equation strip. The same fixture on WebKitGTK 2.50.4 does NOT reproduce it: 0 differing pixels, against 120 for the list-marker case photographed in the same frame, so the probe could see counter breakage and that engine simply does not do it. This is engine dependent and Chromium is the engine most of the web UI runs in. The stylesheet now names `.aui-math-display`, a class the renderer adds AFTER KaTeX has run and only to displays with no equation number. `guardEquationNumbers` also takes the block class back off any block that turns out to contain a numbered display, because `markMathBlocks` runs BEFORE KaTeX, when a display is still `<pre><code class="language-math">` and no `.eqn-num` exists to see; a blockquote holding both inline maths and a numbered display would otherwise scope the counter from above just as effectively. A `:has()` rule would express the exemption in CSS alone and is exactly what must not be used: two `:has()` rules were the measured owner of the whole 500K scroll cost on Chromium, fixed in #9669. So the renderer decides and the stylesheet names a plain class. The wiring test now asserts that neither gated rule uses `:has()`. Verified on Chromium with the fix in place: the numbered displays compute `content-visibility: visible`, the unnumbered one computes `auto`, and the frame is bit-identical to the flag-off leg. npm test: 5177 passed, 0 failed. tsc --noEmit clean. * Studio: correct three comments that still said this ships off The flip left "ships off" claims in `math-block-mode.ts`, `main.tsx` and `index.css`. Anyone configuring or diagnosing the rollout would have read them and assumed containment, and its documented tradeoffs, were inactive. The `index.css` one was worse than stale. It said 39 sub-structures move by one device pixel across seven formulae, blamed paint containment re-parenting KaTeX's absolutely positioned `.vlist` boxes, and gave that as the reason to ship off. Both halves are wrong. `contain: paint` ALONE moves 192 pixels over those same seven frames, two of them bit-identical, against 7,611 for the whole change, so it is about 2.5% of the effect. And the effect is a first-traversal transient rather than a rendering difference: photographed after one full walk of the thread, so that every block has rendered once and `contain-intrinsic-size: auto` holds its real height, the two built bundles differ by TEN pixels over seven frames, four of them bit-identical, with scrollHeight matching exactly. The earlier measurement was catching blocks that still showed their placeholder. The comment now points at `SHIP_DEFAULT` for what this actually costs: a remembered height going stale across a resize, 2.0% of scrollHeight on the 500K corpus against 0.0% with the feature off, and clipping of an inline formula wider than the column, which no block in that corpus is. npm test: 5183 passed, 0 failed. * Studio: correct the second stale ships-off comment in index.css, and test for it The stylesheet carried two separate statements of the default. Correcting the first one left this second block still saying the feature is off by default and that an ordinary install never arms the rule, which is the opposite of what SHIP_DEFAULT now does. Since this is the third round of the same defect on this branch, it is now a test rather than more care: the full text of index.css, main.tsx and math-block-mode.ts is searched for four stale phrasings, with preconditions that each file was really read and that the shipped default really is contain. * Studio: name the real math containment override variables in index.css, and test for it * Studio: keep math containment off the printed page, and test for it --------- Co-authored-by: danielhanchen <moonshotaisubstack@gmail.com>

Studio: scope two
:has()rules to direct children so they stop walking the whole threadScrolling a long chat in Chromium drops frames, and two CSS selectors are a large part of it.
The mechanism
A
:has()whose argument is a descendant selector has to be re-checked whenever anything is inserted or removed anywhere inside its subject, and answering it means walking the subject's subtree. Two of them sit on ancestors of every message:studio/frontend/src/components/ui/sidebar.tsxhas-data-[variant=inset]:bg-sidebar[data-slot="sidebar-wrapper"], which wraps the whole applicationstudio/frontend/src/features/chat/chat-page.tsxhas-[[data-chat-model-notice]]:[--studio-chat-notice-height:2.25rem]So every message that mounts, every token that streams and every deferred code fence that upgrades walks the entire thread, twice.
It is a traversal, not a restyle. Blink's own
UpdateLayoutTree.elementCountfor one inserted span is 1 with both rules in their child form, 2 with one still in descendant form and 3 with both. Only the subjects are restyled. The cost is O(subtree) in time and O(1) in restyled elements.That distinction is load-bearing rather than pedantic, because it explains two results that otherwise look like contradictions:
index.cssnearcontent-visibility: visiblewas recording when it said containment on the message roots was no help.content-visibility: autoon the message roots does not help either, measured at -7% on the same insertion: the argument re-check walks skipped content too.It also retires an angle I first thought was the carrier. The chat wrapper's rule declares an unregistered inheriting custom property, which is the shape of #9328. Measured, the same rule declaring a non-inherited
backgroundcosts the same, so the combinator carries the cost and the property does not. Related by family, not by mechanism.Attribution
Local headless Chromium 151, corpus
23cd2464, r500K, 357,843 elements, two concurrent arms every time. The measured quantity is the cost of appending one empty<span>inside a message, timed as the forced style and layout flush that follows.Controls first, because an ablation whose positive control is cheap is not loaded:
<style>element, whole-document by constructionA reversible stylesheet bisect, with a delete-and-reinsert arm as its negative control:
:has()rules removedThen per rule, over the eight the bisect kept:
For comparison, the same span appended to
<body>costs 0.10 ms in every arm, and the cost is linear in thread size above ~25k elements: 7.3 ms at 111,995 elements, 36.7 ms at 357,843, about 30 ns per element per insertion for the pair.The fix
Both selectors become their child form,
has-[>...]. Not a weakening:data-variantis on the root elementSidebarrenders anddata-chat-model-noticeis on the root elementChatModelNoticerenders, and both are direct children of the elements carrying the rule, so the same elements match. What changes is that a mutation deep in the thread can no longer make the engine ask the question again.Re-measured on the built bundles, not by deleting rules at runtime, same page, same DOM:
<body><style>insertBoth positive controls are unchanged, so the page did not become globally cheaper: one specific traversal was removed. The scroller class toggle is unchanged too, which is correct, since that is a real style change for that element and its descendants.
Scroll, r500K, Chromium
Four arms per bundle, run concurrently, ports rotated between waves so a per-port or launch-order effect cannot masquerade as the arm. Identical DOM in all eight (357,923 elements, 335 code blocks, 247,675 highlight spans) and an identical gesture (632,088 px commanded and 632,088 px travelled, 704 steps, top reached, no deadline hit).
The arms do not overlap: the worst
after(34.4) beats the bestbefore(30.9).The frame-rate channel is effective rate over wall time, not
1000/p50. A jammed positive control ran in the same wave: a deliberately blocked main thread read 59.6 to 20.0 fps on the reported channel while1000/p50read 59.9 both jammed and unjammed.The limit: this is Chromium only
Stated up front rather than buried, because Studio and Desktop on Linux render through WebKitGTK, not Chromium.
On a synthetic thread carrying Studio's real ancestor chain and its built stylesheet, at 300,464 elements, one inserted span costs, for plain / child / one descendant rule / both:
WebKitGTK and Firefox are flat across all four forms.
:has()support was verified live in each engine first, so "no penalty" cannot be "the rule was dropped".Confirmed end to end on the real app in real WebKitGTK 2.50.4 at r500K, three arms per bundle, identical gesture at 613,800 px travelled:
The arms overlap completely. So this does nothing for Studio on Linux and Desktop, and it does not regress them either. The Chromium win stands for Studio in a browser. The WebKitGTK collapse is a different mechanism and is still open.
Short context
A hard gate, and it holds. Identical DOM at both rungs.
Separately, three reps per bundle through a dedicated short-context harness, after a jammed positive control resolving at 13.0 fps / 91.3% busy against 58 fps / 39% unjammed: 0K idle, scroll and stream and 100K idle, scroll and stream are all flat or better, every delta inside the base spread except 100K scroll
busy_pct, which is 39.1 / 39.8 / 38.8 before against 35.3 / 36.1 / 36.3 after.UI idempotency
Nothing renders differently. This hides nothing, defers nothing and clips nothing.
An idempotency gate was built for this change, with its positive control built first, and the control changed the design:
DOM.performSearchwalks the DOM tree and cannot see rendering, so under acontent-visibility: hiddenbreaker it found 6 of 6 needles thatwindow.findcould not find at all. The gate useswindow.find.Every check is red under at least one deliberate breaker, so none of them is decoration:
Base against this branch at r100K, every quantity bit-identical, checksums included: elements 22,247; fences 56; deferred at mount 53; spans at mount 2,458; fence-body checksum 4708751141648480; viewport scrollHeight 56,455 before and 56,547 after a full traversal; find 6/6; selected non-space 166,818 with checksum 1195575140194632; clipboard 189,318 chars / 166,818 non-space / same checksum; commanded and travelled 111,202; deferred after traversal 0; print 53 to 0 with spans 2,458 to 41,410 on both doors.
Selector equivalence measured by putting the attribute on the DOM and reading what the engine computes on every ancestor of the thread viewport: before, nine ancestors respond with
2.25remas direct child and as grandchild; after, exactly one does, and only as a direct child. The shapeChatPageactually renders gives2.25remin both and puts the first message at 84 px in both, against 48 px with no notice, so exactly 36 px is reserved either way. Sidebar wrapper background isrgba(0, 0, 0, 0)in both; forced onto the direct child it isrgb(255, 255, 255)in both; forced onto a grandchild it is white before and transparent after, which is the intended narrowing and is unreachable today becausevariant="inset"is not used anywhere in Studio.The guard
studio/frontend/tests/thread-ancestor-has-scope.test.ts. It asserts both selectors keep their child form, that each rule is still present rather than merely deleted, and, because a child combinator is only equivalent while the target really is a child, thatChatModelNoticerenders a direct child of the declaring element and thatdata-chat-model-noticeis on the component's root. Each of the four checks was confirmed to fail on its own deliberate breaker, and the files were restored from md5-verified byte snapshots afterwards.tests/chat-remembers-its-model.test.tspinned the old selector and is updated to pin the new one, keeping its original intent: one declaration of the height, on the nearest ancestor of both the bar and the padding.All 4,816 frontend tests pass, and
typecheckis clean.