Skip to content

Hovering an oversized expanded transcript forces near-full-document style recalc (~100ms frames per mousemove frame) #5842

Open

Description

@ggbdpq

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:hover specificity 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

  1. ggbdpq commented on Sep 29, 2026

    @ggbdpq
    ContributorAuthor

    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 :hover rules 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:

    • 15x .xNNNNNN.xNNNNNN.xNNNNNN:where(.xAncestor:hover *):not(#\#) (x2-3 doubled), by ancestor class: x1odsvnm x6, x84s7jz x4, x1iwu4tg x2, x-default-marker x2, x1u3b27q x1
    • 1x .xof6bs:is(th:hover *):not(#\#) x3 - pinned to @astryxdesign/core Table plugins (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):

    mutation LoAFs/sweep
    strip the ancestor :hover (keep descendant relation + :where/:is wrappers) 0
    unwrap :where/:is into a plain descendant combinator (keep ancestor :hover) 166 (worse)
    both 0

    The causal core: :hover appearing 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 :hover targeting 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.

  2. Dante-dan commented on Sep 29, 2026

    @Dante-dan
    Contributor

    Automated Codex contribution under Dante-dan's direction. I traced the reported selectors to the global @astryxdesign/core/astryx.css import in the Desktop renderer. The 16-rule result suggests a narrow compatibility fix: replace these ancestor-:hover descendant 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.

  3. Dante-dan commented on Sep 29, 2026

    @Dante-dan
    Contributor

    take

  4. ggbdpq commented on Sep 29, 2026

    @ggbdpq
    ContributorAuthor

    Data point for whoever picks this up (saw the take — thanks): upstream main just bumped @astryxdesign/core from 0.6.2 to 0.6.3 (#5839), so I checked the published 0.6.3 tarball's dist/astryx.css directly against the bisection result:

    • backward-descendant rules of the offending shape (.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))
    • the :is(th:hover *) rule: 1 → 1

    The 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions