Studio: stop the scroll stabilizer invalidating style for the whole thread - #9328
Conversation
…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.
2628b4d to
e2c9c3b
Compare
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 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".
| @property --aui-scroll-stabilizer { | ||
| syntax: "<length>"; | ||
| inherits: false; | ||
| initial-value: 0px; | ||
| } |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
…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.
…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.

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.
useIntentAwareAutoScrollkeepsscrollHeightmonotonic during streaming by writing--aui-scroll-stabilizeron the thread's scroll container. An unregistered custom propertyinherits, so writing it there marks inherited style dirty for every element beneath it, and the
scrollHeightread that follows recalculates style for the whole thread instead of for the messagethat 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
setPropertyframe pointed at the wrong thing for so long, and it is the general lessonworth 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: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,scrollHeightshrinks, and the browser synchronouslycaps
scrollTopat the layout that follows the shrink. That cap is the visible jump the stabilizerexists to prevent. This change leaves the write, its value, its timing and its resulting
padding-bottomexactly 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.
Nothing in the intent detection is touched, because no JavaScript is touched.
initial-value: 0pxreproduces thevar(..., 0px)fallback, so an unset property stillcomputes to
0pxandremoveProperty()still releases the padding.padding-bottomdeclaration on the same element that carries the write.
syntax: "<length>"is a constraint the hook already satisfies: it only ever writes${n}px.@property(Firefox below 128) ignore the block and keep today's behaviour:slower, still correct.
Evidence the guard survives
@propertymakes a property typed, so the failure to rule out is the padding silently ceasing toapply. Checked in both shipped builds against the real viewport element:
padding-bottomwith the stabilizer set to 37px37px37px(the guard, preserved)padding-bottomafterremoveProperty()0px0pxscrollHeight/clientHeight/scrollTopPerformance
stop_generation.stop_msis click Stop, then poll until the run is really over: main-threadavailability during stream finalisation, which is when the stabilizer fires.
Standard tier,
--reps 4, three independent waves, each rung and wave scored against a nullcontrol run concurrently. Every wave is reported.
Growth from 10K to 100K
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
stop_generation.stop_msstop_generation.stop_msstop_generation.stop_mstime_in_jank_pcttime_in_jank_pcttime_in_jank_pctstop_generation.stop_msstop_generation.stop_msstop_generation.stop_msstop_generation.stop_msat 100K cleared all three gates in all three waves.time_in_jank_pctat 100K and
stop_msat 10K each cleared in two of three; both misses were the wave's own floorwidening, 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.
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_msat 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: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:
All twelve:
0 liveness problem(s), exit 0.image_uploadis the one allowance and it is a pre-existing platform hole, not a hole in thischange: the composer's attachments button reports a 0x0 box and
mounted: falseon both arms ofevery run. Without the allowance the gate correctly exits 1.
Method notes
same commit, one with the rule and one without;
git diffbetween them is one file. This avoidsthe stale-branch trap in
CONTRIBUTING-perf.md: the arms differ by the change and nothing else.origin/studiobench-sweepbefore the final run, so the measured pair sitson current code. That branch is a strict superset of
origin/studiobenchwith a byte-identicalfrontend.
ran four jobs beside two other people's benchmarks; that machine state is not what produced the
numbers above.
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.
stop_generation.stop_msMutationObserverArms 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:
suppressedViewportObserves0 to 60 at 100K for D,suppressedStabilizerSets0 to 3 per cell for E. The arm instrumentation is not part of this pullrequest; 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
UpdateLayoutTreetrace events over a 51,608-element thread, 20 writes eachfollowed by a
scrollHeightread to force synchronous style and layout:@propertyrule)inherits: trueThe third row is the control that matters. Registering the property but leaving it inheriting
reproduces main exactly, to the element, so
inherits: falseis the load-bearing part and not theregistration. 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-stabilizeris writtenzero 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 tellwas in the data already and was missed: the census reported 40,111 writes of
--shiki-darkacrossexactly 40,111 elements, and one write per span is the signature of a single fresh render.
suppressedStabilizerSets0; census 89,823 writes; arm D "8 of 14 observes"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
--sdm-c123,810 and--shiki-dark113,753 in one 100K film) onto 40,111 leaf spans. Every one lands on a leaf, so nonetriggers the whole-thread invalidation this change is about.
the following behaviour.
scrollHeightread itself, which this change does not remove. It grows with the pagerather than faster than it.