Repository navigation
Hovering an oversized expanded transcript forces near-full-document style recalc (~100ms frames per mousemove frame) #5842
Description
Summary
Moving the pointer across an expanded oversized transcript produces a long frame (>50ms) on every mousemove frame: 86 long tasks in a single sweep. The cost is Document::recalcStyle (UpdateLayoutTree) running over a large fraction of the document on each hover-chain switch, and it grows super-linearly with transcript size. Style-recalc invalidation from :hover rule chains - not containment, not event handlers, not layout - is the mechanism.
Reproduction
- Fixture: app-shell story shape with a single expanded process turn of 360 steps (
oversizedTurnMessages(360)), 14,919 elements, transcript ~103,028px tall; Storybook dev server, real Chromium driven via CDP. - Drive: pointer serpentine across the transcript area (14x40 grid, 2-step moves), rAF frame sampling + long-task (LoAF) counting.
- Result: 86 LoAFs >50ms per sweep; per-frame cost ~100-110ms continuously while the pointer moves inside the transcript area; idle or pointer outside the area -> zero.
Source-inspection baseline: current main. Single Windows machine, Chromium via CDP, synthetic pointer input - but the mechanism is CSS build-level (same rules ship in the real Desktop app over the same DOM shape).
Discriminating experiments (same build, same sweep)
| intervention | LoAFs >50ms per sweep |
|---|---|
| pointer crosses element boundaries (baseline) | 86 |
| pointer stays inside one element (no hover-chain switch) | 0 |
pointer-events: none on the transcript (hover never applies) |
0 |
CSSOM rewrite neutralizing all :hover rules |
0 |
neutralizing only the 58 design-system hash-class :hover rules (.hash.hash:hover:where(...) doubled-name shape) |
0 |
neutralizing only the 29 app .maka-* rules |
partial |
both containment tiers removed (contain dropped) |
86 (unchanged) |
Cost center (trace evidence)
disabled-by-default-devtools.timeline in one sweep: Document::recalcStyle (UpdateLayoutTree) 109 hits / 8,298ms total / max 102ms per hit; layout itself ~0. Scale: 2,634 elements -> 12.5ms per sweep frame (no long tasks); 14,919 -> ~100ms per frame, all frames long. The supralinear growth is consistent with the recalc scope covering a growing fraction of the document.
Containment does not scope this
contain: content does not include style containment and does not bound the recalc caused by hover-chain switching: removing both containment tiers changes nothing (86 -> 86). Relevant to the Turn-level vs sub-turn containment discussion on #4259: neither tier addresses this cost.
Amplifier (separate, actionable upstream)
Storybook's preview highlight tracker (useHighlights: document.body mousemove -> rAF -> hoverCoords.set -> updateHovered -> isOverMenu, whose first line is getBoundingClientRect()) forces style+layout every frame even when its boxes set is empty. Causal experiment: intercepting the listener leaves the long-frame count unchanged (86-87) but the JS hotspot disappears and the long-task invoker becomes (program) - i.e., the native recalc was being absorbed into a ~98ms FrameRequestCallback task. An empty-boxes early-exit upstream would remove the script-visible amplifier for all Storybook users; the native recalc would remain.
Fix directions (for discussion)
- Product: the ~15k elements come almost entirely from one expanded 360-step process - turn-level virtualization (refactor(desktop): load the transcript whole and virtualize it in the renderer #5366) does not reach inside a turn. Windowing or lazy expansion of oversized processes would cut the recalc scope at the source.
- CSS: the 58 hash-class rules are a sufficient condition; bisecting which rule shape (the doubled
.hash.hash:hoverspecificity form is the obvious suspect - it looks like CSS-in-JS emitted rules) triggers the wide invalidation is the natural next step. - Trigger-side mitigations (hover-intent gating) exist but change behavior; noting for completeness.
Raw drivers, traces, profiles, and per-sweep numbers are archived; happy to share if useful.
Activity
ggbdpq commentedon Sep 29, 2026
Bisection result: the trigger narrows from 58 rules to 16 - and none of them match anything in the document.
Method: CSSOM rewrite bisection over the 58 hash-class
:hoverrules with the LoAF count (86 <-> 0) as a hard signal, then per-rule single-restore enumeration after the first halving converged to a single rule that failed confirmation (multi-culprit signature).Minimal trigger set: 16 rules, all from
@astryxdesign/core@0.6.2(the StyleX design package). Shapes:.xNNNNNN.xNNNNNN.xNNNNNN:where(.xAncestor:hover *):not(#\#)(x2-3 doubled), by ancestor class:x1odsvnmx6,x84s7jzx4,x1iwu4tgx2,x-default-markerx2,x1u3b27qx1.xof6bs:is(th:hover *):not(#\#)x3 - pinned to@astryxdesign/coreTableplugins (useTableSortable.tsx:153,useTableFiltering.tsx:391)Key reversal: these 16 rules match ZERO elements in the 14,919-element document (ancestor-class elements: 0; descendant classes: 0; the page has no table at all). The pathology is in the CSSOM invalidation shape, not the match domain.
Per-rule and group evidence: each of the 16 individually restored -> 86 (16/16 individually sufficient - a shape-level property, not specific rules); the remaining 42 restored as a group -> 0.
Mechanism discrimination (orthogonal mutations):
:hover(keep descendant relation +:where/:iswrappers):where/:isinto a plain descendant combinator (keep ancestor:hover)The causal core:
:hoverappearing in a non-rightmost compound selector, targeting descendants. Evidence-grade hypothesis: Blink registers a backward-descendant invalidation for ancestor-hover-dependent rules, and the rightmost*leaves the invalidation set un-narrowable by class features - so any hover-chain change invalidates the whole document. Zero-match rules still triggering locates the problem at the RuleFeatureSet / invalidation-set layer.Scroll path: pointer-wheel sweeps at the same 14,919-element expanded state -> 0/0 LoAFs. #5842 is hover-specific; scrolling is clean.
Fix directions, refined: the actionable shape is "ancestor
:hovertargeting descendants" emitted by the design package's StyleX runtime (doubled class names +:not(#\#)specificity bump = styleq dev-runtime signature). Options: upstream the shape fix to@astryxdesign/core(or its styleq dependency), avoid emitting ancestor-hover-descendant rules for these components, or track the Chromium invalidation behavior. Happy to provide the full 87-rule CSSOM dump and the bisection drivers.Dante-dan commentedon Sep 29, 2026
Automated Codex contribution under Dante-dan's direction. I traced the reported selectors to the global
@astryxdesign/core/astryx.cssimport in the Desktop renderer. The 16-rule result suggests a narrow compatibility fix: replace these ancestor-:hoverdescendant triggers in the bundled stylesheet with an explicit hover marker on the relevant Astryx component roots, set and cleared on pointer entry/exit. Those component roots are absent from the oversized transcript in the reported fixture, so transcript mousemove would no longer exercise the expensive invalidation; the components that do exist elsewhere should retain their hover states.I would first test the marker form against the existing 360-step Storybook scene and the actual affected components (Overlay, Indicator, Switch, TabList, Table), measuring LoAF/style-recalc and checking pointer, focus, and disabled behavior. If the replacement still causes broad invalidation or changes a component state, I will not ship it; the next route would be a design-package fix or separately discussed process windowing. This proposal is based on the issue's CSSOM experiments and local source inspection, not a claimed successful A/B yet.
Dante-dan commentedon Sep 29, 2026
take
ggbdpq commentedon Sep 29, 2026
Data point for whoever picks this up (saw the
take— thanks): upstreammainjust bumped@astryxdesign/corefrom 0.6.2 to 0.6.3 (#5839), so I checked the published 0.6.3 tarball'sdist/astryx.cssdirectly against the bisection result:.xN.xN.xN:where(.xAnc:hover *):not(#\#)): 15 in 0.6.2 → 15 in 0.6.3, byte-shape identical (both also wrapped in@media (hover: hover)):is(th:hover *)rule: 1 → 1The shape disease was not touched by the version bump, so the bisection in the issue body stays valid on current main. Caveat: verified by grepping the npm-published tarballs, not a full browser reproduction on 0.6.3.