Studio: scope two :has() rules to direct children so they stop walking the whole thread by danielhanchen · Pull Request #9669 · unslothai/unsloth · GitHub
Skip to content

Studio: scope two :has() rules to direct children so they stop walking the whole thread - #9669

Merged
danielhanchen merged 3 commits into
mainfrom
fix/fence-upgrade-invalidation-scope
Aug 25, 2026
Merged

Studio: scope two :has() rules to direct children so they stop walking the whole thread#9669
danielhanchen merged 3 commits into
mainfrom
fix/fence-upgrade-invalidation-scope

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 25, 2026

Copy link
Copy Markdown
Member

Studio: scope two :has() rules to direct children so they stop walking the whole thread

Scrolling 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:

file selector what it is on
studio/frontend/src/components/ui/sidebar.tsx has-data-[variant=inset]:bg-sidebar [data-slot="sidebar-wrapper"], which wraps the whole application
studio/frontend/src/features/chat/chat-page.tsx has-[[data-chat-model-notice]]:[--studio-chat-notice-height:2.25rem] the wrapper containing the thread

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.elementCount for 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:

  • Containment cannot help, because there is no scope left to reduce. That is exactly what the note in index.css near content-visibility: visible was recording when it said containment on the message roots was no help.
  • 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 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 background costs 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:

control requirement measured
negative: force the flush with nothing dirtied must be ~0 0.00 ms
positive: write an unregistered inherited custom property on the scroller must be large 1,938 ms
positive: insert a <style> element, whole-document by construction must be large 35 ms
drift: the control again after every probe must match the first 17.4 / 19.0 vs 17.5 / 18.6 ms

A reversible stylesheet bisect, with a delete-and-reinsert arm as its negative control:

arm rep A rep B
every rule in place 17.5 ms 18.6 ms
all 142 :has() rules removed 0.10 0.10
816 universal-selector rules removed 18.4 17.3
80 structural-pseudo rules removed 18.1 17.4
delete-and-reinsert a rule matching nothing 18.0 18.4

Then per rule, over the eight the bisect kept:

removed rep A rep B
the sidebar wrapper's rule alone 8.7 9.0
the chat wrapper's rule alone 9.8 9.3
both 0.10 0.10
the other eleven, together 17.2 19.2

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-variant is on the root element Sidebar renders and data-chat-model-notice is on the root element ChatModelNotice renders, 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:

before after
empty span inside a message 21.5 ms 0.20 ms
shiki-shaped span inside a message 17.8 ms 0.30 ms
the same span on <body> 0.10 ms 0.20 ms
positive control, inherited custom property 1,938 ms 1,900 ms
positive control, <style> insert 35.1 ms 36.7 ms
a class toggled on the scroller itself 15.8 ms 17.3 ms

Both 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).

bundle scroll fps busy traversal step p95 worst frame
before 28.2 / 28.2 / 30.9 / 28.2 82.2 - 83.9% 46.7 - 51.3 s 83 - 100 ms 217 - 267 ms
after 35.4 / 34.4 / 39.3 / 39.3 77.6 - 82.0% 36.7 - 42.0 s 50 - 67 ms 183 - 233 ms

The arms do not overlap: the worst after (34.4) beats the best before (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 while 1000/p50 read 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:

engine plain child one descendant 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. :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:

bundle scroll fps wall
before 14.68 / 14.18 / 14.25 95.6 / 98.7 / 98.4 s
after 14.36 / 14.21 / 14.51 97.3 / 98.7 / 96.7 s

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.

rung before after
0K 59.4 fps at 1.4% busy 59.3 fps at 1.5% busy
100K 57.5 fps at 42.6% busy 58.0 fps at 37.9% busy

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.performSearch walks the DOM tree and cannot see rendering, so under a content-visibility: hidden breaker it found 6 of 6 needles that window.find could not find at all. The gate uses window.find.

Every check is red under at least one deliberate breaker, so none of them is decoration:

check none cv_hidden cv_auto cv_auto_cis cv_auto_fences latch_never print_never contain_paint_clip
find PASS FAIL PASS PASS PASS PASS PASS PASS
select_all PASS FAIL PASS PASS PASS PASS PASS PASS
copy PASS FAIL PASS PASS PASS PASS PASS PASS
latch PASS FAIL PASS PASS PASS FAIL PASS PASS
geometry PASS PASS FAIL FAIL PASS PASS PASS FAIL
print PASS PASS PASS PASS PASS PASS FAIL PASS

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.25rem as direct child and as grandchild; after, exactly one does, and only as a direct child. The shape ChatPage actually renders gives 2.25rem in 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 is rgba(0, 0, 0, 0) in both; forced onto the direct child it is rgb(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 because variant="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, that ChatModelNotice renders a direct child of the declaring element and that data-chat-model-notice is 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.ts pinned 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 typecheck is clean.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +203 to +205
const walk = (node: ts.Node): void => {
if (rootHasAttribute) return;
if (ts.isReturnStatement(node) && node.expression) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
@danielhanchen danielhanchen changed the title Studio: stop two :has() rules restyling the whole thread on every change Studio: scope two :has() rules to direct children so they stop walking the whole thread Aug 25, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +235 to +241

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@danielhanchen
danielhanchen merged commit 8de77bf into main Aug 25, 2026
47 checks passed
@danielhanchen
danielhanchen deleted the fix/fence-upgrade-invalidation-scope branch August 25, 2026 09:30
danielhanchen added a commit that referenced this pull request Aug 25, 2026
…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.
danielhanchen added a commit that referenced this pull request Aug 26, 2026
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.
danielhanchen added a commit that referenced this pull request Aug 26, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant