Skip to content

perf: cache getBoundingClientRect in EventHandler to avoid layout thrashing - #823

Open
NemeZZiZZ wants to merge 1 commit into
klinecharts:mainfrom
NemeZZiZZ:perf/cache-bounding-client-rect
Open

perf: cache getBoundingClientRect in EventHandler to avoid layout thrashing#823
NemeZZiZZ wants to merge 1 commit into
klinecharts:mainfrom
NemeZZiZZ:perf/cache-bounding-client-rect

Conversation

@NemeZZiZZ

Copy link
Copy Markdown
Contributor

Problem

EventHandler._makeCompatEvent (called on every mousemove / touchmove /
mousedown) and _startPinch call this._target.getBoundingClientRect() on
every event. On hi-DPI trackpads that is 120+ events per second, and
getBoundingClientRect() forces a synchronous layout reflow — the browser
must compute the full layout before returning the rect. This is the classic
layout-thrashing pattern and shows up as jank during pan / zoom / hover.

Fix

Cache the rect in _boundingRectCache and invalidate it on the events that can
move or resize the target:

private _boundingRectCache: Nullable<DOMRect> = null

private _readBoundingRect(): DOMRect {
  if (this._boundingRectCache === null) {
    this._boundingRectCache = this._target.getBoundingClientRect()
  }
  return this._boundingRectCache
}

Invalidation triggers:

  1. scroll — passive window listener with capture: true. scroll does not
    bubble, but capture-phase listeners on ancestors fire for descendant scroll
    containers, so scrolling any nested container invalidates the cache. passive: true
    never blocks scrolling.
  2. resizeResizeObserver on _target (covers window resize and
    layout-caused target resize).
  3. mouseEnter — refresh at the start of each hover session (a natural point:
    mousemove is already re-registered in _mouseEnterHandler).

The two getBoundingClientRect() call sites (_makeCompatEvent, _startPinch)
are switched to _readBoundingRect(). Listeners and the observer are removed in
destroy() (the chain Chart.destroyEvent.destroyEventHandler.destroy
reaches it), so there is no leak.

isValid(ResizeObserver) is used for the observer guard, matching the existing
pattern in Chart.ts:194.

Known limitation

If the target is moved by a CSS transform / margin on an ancestor without a
scroll or resize (pure positional translation), the cache is not invalidated and
the crosshair stays offset by the delta until the next mouseEnter. This is rare
in stable chart layouts and self-corrects on the next hover session — the same
tradeoff used by lightweight-charts. If it matters for a given use case, a
MutationObserver on ancestors could be added (at extra overhead).

Verification

  • _readBoundingRect() only replaces the read; _makeCompatEvent / _startPinch
    only read box.left / box.top (no mutation), so returning the cached object is safe.
  • addEventListener('scroll', invalidate, { passive: true, capture: true }) and
    removeEventListener('scroll', invalidate, { capture: true }) match on the capture
    flag (the only flag removeEventListener considers for matching), so the
    listener is removed cleanly in destroy().
  • pnpm code-lint — pass (154 files, no fixes)
  • pnpm type-check — pass
  • pnpm build-umd:prod — pass

Notes

  • EventHandler is instantiated once per chart; _target is the chart container.
  • No public API change. Manual browser verification (crosshair accuracy after
    page scroll and window resize, pan/zoom smoothness on trackpad) is recommended.

@liihuu

liihuu commented Aug 5, 2026

Copy link
Copy Markdown
Member

Are there any important data comparisons before and after optimization?

@NemeZZiZZ

Copy link
Copy Markdown
Contributor Author

Benchmark data (measured on current main vs this branch, production UMD builds, Chromium, 1000 dispatched mousemove events over a live chart with MA + VOL, median of 3 runs):

scenario main this PR
clean layout (idle page) 13.7 ms / 1000 events 12.5 ms / 1000 events
dirty layout (DOM around the chart mutated between events) 977.9 ms 895.2 ms

Instrumented geometry reads during the dirty run (1000 events):

main this PR
getBoundingClientRect 1000 (1 per event) 0 — cache works as intended
clientHeight 12 701 (~12.7 per event) 12 701 (unchanged)

So the cache does eliminate every getBoundingClientRect on the event path, but the end-to-end win in this scenario is limited (~8%) — because Pane.update() (src/pane/Pane.ts:59) reads this._container.clientHeight on every updatePane() call, i.e. ~12.7 forced layout reads per mousemove with a moving crosshair. Whichever geometry read comes first after a DOM mutation pays the reflow, and on the mousemove path it is almost always clientHeight, not getBoundingClientRect.

In other words: this PR is correct and regression-free (clean case unchanged, listener lifecycle verified), but on its own it does not remove the layout thrashing on the crosshair path — Pane.update() needs the same treatment (cache the height check against the last applied value instead of reading clientHeight, or rely on the existing ResizeObserver) to get the full effect.

Happy to prepare that follow-up as a separate PR if you want the full fix, or to close this one if you would rather solve both in one place.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants