Skip to content

[Cascade] Improve scroll sync implementation#256511

Merged
eokoneyo merged 4 commits into
elastic:mainfrom
eokoneyo:patch/improve-scroll-sync-implementation
Mar 10, 2026
Merged

[Cascade] Improve scroll sync implementation#256511
eokoneyo merged 4 commits into
elastic:mainfrom
eokoneyo:patch/improve-scroll-sync-implementation

Conversation

@eokoneyo
Copy link
Copy Markdown
Contributor

@eokoneyo eokoneyo commented Mar 6, 2026

Summary

This PR is part of the cascade performance improvement work(#255745), culled from #256037.

Refactors the ScrollSyncProvider to eliminate per-row scroll overhead by centralizing scroll coordination into a single capture-phase listener with an allocation-free hot path.

What changed

  • Single capture-phase scroll listener: Replaced N individual resize observers and onScroll React handlers (one per row) with a single native scroll listener attached at the provider's wrapper element using { capture: true, passive: true }. This bypasses React's synthetic event system entirely for programmatic scroll events and uses a scroll-leader pattern to prevent the O(N^2) feedback cascade where each scrollTo write re-triggers handlers on all other rows.

  • useSyncExternalStore for scroll state: Replaced per-row useState + onScroll with a centralized pub/sub store. The provider computes three boolean flags (isScrollable, canScrollLeft, canScrollRight) and only emits to subscribers when the booleans actually change — not on every pixel of scroll. Consumers call useSyncExternalStore(subscribe, getSnapshot) for batched, stable re-renders.

  • Cached layout dimensions: scrollWidth and clientWidth are cached in the provider and only refreshed via ResizeObserver or on hover. The scroll hot path never reads these layout-forcing properties from the DOM.

  • Zero-allocation scroll hot path: All intermediate objects are mutated in place to minimize GC pressure:

    • Scroll state booleans are compared inline; a new snapshot object is only allocated when they change (~2-3 times per session, not per event).
    • A single reusable ScrollToOptions ref is shared across all scrollTo calls instead of allocating { left, behavior: 'instant' } per container per event.
    • cachedDimensions are updated via property assignment, not object replacement.
    • for...of loops replace .forEach to avoid closure allocation.
    • The scroll-leader timeout callback is hoisted to a stable useCallback ref.
  • Instant programmatic scrolling: All programmatic scrollTo calls use { behavior: 'instant' } to override the CSS scroll-behavior: smooth on scroll containers, ensuring synchronized rows update in the same frame as the leader. User-initiated smooth scroll (e.g. arrow button clicks) is unaffected since those rely on the CSS property directly.

Performance results

Measured via Chrome DevTools traces on a data cascade with ~40 visible rows, comparing the optimized implementation against the original baseline:

Category Metric Baseline Optimized Change
GC Events 4,949 3,818 -23%
Time 2,121 ms 2,099 ms -1% (parity)
Layout Style recalc time 722 ms 668 ms -7%
Layout time 47 ms 40 ms -15%
Frames P95 inter-frame gap 111 ms 111 ms parity
P50 FPS 59.9 59.8 parity

Key wins: GC events are 23% below baseline, layout/style costs are reduced, and the P95 inter-frame gap and median FPS are at parity. The architectural shift from N independent scroll handlers to a single coordinated listener also reduces the total number of scroll dispatch events by 26%.

Most importantly we are giving back rendering time to consumers of the component.

How to test

  • Verify that scroll sync works;
    • Navigate to the storybook link that show cases the cascade rendering multiple stats, on resizing the viewport to the extent where the stats get clipped, hovering over any one of the rows should reveal the scroll button
    • Clicking the scroll button should move all rows in the specified direction

@eokoneyo eokoneyo self-assigned this Mar 6, 2026
@eokoneyo eokoneyo added release_note:skip Skip the PR/issue when compiling release notes Team:SharedUX Platform AppEx-SharedUX (formerly Global Experience) t// backport:version Backport to applied version labels v9.3.2 labels Mar 6, 2026
@eokoneyo
Copy link
Copy Markdown
Contributor Author

eokoneyo commented Mar 6, 2026

/ci

@kibanamachine
Copy link
Copy Markdown
Contributor

@eokoneyo eokoneyo marked this pull request as ready for review March 7, 2026 00:04
@eokoneyo eokoneyo requested a review from a team as a code owner March 7, 2026 00:04
@elasticmachine
Copy link
Copy Markdown
Contributor

Pinging @elastic/appex-sharedux (Team:SharedUX)

@eokoneyo
Copy link
Copy Markdown
Contributor Author

eokoneyo commented Mar 7, 2026

@elasticmachine merge upstream

@eokoneyo
Copy link
Copy Markdown
Contributor Author

eokoneyo commented Mar 9, 2026

@elasticmachine merge upstream

@eokoneyo
Copy link
Copy Markdown
Contributor Author

eokoneyo commented Mar 9, 2026

@elasticmachine merge upstream

@eokoneyo eokoneyo changed the title Improve scroll sync implementation [Cascade] Improve scroll sync implementation Mar 9, 2026
@elasticmachine
Copy link
Copy Markdown
Contributor

💚 Build Succeeded

Metrics [docs]

Module Count

Fewer modules leads to a faster build time

id before after diff
discover 1978 1979 +1

Async chunks

Total size of all lazy-loaded chunks that will be downloaded as the user navigates the app

id before after diff
discover 1.6MB 1.6MB +1.7KB

History

cc @eokoneyo

Copy link
Copy Markdown
Member

@kowalczyk-krzysztof kowalczyk-krzysztof left a comment

Choose a reason for hiding this comment

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

I like the changes but there's a lot of refs and listeners - could you check if everything works as expected on the consumer side by temporarily enabling React 18 concurrent mode in Discover (src/platform/plugins/shared/discover/public/application/index.tsx)?

@eokoneyo
Copy link
Copy Markdown
Contributor Author

I like the changes but there's a lot of refs and listeners - could you check if everything works as expected on the consumer side by temporarily enabling React 18 concurrent mode in Discover (src/platform/plugins/shared/discover/public/application/index.tsx)?

Tested in concurrent mode, works well.

@eokoneyo eokoneyo merged commit a15a512 into elastic:main Mar 10, 2026
18 checks passed
@eokoneyo eokoneyo deleted the patch/improve-scroll-sync-implementation branch March 10, 2026 19:11
@kibanamachine
Copy link
Copy Markdown
Contributor

Starting backport for target branches: 9.3

https://github.com/elastic/kibana/actions/runs/22919854670

@kibanamachine
Copy link
Copy Markdown
Contributor

💔 All backports failed

Status Branch Result
9.3 Backport failed because of merge conflicts

Manual backport

To create the backport manually run:

node scripts/backport --pr 256511

Questions ?

Please refer to the Backport tool documentation

qn895 pushed a commit to qn895/kibana that referenced this pull request Mar 11, 2026
## Summary

This PR is part of the cascade performance improvement
work(elastic#255745), culled from
elastic#256037.

Refactors the `ScrollSyncProvider` to eliminate per-row scroll overhead
by centralizing scroll coordination into a single capture-phase listener
with an allocation-free hot path.

### What changed

- **Single capture-phase scroll listener**: Replaced N individual resize
observers and `onScroll` React handlers (one per row) with a single
native `scroll` listener attached at the provider's wrapper element
using `{ capture: true, passive: true }`. This bypasses React's
synthetic event system entirely for programmatic scroll events and uses
a scroll-leader pattern to prevent the O(N^2) feedback cascade where
each `scrollTo` write re-triggers handlers on all other rows.

- **`useSyncExternalStore` for scroll state**: Replaced per-row
`useState` + `onScroll` with a centralized pub/sub store. The provider
computes three boolean flags (`isScrollable`, `canScrollLeft`,
`canScrollRight`) and only emits to subscribers when the booleans
actually change — not on every pixel of scroll. Consumers call
`useSyncExternalStore(subscribe, getSnapshot)` for batched, stable
re-renders.

- **Cached layout dimensions**: `scrollWidth` and `clientWidth` are
cached in the provider and only refreshed via `ResizeObserver` or on
hover. The scroll hot path never reads these layout-forcing properties
from the DOM.

- **Zero-allocation scroll hot path**: All intermediate objects are
mutated in place to minimize GC pressure:
- Scroll state booleans are compared inline; a new snapshot object is
only allocated when they change (~2-3 times per session, not per event).
- A single reusable `ScrollToOptions` ref is shared across all
`scrollTo` calls instead of allocating `{ left, behavior: 'instant' }`
per container per event.
- `cachedDimensions` are updated via property assignment, not object
replacement.
  - `for...of` loops replace `.forEach` to avoid closure allocation.
- The scroll-leader timeout callback is hoisted to a stable
`useCallback` ref.

- **Instant programmatic scrolling**: All programmatic `scrollTo` calls
use `{ behavior: 'instant' }` to override the CSS `scroll-behavior:
smooth` on scroll containers, ensuring synchronized rows update in the
same frame as the leader. User-initiated smooth scroll (e.g. arrow
button clicks) is unaffected since those rely on the CSS property
directly.

### Performance results

Measured via Chrome DevTools traces on a data cascade with ~40 visible
rows, comparing the optimized implementation against the original
baseline:

| Category | Metric | Baseline | Optimized | Change |
|---|---|---|---|---|
| **GC** | Events | 4,949 | 3,818 | **-23%** |
| | Time | 2,121 ms | 2,099 ms | **-1%** (parity) |
| **Layout** | Style recalc time | 722 ms | 668 ms | **-7%** |
| | Layout time | 47 ms | 40 ms | **-15%** |
| **Frames** | P95 inter-frame gap | 111 ms | 111 ms | parity |
| | P50 FPS | 59.9 | 59.8 | parity |

**Key wins**: GC events are 23% below baseline, layout/style costs are
reduced, and the P95 inter-frame gap and median FPS are at parity. The
architectural shift from N independent scroll handlers to a single
coordinated listener also reduces the total number of scroll dispatch
events by 26%.

Most importantly we are giving back rendering time to consumers of the
component.

How to test

- Verify that scroll sync works;
- Navigate to the storybook link that show cases the cascade rendering
multiple stats, on resizing the viewport to the extent where the stats
get clipped, hovering over any one of the rows should reveal the scroll
button
- Clicking the scroll button should move all rows in the specified
direction

<!--

### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
- [ ] Review the [backport
guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing)
and apply applicable `backport:*` labels.

### Identify risks

Does this PR introduce any risks? For example, consider risks like hard
to test bugs, performance regression, potential of data loss.

Describe the risk, its severity, and mitigation for each identified
risk. Invite stakeholders and evaluate how to proceed before merging.

- [ ] [See some risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx)
- [ ] ...


-->

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
@kibanamachine kibanamachine added the backport missing Added to PRs automatically when the are determined to be missing a backport. label Mar 11, 2026
@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

1 similar comment
@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

26 similar comments
@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

@kibanamachine
Copy link
Copy Markdown
Contributor

Friendly reminder: Looks like this PR hasn’t been backported yet.
To create automatically backports add a backport:* label or prevent reminders by adding the backport:skip label.
You can also create backports manually by running node scripts/backport --pr 256511 locally
cc: @eokoneyo

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

Labels

backport missing Added to PRs automatically when the are determined to be missing a backport. backport:version Backport to applied version labels release_note:skip Skip the PR/issue when compiling release notes Team:SharedUX Platform AppEx-SharedUX (formerly Global Experience) t// v9.3.2 v9.4.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants