Skip to content

Android - touch events bleeding to components outside of sandbox #27

@ryan-karn

Description

@ryan-karn

Summary

When testing with the fs experiment demo (as of 5.0) on Android, I was seeing touch event seemingly firing twice. I was able to reproduce the issue after tweaking the basic demo slightly, see video below

video-demo-28.mp4

Analysis

This seems to be related to how fabric on android resolves view tags. Notably, to reproduce the demo, I added a bunch of empty padding views such that Host Button has tag 28 and the 3rd button in the sandbox also has tag 28.

Adding some AI-assisted analysis:

The host and sandbox Fabric surfaces share the same global React view tag namespace on Android. In Fabric, React tags are used as Android view IDs (view.setId(reactTag)). Both surfaces allocate tags sequentially starting from low numbers. When a sandbox view and a host view are assigned the same tag (e.g., both get tag 20), touch events targeting that tag in the sandbox are also resolved by the host's Fabric renderer to its own view at the same tag.
Why it happens

The Android event dispatch flow for a touch inside the sandbox:

  • Activity.dispatchTouchEvent() delivers the MotionEvent to the window
  • The event propagates down to the host's ReactSurfaceView.dispatchTouchEvent()
  • The host's ReactSurfaceView feeds the event into Fabric's C++ touch handler, which does hit-testing using the shadow tree and React tags — this is where the bleed occurs
  • ReactSurfaceView then calls super.dispatchTouchEvent(), propagating down the Android view tree
  • The event reaches SandboxReactNativeView, which forwards to the sandbox's ReactSurfaceView
  • The sandbox's Fabric processes the event correctly for its own surface

The host processes the event at step 3 before the sandbox sees it at step 5. Any fix applied at step 5 or later cannot prevent the bleed.

Why it's layout-dependent

The bug manifests only when a host touchable component happens to have the same React tag as a sandbox internal view. Adding or removing views in the host shifts tag assignments, which can trigger or hide the collision. This makes the bug appear intermittent and layout-dependent.

Why iOS is not affected

On iOS, Fabric uses UIView-based hit testing (hitTest:withEvent:) which walks the view hierarchy and returns the deepest view containing the touch point. The sandbox's RCTSurfaceHostingView naturally clips hit testing to its bounds. iOS's touch dispatch doesn't resolve tags globally across surfaces the way Android's ReactSurfaceView does.

Reproduction

Was able to reproduce following changes to the basic demo:

App.tsx


import SandboxReactNativeView from '@callstack/react-native-sandbox'
import React from 'react'
import {SafeAreaView, ScrollView, StyleSheet, Text, View} from 'react-native'
import Toast from 'react-native-toast-message'
import TouchBleedRepro from './TouchBleedRepro'
const DemoApp: React.FC = () => {
  return <TouchBleedRepro />
}
const styles = StyleSheet.create({})
export default DemoApp

CrashIfYouCanDemo.tsx

import React, {useState} from 'react'
import {
  LogBox,
  NativeModules,
  ScrollView,
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
} from 'react-native'

export default function CrashIfYouCanDemo() {
  const [counter, setCounter] = useState(0)
  const [status, setStatus] = useState('Ready')

  const triggerCrash = () => {
    setStatus('Crash triggered')
    // @ts-ignore
    global.nonExistentMethod() // Should crash the app
  }

  const overwriteGlobal = () => {
    setStatus('Overwrite triggered')
    // Overwrite console.log to something harmful
    console.log = () => {
      throw new Error('console.log has been hijacked!')
    }
    console.log('This will now throw') // This will crash or break logs
  }

  const accessBlockedTurboModule = () => {
    setStatus('Blocked module triggered')
    const FileReaderModule = NativeModules.FileReaderModule
    FileReaderModule.readAsText('/some/file.txt')
      .then((text: string) => console.log(text))
      .catch((err: any) => console.log(err.message))
  }

  const infiniteLoop = () => {
    setStatus('Infinite loop triggered')
    while (true) {}
  }

  const incrementCounter = () => {
    setCounter(counter + 1)
    setStatus(`Increment triggered: ${counter + 1}`)
  }

  return (
    <ScrollView contentContainerStyle={styles.container}>
      <Text style={styles.status}>Host status: {status}</Text>
      <TouchableOpacity style={styles.button} onPress={triggerCrash}>
        <Text style={styles.buttonText}>1. Crash App (undefined global)</Text>
      </TouchableOpacity>
      <View style={styles.spacer} />
      <TouchableOpacity style={styles.button} onPress={overwriteGlobal}>
        <Text style={styles.buttonText}>2. Overwrite Global (console.log)</Text>
      </TouchableOpacity>
      <View style={styles.spacer} />
      <TouchableOpacity style={styles.button} onPress={accessBlockedTurboModule}>
        <Text style={styles.buttonText}>3. Access Blocked TurboModule</Text>
      </TouchableOpacity>
      <View style={styles.spacer} />
      <TouchableOpacity style={styles.button} onPress={infiniteLoop}>
        <Text style={styles.buttonText}>4. Infinite Loop</Text>
      </TouchableOpacity>
      <View style={styles.spacer} />
      <TouchableOpacity style={styles.button} onPress={incrementCounter}>
        <Text style={styles.buttonText}>Increment {counter}</Text>
      </TouchableOpacity>
    </ScrollView>
  )
}

LogBox.ignoreAllLogs()

const styles = StyleSheet.create({
  container: {
    padding: 16,
    justifyContent: 'center',
  },
  spacer: {
    height: 16,
  },
  status: {
    fontSize: 14,
    fontStyle: 'italic',
    marginBottom: 16,
    color: '#666',
  },
  button: {
    backgroundColor: '#2196F3',
    paddingVertical: 12,
    paddingHorizontal: 16,
    borderRadius: 8,
    alignItems: 'center',
  },
  buttonText: {
    color: '#ffffff',
    fontWeight: '600',
    fontSize: 15,
  },
})

new TouchBleedRepo.tsx

/**
 * Minimal reproduction of touch bleed between host and sandbox surfaces.
 *
 * The bug: When the host's React view tags happen to be numerically adjacent
 * to the sandbox's ReactSurfaceView ID, touch events from the sandbox
 * "bleed" into the host — causing host buttons to show press highlights
 * (and sometimes fire handlers) when sandbox buttons are pressed.
 *
 * The padding Views below push the host button's React tag into the
 * collision zone with the sandbox's ReactSurfaceView ID (~31).
 * Remove the padding Views and the bleed disappears.
 */
import SandboxReactNativeView from '@callstack/react-native-sandbox'
import React, {useState} from 'react'
import {
  SafeAreaView,
  ScrollView,
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
} from 'react-native'
export default function TouchBleedRepro() {
  const [pressCount, setPressCount] = useState(0)
  return (
    <SafeAreaView style={styles.container}>
      <ScrollView>
        {/* Host section */}
        <View style={styles.section}>
          <Text style={styles.title}>Touch Bleed Repro</Text>
          <Text style={styles.subtitle}>
            Press the sandbox button below. Watch if the host button highlights.
          </Text>
          {/*
           * Adjust padding view count to push host button tag
           * near a sandbox internal button tag.
           * Sandbox buttons are at tags: 12, 20, 28, 36, 46
           */}
           
          {/* Padding to push host button tag to id 20 (matching 2nd button in sandbox) */}
         
          <View style={styles.pad} />
          <View style={styles.pad} />
          <View style={styles.pad} />
       
          {/* Padding to push host button tag to id 28 (matching 3rd button in sandbox) */}
          {/* 
          <View style={styles.pad} />
          <View style={styles.pad} />
          <View style={styles.pad} />
          <View style={styles.pad} />
          <View style={styles.pad} />
          <View style={styles.pad} />
          <View style={styles.pad} />
          */}
          <TouchableOpacity
            style={styles.hostButton}
            onLayout={(e) => {
              console.log(`[REPRO] Host button nativeTag=${(e as any).nativeEvent?.target}`)
            }}
            onPress={() => setPressCount(c => c + 1)}>
            <Text style={styles.buttonText}>Host Button</Text>
          </TouchableOpacity>
          <Text style={styles.counter}>Host press count: {pressCount}</Text>
        </View>
        {/* Sandbox section */}
        <View style={styles.sandboxSection}>
          <Text style={styles.title}>Sandbox</Text>
          <SandboxReactNativeView
            style={styles.sandbox}
            componentName={'SandboxedDemo'}
            jsBundleSource={'sandbox.android.bundle'}
            onError={error => {
              console.warn('Sandbox error:', error)
            }}
          />
        </View>
      </ScrollView>
    </SafeAreaView>
  )
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  section: {
    padding: 16,
  },
  title: {
    fontSize: 20,
    fontWeight: '700',
    marginBottom: 8,
  },
  subtitle: {
    fontSize: 14,
    color: '#666',
    marginBottom: 12,
  },
  pad: {
    height: 1,
  },
  counter: {
    fontSize: 16,
    marginBottom: 12,
    fontStyle: 'italic',
  },
  hostButton: {
    backgroundColor: '#007aff',
    paddingVertical: 14,
    borderRadius: 8,
    alignItems: 'center',
  },
  buttonText: {
    color: '#fff',
    fontWeight: '600',
    fontSize: 16,
  },
  sandboxSection: {
    padding: 16,
    borderTopWidth: 1,
    borderTopColor: '#ccc',
  },
  sandbox: {
    height: 400,
    borderWidth: 1,
    borderColor: '#8232ff',
    borderRadius: 4,
  },
})
video-demo-20.mp4

With the # of views changed, I can then see it fire on the second button.

Some logs I added to back up the view tag notion described above

04-08 10:43:40.821  3588  3621 I ReactNativeJS: [REPRO] Host button nativeTag=20 

04-08 10:43:44.408  3588  3588 D SandboxTouch: TOUCH_DOWN local=(808.0,153.0) raw=(853.0,821.0) viewOnScreen=(45,668) viewSize=(990x1044) myId=34 childCount=1
04-08 10:43:44.409  3588  3588 D SandboxTouch: child class=ReactSurfaceView childId=31
04-08 10:43:44.409  3588  3588 D SandboxTouch:   child[0] class=ReactScrollView id=50
04-08 10:43:44.409  3588  3588 D SandboxTouch:     child[0] class=ReactViewGroup id=48
04-08 10:43:44.409  3588  3588 D SandboxTouch:       child[0] class=ReactTextView id=6
04-08 10:43:44.409  3588  3588 D SandboxTouch:       child[1] class=ReactViewGroup id=12
04-08 10:43:44.409  3588  3588 D SandboxTouch:         child[0] class=ReactTextView id=10
04-08 10:43:44.409  3588  3588 D SandboxTouch:       child[2] class=ReactViewGroup id=20        
04-08 10:43:44.409  3588  3588 D SandboxTouch:         child[0] class=ReactTextView id=18
04-08 10:43:44.409  3588  3588 D SandboxTouch:       child[3] class=ReactViewGroup id=28
04-08 10:43:44.409  3588  3588 D SandboxTouch:       child[4] class=ReactViewGroup id=36
04-08 10:43:44.409  3588  3588 D SandboxTouch:       child[5] class=ReactViewGroup id=46

I also did some testing with focus using textInput as well as accessibility testing which all seemed to work as expected. I'm thus far only see this tag resolution present an issue with touch events

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions