Skip to content

Perf/pointer event handling#9

Open
gmacmaster wants to merge 2 commits intomainfrom
perf/pointer-event-handling
Open

Perf/pointer event handling#9
gmacmaster wants to merge 2 commits intomainfrom
perf/pointer-event-handling

Conversation

@gmacmaster
Copy link
Copy Markdown

No description provided.

gmacmaster and others added 2 commits April 17, 2026 22:50
m_capturedPointers was a vector searched linearly with std::find() on
every pointer move/press/release event at 60Hz. Changed to
unordered_set for O(1) lookup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GetTouchableViewsInPathToRoot() walked from target to root on every
pointer event. Added single-entry cache keyed by target tag to avoid
redundant walks for consecutive events on the same component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 18, 2026

Greptile Summary

This PR makes two performance improvements to pointer-event handling: (1) converts m_capturedPointers from std::vector to std::unordered_set for O(1) lookup/erase, and (2) adds a single-entry cache in GetTouchableViewsInPathToRoot (promoted from static to an instance method) to skip repeated ancestor-tree walks when the same leaf component fires back-to-back pointer events.

  • Cache invalidation gap (P1): The cache is keyed only on componentView.Tag() and is never cleared when the Fabric tree is mutated. A re-render that restructures ancestors while preserving a leaf's tag will leave a stale path in m_cachedEventPath, causing incorrect enter/leave events to fire.
  • Sentinel collision (P2): m_cachedEventPathTag is initialised to -1, the same sentinel used elsewhere (e.g. m_pointerCapturingComponentTag{-1}); prefer std::optional<facebook::react::Tag> to make the uninitialised state unambiguous.

Confidence Score: 4/5

Safe to merge with low risk for most interaction patterns, but the cache's lack of tree-mutation invalidation is a correctness hazard that should be addressed before shipping.

The unordered_set refactor is clean and correct. The cache optimisation is the right idea, but missing invalidation logic means stale paths can be returned after a Fabric tree commit that restructures ancestors of a still-live leaf node — a realistic scenario in dynamic React Native UIs. This warrants a P1 fix before merge.

vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp — specifically the GetTouchableViewsInPathToRoot cache read/write and its interaction with Fabric tree commits.

Important Files Changed

Filename Overview
vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.cpp Adds a single-entry cache for GetTouchableViewsInPathToRoot and replaces vector-based captured-pointer tracking with unordered_set; the cache lacks invalidation on tree mutations, which can cause stale ancestor paths during pointer event dispatch.
vnext/Microsoft.ReactNative/Fabric/Composition/CompositionEventHandler.h Promotes GetTouchableViewsInPathToRoot and IsPointerWithinInitialTree to instance methods, adds unordered_set for captured pointers, and introduces cache member fields; sentinel initialisation of m_cachedEventPathTag to -1 creates an ambiguous initial state.

Sequence Diagram

sequenceDiagram
    participant OS as OS Input
    participant CEH as CompositionEventHandler
    participant Cache as m_cachedEventPath
    participant Tree as ComponentView Tree

    OS->>CEH: onPointerMoved(pointerPoint)
    CEH->>CEH: getTargetPointerArgs() → tag
    CEH->>CEH: HandleIncomingPointerEvent(targetView)
    CEH->>Cache: GetTouchableViewsInPathToRoot(targetView)
    alt cache hit (same tag)
        Cache-->>CEH: return m_cachedEventPath (⚠ may be stale if tree mutated)
    else cache miss
        CEH->>Tree: walk parent chain
        Tree-->>CEH: path[]
        CEH->>Cache: store tag + path
        Cache-->>CEH: return path
    end
    CEH->>CEH: fire enter/leave events along path

    OS->>CEH: onPointerReleased(pointerPoint)
    CEH->>CEH: DispatchTouchEvent(End)
    CEH->>CEH: IsPointerWithinInitialTree(activeTouch)
    CEH->>Cache: GetTouchableViewsInPathToRoot(initialComponentView)
    Cache-->>CEH: path (likely cache miss — different tag)
    CEH->>CEH: onClick / onAuxClick if target in path
Loading

Reviews (1): Last reviewed commit: "Cache event path to root to avoid repeat..." | Re-trigger Greptile

Comment on lines +669 to +673
if (componentView) {
auto tag = componentView.Tag();
if (tag == m_cachedEventPathTag) {
return m_cachedEventPath;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Cache has no invalidation on tree mutations

The cache is keyed solely on componentView.Tag(). In Fabric, a parent view can be inserted, removed, or replaced between pointer events (via a state-driven re-render) while the leaf component retains its tag. When that happens the cached path m_cachedEventPath becomes stale, and HandleIncomingPointerEvent / IsPointerWithinInitialTree will traverse the wrong set of ancestors — firing enter/leave events on views that are no longer in the path, or missing views that were added.

A minimal fix is to clear the cache at the start of each top-level event dispatch (e.g., in onPointerMoved, onPointerPressed, etc.) rather than relying on tag identity alone.

winrt::Microsoft::UI::Input::InputCursor m_inputCursor{nullptr};

// Single-entry cache for GetTouchableViewsInPathToRoot to avoid repeated tree walks
facebook::react::Tag m_cachedEventPathTag{-1};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Cache sentinel -1 collides with valid tag values

m_cachedEventPathTag is initialised to -1. If any component view legitimately returns Tag() == -1 (a value that is also used as a sentinel elsewhere in this file, e.g. m_pointerCapturingComponentTag{-1}), the very first call to GetTouchableViewsInPathToRoot with that view would hit the cache and return an empty vector instead of computing the real path.

Using std::optional<facebook::react::Tag> for the key makes the uninitialised state unambiguous:

// .h
std::optional<facebook::react::Tag> m_cachedEventPathTag;
std::vector<winrt::Microsoft::ReactNative::ComponentView> m_cachedEventPath;
// .cpp — cache-read guard
if (componentView) {
  auto tag = componentView.Tag();
  if (m_cachedEventPathTag.has_value() && tag == *m_cachedEventPathTag) {
    return m_cachedEventPath;
  }
}
// ...
if (componentView) {
  m_cachedEventPathTag = componentView.Tag();
  m_cachedEventPath = results;
}

Comment on lines 1067 to 1068

if (m_pointerCapturingComponentTag) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Unnecessary copy to unordered_set in onPointerCaptureLost

The comment says "copy array to avoid iterator being invalidated during deletion", which was the original intent. Now that m_capturedPointers is already an unordered_set, copying it into another unordered_set is still correct but wasteful. A plain std::vector<PointerId> snapshot is lighter — it just needs to be iterable, not to support O(1) lookup:

Suggested change
if (m_pointerCapturingComponentTag) {
std::vector<PointerId> capturedPointers(m_capturedPointers.begin(), m_capturedPointers.end());

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.

1 participant