Studio: stop the scroll stabilizer invalidating style for the whole thread by danielhanchen · Pull Request #9328 · unslothai/unsloth · GitHub
Skip to content

Studio: stop the scroll stabilizer invalidating style for the whole thread - #9328

Merged
danielhanchen merged 4 commits into
mainfrom
perf-scroll-stabilizer-property
Aug 20, 2026
Merged

Studio: stop the scroll stabilizer invalidating style for the whole thread#9328
danielhanchen merged 4 commits into
mainfrom
perf-scroll-stabilizer-property

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 19, 2026

Copy link
Copy Markdown
Member

Studio: stop the scroll stabilizer invalidating style for the whole thread

Ending a generation in a long chat is slow, and it gets slower as the thread fills. The cause is
one CSS custom property.

useIntentAwareAutoScroll keeps scrollHeight monotonic during streaming by writing
--aui-scroll-stabilizer on the thread's scroll container. An unregistered custom property
inherits, so writing it there marks inherited style dirty for every element beneath it, and the
scrollHeight read that follows recalculates style for the whole thread instead of for the message
that changed.

The write fires three or four times per measured cell, essentially all at stream finalisation when
Shiki re-highlights the trailing code block, against 252,723 custom-property writes in the same
film. Three writes out of 252,723: invisible by count, dominant by cost. That is why an
aggregate setProperty frame pointed at the wrong thing for so long, and it is the general lesson
worth taking from this change: rank frames by slope, never by count.

The diff

One hunk, studio/frontend/src/index.css, immediately above .aui-stream-viewport:

@property --aui-scroll-stabilizer {
	syntax: "<length>";
	inherits: false;
	initial-value: 0px;
}

No JavaScript changes. No DOM changes.

Why this is safe

It keeps every write. This is the point, and it is what separates the fix from the ablation that
first proved the mechanism. Suppressing the write is also fast, and it is wrong: when Shiki
re-renders the <pre>, its height dips, scrollHeight shrinks, and the browser synchronously
caps scrollTop at the layout that follows the shrink. That cap is the visible jump the stabilizer
exists to prevent. This change leaves the write, its value, its timing and its resulting
padding-bottom exactly as they are, and narrows only the scope of the invalidation it causes.

The same fact rules out the other obvious fix. rAF-coalescing the observer callback removes the same
forced layout, but a rAF runs on the next frame, by which time the cap has already happened. The
synchronous path has to stay synchronous; this makes it cheap instead of moving it.

  • Autoscroll still follows a stream, and a user who scrolled up is still not pulled back down.
    Nothing in the intent detection is touched, because no JavaScript is touched.
  • initial-value: 0px reproduces the var(..., 0px) fallback, so an unset property still
    computes to 0px and removeProperty() still releases the padding.
  • Nothing below the viewport ever read the variable. The only consumer is the padding-bottom
    declaration on the same element that carries the write.
  • syntax: "<length>" is a constraint the hook already satisfies: it only ever writes ${n}px.
  • Engines without @property (Firefox below 128) ignore the block and keep today's behaviour:
    slower, still correct.

Evidence the guard survives

@property makes a property typed, so the failure to rule out is the padding silently ceasing to
apply. Checked in both shipped builds against the real viewport element:

check base with this change
a descendant inherits the property yes no (the mechanism, observed)
padding-bottom with the stabilizer set to 37px 37px 37px (the guard, preserved)
padding-bottom after removeProperty() 0px 0px
viewport scrollHeight / clientHeight / scrollTop 58161 / 960 / 57201 identical

Performance

stop_generation.stop_ms is click Stop, then poll until the run is really over: main-thread
availability during stream finalisation, which is when the stabilizer fires.

Standard tier, --reps 4, three independent waves, each rung and wave scored against a null
control run concurrently. Every wave is reported.

Growth from 10K to 100K

wave base 10K base 100K base growth fix 10K fix 100K fix growth
1 118.2 ms 338.2 ms 2.86x 85.8 ms 100.8 ms 1.17x
2 119.4 ms 495.9 ms 4.15x 77.4 ms 113.8 ms 1.47x
3 113.5 ms 346.8 ms 3.06x 87.0 ms 101.4 ms 1.17x

The base cost roughly triples across the rung in every wave; the fixed cost is close to flat. The
treatment value at 100K is 100.8 / 113.8 / 101.4 ms across three independent sessions while the base
value is 338.2 / 495.9 / 346.8 ms. A removed length-dependent term is exactly what that looks like.

Every quoted metric, with its floor

rung wave metric base treat delta spread floor verdict
100K 1 stop_generation.stop_ms 338.2 100.8 -70.2% 4.1% 46.5% faster
100K 2 stop_generation.stop_ms 495.9 113.8 -75.9% 15.4% 54.3% faster
100K 3 stop_generation.stop_ms 346.8 101.4 -70.5% 7.0% 42.3% faster
100K 1 time_in_jank_pct 6.8 5.8 -15.2% 7.8% 12.5% faster
100K 2 time_in_jank_pct 7.1 6.0 -14.9% 4.0% 6.3% faster
100K 3 time_in_jank_pct 7.1 5.9 -16.3% 11.7% 21.6% VOID (under floor)
10K 1 stop_generation.stop_ms 118.2 85.8 -27.3% 14.5% 16.6% faster
10K 2 stop_generation.stop_ms 119.4 77.4 -35.2% 14.7% 7.5% faster
10K 3 stop_generation.stop_ms 113.5 87.0 -23.3% 22.0% 36.0% VOID (under floor)

stop_generation.stop_ms at 100K cleared all three gates in all three waves. time_in_jank_pct
at 100K and stop_ms at 10K each cleared in two of three; both misses were the wave's own floor
widening, not the treatment moving, and the delta in the missed wave is in line with the two that
passed.

Everything else is VOID under its own floor at every rung and wave: all scroll, menu, keystroke,
copy, settings, model-change and thread-reopen metrics. This change does not make a long thread
feel generally faster.
It removes one length-dependent cost on the action where the stabilizer
fires.

The null control's own health, reported before scoring

A floor is only a floor if the null that produced it was well behaved, so each wave's null was
checked on its own, before any treatment number was read. A null is base against base, so in truth
every one of its eight cells measures the same thing.

rung wave null median spread outlier ratio
100K 1 357.0 ms 75.3% 1.67 disturbed (one cell 594.4, rest 325.6-579.1)
100K 2 358.5 ms 79.5% 1.75 disturbed (one cell 614.8, rest 329.9-418.3)
100K 3 350.2 ms 94.9% 1.92 disturbed (one cell 667.6, rest 335.2-377.7)
10K 1 114.8 ms 20.5% 1.17 well behaved
10K 2 116.8 ms 9.9% 1.05 well behaved
10K 3 122.8 ms 59.8% 1.42 disturbed (one cell 174.7, rest 101.2-136.6)

This is a property of the metric, not of the machine. The 100K null is disturbed in all three
waves, and those waves ran at 1-minute load averages of 1.08, 40.4 and 71.8 respectively. A single
cell runs away to 590-670 ms while the rest sit in a 325-420 ms band, every time, at every load.

The practical consequence for a reviewer: the floor on stop_generation.stop_ms at 100K is 42-54%
in every wave, so the gate only passes there for very large effects. This one passes because it is
70-76%. A smaller but real improvement to that metric would be voided by this harness, and that is
worth knowing before anyone tries to measure one.

UI parity

sweep.ui_parity, all six pairs:

pair matched stable actions differing style probe differing not comparable
100K waves 1 / 2 / 3 55 / 57 / 56 0 / 0 / 0 0 0
10K waves 1 / 2 / 3 58 / 57 / 56 0 / 0 / 0 0 0

Because this touches CSS, and the parity digest cannot see stylesheet CSS, computed layout or
anything rastered, screenshots were taken as well: the same seeded 100K thread at the same scroll
position on both builds, at the bottom and mid-thread. Identical, including Shiki's token colours,
which are themselves custom-property driven and would have been the first thing to break had the
registration been wrong.

Liveness

Every one of the twelve payloads was gated before any timing was read:

python -m tests.studio.studiobench --assert-liveness <payload> --allow-not-run image_upload

All twelve: 0 liveness problem(s), exit 0.

image_upload is the one allowance and it is a pre-existing platform hole, not a hole in this
change: the composer's attachments button reports a 0x0 box and mounted: false on both arms of
every run. Without the allowance the gate correctly exits 1.

Method notes

  • The base build is not main, it is this branch minus the hunk. Both dists were built from the
    same commit, one with the rule and one without; git diff between them is one file. This avoids
    the stale-branch trap in CONTRIBUTING-perf.md: the arms differ by the change and nothing else.
  • Rebased onto current origin/studiobench-sweep before the final run, so the measured pair sits
    on current code. That branch is a strict superset of origin/studiobench with a byte-identical
    frontend.
  • Two measurement jobs at a time, never more, the arm and its in-band null. An earlier attempt
    ran four jobs beside two other people's benchmarks; that machine state is not what produced the
    numbers above.
  • The box was not idle for waves 2 and 3: another workspace was running a KLD sweep at 20-plus
    cores. Load per wave is recorded above, and the null health table shows the 100K disturbance is
    independent of it.

How the mechanism was established

Three treatments, each removing the mechanism a different way, converge at 100K. This is what makes
it an attribution rather than a correlation.

treatment what it removes stop_generation.stop_ms
arm E (runtime knob) the stabilizer write only; observer and forced read intact -78.9%
arm D (runtime knob) the whole viewport MutationObserver -78.7%
this change (shipped build) the write's inheritance, keeping the write -70 to -76%

Arms D and E both held EXACT invariance with byte-identical output digests on every paired cell, and
both proved potency by a counter that moved: suppressedViewportObserves 0 to 60 at 100K for D,
suppressedStabilizerSets 0 to 3 per cell for E. The arm instrumentation is not part of this pull
request; it is on a separate branch.

Re-measured in isolation against the SHIPPED bundle (dist/assets/index-*.css, not the source),
using Chromium's own UpdateLayoutTree trace events over a 51,608-element thread, 20 writes each
followed by a scrollHeight read to force synchronous style and layout:

arm elements restyled per write total style time, 20 writes
main (no @property rule) 51,601 3,466.04 ms
this change 1 2.11 ms
control: same rule with inherits: true 51,601 3,441.22 ms

The third row is the control that matters. Registering the property but leaving it inheriting
reproduces main exactly, to the element, so inherits: false is the load-bearing part and not the
registration. An earlier revision of this description quoted 28x from a differently instrumented
synthetic page; these numbers supersede it.

Withdrawn numbers, and why

An earlier revision reported the opposite conclusion: that --aui-scroll-stabilizer is written
zero times and therefore cannot be the cost. That was wrong and is withdrawn. It is recorded
here because a reviewer who finds it in the branch history unprompted should find the explanation
beside it.

Arm evidence is read after the cell finishes, deliberately, because re-measuring the gauges forces a
style recalc and doing that inside a measured window would put the instrument into the measurement.
But the last action in the scene navigates, a navigation is a new document, and every in-page
counter restarted at zero. A tally reading zero because a navigation wiped it is indistinguishable
from a tally reading zero because the patch never fired, and the second reading was published for a
counter that was showing the first.

It was caught by a colleague hitting the same defect on unrelated arms, and confirmed here rather
than assumed: grep -c 'navigating instead' returns 8 over 8 cells in every affected run. The tell
was in the data already and was missed: the census reported 40,111 writes of --shiki-dark across
exactly 40,111 elements, and one write per span is the signature of a single fresh render.

collected numbers status
before the counter fix suppressedStabilizerSets 0; census 89,823 writes; arm D "8 of 14 observes" withdrawn, post-navigation fragments
after it every potency and census figure quoted above stands
never affected all A/B timings, all floor tables, the synthetic isolation the defect touched only the in-page tally

Counters and the census are now mirrored into sessionStorage. Gauges deliberately are not:
a gauge is a point-in-time measurement of the DOM in front of it, and carrying one across a
navigation would report the previous document's cascade as this one's.

Unexplained

The write count falls as the thread grows: about 10 per cell at 10K and 3 to 4 at 100K, while
the cost of each write rises with thread size. The net effect is still strongly length-dependent,
which is what the growth table shows, but I do not know why the count moves in the opposite
direction and am not going to guess. It does not affect the result: fewer, more expensive writes is
still the mechanism, and this change removes the expensive part regardless of the count.

Deliberately out of scope

  • Streamdown and Shiki write about 94% of all custom properties (--sdm-c 123,810 and
    --shiki-dark 113,753 in one 100K film) onto 40,111 leaf spans. Every one lands on a leaf, so none
    triggers the whole-thread invalidation this change is about.
  • Arm D as a shippable change. It is an ablation, not a fix: detaching the observer also removes
    the following behaviour.
  • The forced scrollHeight read itself, which this change does not remove. It grows with the page
    rather than faster than it.

…hread

useIntentAwareAutoScroll keeps scrollHeight monotonic during streaming by writing
--aui-scroll-stabilizer on the thread's scroll container. An unregistered custom property inherits,
so writing it there marks inherited style dirty for every element beneath it, and the scrollHeight
read that follows recalculates style for the whole thread instead of for the message that changed.

The write is rare and the cost is not. It fires three or four times per measured cell, essentially
all at stream finalisation when shiki re-highlights the trailing code block, against 252,723
custom-property writes in the same film. Three writes out of 252,723 is invisible by count and
dominant by cost, which is why an aggregate setProperty frame pointed at the wrong thing.

Registering the property with inherits:false confines the invalidation to the viewport element.
Every write still happens, with the same value and the same resulting padding-bottom, so
scrollHeight stays monotonic and the synchronous scrollTop cap that the stabilizer exists to
prevent cannot return. initial-value reproduces the var() fallback, so an unset property still
computes to 0px and removeProperty() still releases the padding. Nothing below the viewport reads
the variable. No JavaScript changes, so autoscroll still follows a stream and a user who scrolled
up is still not pulled back down.
@danielhanchen
danielhanchen force-pushed the perf-scroll-stabilizer-property branch from 2628b4d to e2c9c3b Compare August 20, 2026 03:18

@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: b2798a25a2

ℹ️ 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 +2867 to +2871
@property --aui-scroll-stabilizer {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the registration in the production CSS

When the Studio frontend is built with npm run build, the generated dist/assets/index-*.css retains .aui-stream-viewport{padding-bottom:var(--aui-scroll-stabilizer,0px)} but contains no @property --aui-scroll-stabilizer registration. Consequently, production browsers still treat this dynamically assigned property as inherited, so long threads continue to incur the whole-subtree style invalidation this commit is intended to remove. The registration needs to be expressed in a form that the Tailwind/Vite production pipeline does not eliminate.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not reproducible: the registration is in the production CSS. In dist/assets/index-DThPvgHR.css built from this head:

@property --aui-scroll-stabilizer{syntax:"<length>";inherits:false;initial-value:0}.aui-stream-viewport{padding-bottom:var(--aui-scroll-stabilizer,0px)}

The same grep against a bundle built from main returns 0, and the consumer rule is present in both, so the registration is exactly what this branch adds to the shipped output.

Two things probably made this look absent. Lightning CSS minifies initial-value: 0px to initial-value:0, so a search for the source form does not match; unitless zero is a valid (css-values-4, 'for zero lengths the unit identifier is optional'). And Tailwind emits its own @Property block plus a universal fallback declaration nearby, so this one does not stand out.

Potency was measured on that exact bundle rather than inferred, using Chromium UpdateLayoutTree trace events over a 51,608-element thread, 20 writes each followed by a forced style and layout:

arm elements restyled per write style time, 20 writes
main, no registration 51,601 3,466.04 ms
this head 1 2.11 ms
control: same rule with inherits: true 51,601 3,441.22 ms

The third row is the control that matters: registering but leaving it inheriting reproduces main to the element, so the harness can detect a registration that fails to help, and this one does not fail.

danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 20, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

@danielhanchen
danielhanchen merged commit c589d3c into main Aug 20, 2026
3 of 35 checks passed
@danielhanchen
danielhanchen deleted the perf-scroll-stabilizer-property branch August 20, 2026 16:22
danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 20, 2026
tsautier pushed a commit to tsautier/unsloth that referenced this pull request Aug 25, 2026
…g the whole thread (unslothai#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 unslothai#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 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.
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.

2 participants