Skip to content

fix(android): touch interceptor treats box-none dev overlay as obscuring → sandbox untappable in debug#39

Merged
CAMOBAP merged 1 commit into
callstackincubator:mainfrom
akelmanson:fix/touch-interceptor-pointerevents
Jun 29, 2026
Merged

fix(android): touch interceptor treats box-none dev overlay as obscuring → sandbox untappable in debug#39
CAMOBAP merged 1 commit into
callstackincubator:mainfrom
akelmanson:fix/touch-interceptor-pointerevents

Conversation

@akelmanson

Copy link
Copy Markdown
Contributor

Problem

Touches that land inside a SandboxReactNativeView are silently dropped in debug builds — the sandbox content renders but is completely untappable (taps and scrolls). The exact same build works in release, which makes it easy to dismiss as flaky or environment-specific.

Root cause

SandboxTouchInterceptor.isObscuredAt decides whether a sandbox is covered by an overlay using only the sibling's visibility + screen bounds — it never checks pointerEvents.

In dev, React Native's AppContainer mounts a full-screen pointerEvents="box-none" overlay (for LogBox and the element inspector), drawn on top of the whole app. Visually it's transparent to touch, but isObscuredAt sees a visible, full-screen sibling drawn after the sandbox and reports it as obscuring. The interceptor then takes the redispatchWithHiddenSandbox path: it hides the sandbox surface and re-dispatches through the normal pipeline, so the event lands on the (empty) host behind it instead of the sandbox. Result: every touch in the sandbox is stolen.

Release has no such overlay, so it only reproduces in debug — which is why it's easy to miss.

Repro

  1. Embed a SandboxReactNativeView with any tappable content in a debug build (New Arch / bridgeless, RN 0.85).
  2. Tap/scroll inside the sandbox → nothing happens.
  3. Same build in release → works.

(Confirmed by logging isObscuredAt: it returns the AppContainer's ReactViewGroup — a full-screen box-none view drawn after the sandbox.)

Fix

In isObscuredAt, respect pointerEvents:

  • NONE → fully transparent to touch; skip the sibling entirely.
  • BOX_NONE → its own box passes touch through (only its children can catch); skip the own-bounds test but still check descendants.

Three lines, no behavior change for real overlays (Modals etc. keep pointerEvents auto). Follow-up to #33, which introduced the interceptor.

🤖 Generated with Claude Code

`SandboxTouchInterceptor.isObscuredAt` decided whether a sandbox is covered by
an overlay using only the sibling's visibility + bounds — it never checked
`pointerEvents`. React Native's AppContainer adds a full-screen
`pointerEvents="box-none"` overlay in dev (LogBox / the element inspector),
drawn on top of everything. That overlay was treated as obscuring, so every
touch landing in a sandbox was rerouted via `redispatchWithHiddenSandbox` and
the sandbox became untappable — but only in debug builds (release has no such
overlay), which makes it very easy to miss.

Fix: skip siblings whose `pointerEvents` is NONE (fully transparent to touch),
and skip the own-bounds test for BOX_NONE (its own box passes touch through;
only its children can catch). Descendant checks are unchanged.

Follow-up to callstackincubator#33 (the interceptor introduced there).
@ryan-karn

Copy link
Copy Markdown
Contributor

Change LGTM as follow up to the introduction of the SandboxTouchInterceptor to fix the touch bleed events.

That said, I think a similar issue could manifest in the hasDescendantAt method:

private fun hasDescendantAt(viewGroup: ViewGroup, screenX: Int, screenY: Int): Boolean {
      for (i in 0 until viewGroup.childCount) {
          val child = viewGroup.getChildAt(i)
          if (child.visibility != View.VISIBLE) continue  // only checks visibility
          // ...
          if (childRect.contains(screenX, screenY)) return true  // reports as obscuring
          if (child is ViewGroup && hasDescendantAt(child, ...)) return true
      }
  } 

It only checks visibility, never pointerEvents. So for a structure like:

ReactViewGroup (pointerEvents="box-none", full-screen) ← dev overlay
└── ReactViewGroup (pointerEvents="none", VISIBLE, full-screen) ← e.g. a diagnostic layer

The fix correctly skips the parent's own bounds. Then hasDescendantAt walks into the child, sees it's VISIBLE, sees its bounds contain the point, and returns true — sandbox is reported obscured, touches are stolen again.

Potential fix:

  private fun hasDescendantAt(viewGroup: ViewGroup, screenX: Int, screenY: Int): Boolean {
      for (i in 0 until viewGroup.childCount) {
          val child = viewGroup.getChildAt(i)
          if (child.visibility != View.VISIBLE) continue
          val childPE = (child as? ReactViewGroup)?.pointerEvents
          if (childPE == PointerEvents.NONE) continue
          // ... rest of bounds check
      }
  }

Haven't tested this myself, and think such a scenario would be unlikely, so reasonable to defer to separate follow up if needed IMO

@ryan-karn

Copy link
Copy Markdown
Contributor

Did some quick testing on my end, summarizing findings:

  1. isObscuredAt debug bug — reproduced: sandbox buttons untappable in debug due to the dev overlay
  2. isObscuredAt debug bug— verified: pointerEvents check in associated PR commit resolves it
  3. The hasDescendantAt debug gap mentioned in comment above — reproduced: visible pointerEvents="none" descendants still falsely report obscuring
  4. The hasDescendantAt fix — verified: extending the same pointerEvents logic to the recursive descendant walk resolves it

    389              val child = viewGroup.getChildAt(i)
    390              if (child.visibility != View.VISIBLE) continue
    391
    392 +            val childPE = (child as? ReactViewGroup)?.pointerEvents
    393 +            if (childPE == PointerEvents.NONE) continue
    394 +
    395              child.getLocationOnScreen(loc)
    396              val childRect =
    397                  Rect(
   ...
    400                      loc[0] + child.width,
    401                      loc[1] + child.height,
    402                  )
    400 -            if (childRect.contains(screenX, screenY)) return true
    403 +            if (childPE != PointerEvents.BOX_NONE) {
    404 +                if (childRect.contains(screenX, screenY)) return true
    405 +            }
    406              if (child is ViewGroup && hasDescendantAt(child, screenX, screenY)) return true
    407          }
    408          return false

@CAMOBAP CAMOBAP merged commit b6ccdc6 into callstackincubator:main Jun 29, 2026
5 of 6 checks passed
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.

3 participants