Files
multica/packages
e2e237a22f perf(ui): coalesce useScrollFade observers and skip identical setState (MUL-7987) (#9207)
* perf(ui): coalesce useScrollFade observers and skip identical setState

Every fade host (sidebar groups, the tab bar, dialogs) observed scroll,
resize AND childList:subtree mutations, and each observer hit
synchronously read layout and dispatched a setState even when the
computed fade value was unchanged. In busy workspaces with frequent
realtime re-renders, a route remount fired hundreds of identical
dispatchSetState calls per second across the shell's fade hosts — every
one going through React's lane scheduler — stretching tab switches and
detail-page opens out to several seconds. A production CPU profile of
the stall showed roughly half of all samples inside that
dispatchSetState path.

Coalesce the three sources through one requestAnimationFrame per host
and only dispatch when the computed value actually changes. The first
frame after mount still applies the initial mask, so the visual
behavior is unchanged.

Also gives packages/ui a minimal vitest setup (jsdom + @testing-library,
matching packages/core and packages/views) with coverage for the mask
values and a regression test asserting that a mutation/scroll storm
with unchanged layout reaches React zero times.

* fix(ui): keep useScrollFade scroll handling synchronous

Coalescing scroll through rAF broke chat's auto-scroll handshake: its
stick-to-bottom latch attributes wheel displacement to the scroll event
that confirms it within the same task, and deferring the fade update
(and its re-render) off that task left followOutput stale in the
autoscroll suite.

Scroll stays synchronous — user scrolling is too rare to need
coalescing, and the storms this hook guards against come from resizes
and subtree mutations, which remain rAF-coalesced. Full
packages/views suite (5981 tests) passes with this split.

* fix(ui): resolve useScrollFade lifecycle defects from review

Two defects from the PR review:

1. The cleanup removed the wrong closure — the listener was registered
   as `run` but unregistered as `schedule`, so StrictMode remounts and
   axis changes left the old measurement attached (double reads, stale
   axis). Remove `run` symmetrically.

2. A synchronous scroll executed `raf = 0` while a queued frame was
   still alive: the frame fired anyway (resize→scroll→mutation yielded
   two pending frames instead of one) and survived unmount cleanup,
   which saw the zeroed handle. `run` now cancels the queued frame up
   front — a synchronous measurement absorbs it.

Also refreshes the header comment that still claimed all three sources
go through rAF, and adds regression tests for the three review
scenarios (absorb queued frame, zero frames after unmount, listener
symmetry across cleanup and axis rebuild).

* chore(ui): drop unused no-var eslint directive in test setup

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Multica Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-19 13:59:51 +08:00
..