Skip to content

Commit a577d75

Browse files
ryan-karnRyan Karn
andauthored
fix: resolve android touch event bleed between sandbox and host surfaces (#33)
* fix: resolve android touch event bleed between sandbox and host surfaces Touch events from a sandbox Fabric surface were leaking into the host surface on Android. Both surfaces share the same global React view tag namespace, so when a sandbox view and host view are assigned the same tag, touch events targeting that tag in the sandbox are also resolved by the host's Fabric renderer. Fix: intercept touch events at the Window level before they reach the host's ReactSurfaceView. If a touch lands inside a sandbox's bounds, route it directly to the sandbox's child surface view, bypassing the host dispatch entirely. Includes a touch-test-demo app to reproduce and verify the fix. Ref: #27 * chore: update bun.lock and fix ktlint formatting --------- Co-authored-by: Ryan Karn <rkarn@amazon.com>
1 parent 94232fb commit a577d75

41 files changed

Lines changed: 1699 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/touch-test-demo/App.tsx

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/**
2+
* Touch Test Demo
3+
*
4+
* Single scrollable page combining Touch Bleed and Overlay tests.
5+
* Minimal host views to keep tag values low and predictable.
6+
*/
7+
import SandboxReactNativeView from '@callstack/react-native-sandbox'
8+
import React, {useState} from 'react'
9+
import {
10+
SafeAreaView,
11+
ScrollView,
12+
StyleSheet,
13+
Text,
14+
TouchableOpacity,
15+
View,
16+
} from 'react-native'
17+
18+
// ─── Padding helper ─────────────────────────────────────────────────────────
19+
20+
function TagPadding({count}: {count: number}) {
21+
return (
22+
<>
23+
{Array.from({length: count}, (_, i) => (
24+
<View key={i} style={styles.pad} />
25+
))}
26+
</>
27+
)
28+
}
29+
30+
// ─── App ────────────────────────────────────────────────────────────────────
31+
32+
export default function App() {
33+
const [pressCount, setPressCount] = useState(0)
34+
const [hostTag, setHostTag] = useState<number | null>(null)
35+
const [overlayVisible, setOverlayVisible] = useState(false)
36+
const [overlayPressCount, setOverlayPressCount] = useState(0)
37+
const [overlayBtnTag, setOverlayBtnTag] = useState<number | null>(null)
38+
39+
return (
40+
<SafeAreaView style={styles.container}>
41+
<ScrollView>
42+
{/* ── Test 1: Touch Bleed ── */}
43+
<View style={styles.section}>
44+
<Text style={styles.title}>Test 1: Touch Bleed</Text>
45+
<Text style={styles.description}>
46+
Press a sandbox button whose tag matches the host button tag. Watch
47+
if the host button highlights or press count increases.
48+
</Text>
49+
50+
<TagPadding count={10} />
51+
52+
<TouchableOpacity
53+
style={styles.hostButton}
54+
onLayout={e => {
55+
const tag = (e as any).nativeEvent?.target
56+
if (tag != null) setHostTag(tag)
57+
}}
58+
onPress={() => setPressCount(c => c + 1)}>
59+
<Text style={styles.buttonText}>
60+
Host Button{hostTag != null ? ` (tag: ${hostTag})` : ''}
61+
</Text>
62+
</TouchableOpacity>
63+
<Text style={styles.result}>
64+
Press count: {pressCount}{' '}
65+
{pressCount > 0 ? '❌ BLEED DETECTED' : '✅ No bleed'}
66+
</Text>
67+
</View>
68+
69+
<View style={styles.sandboxSection}>
70+
<Text style={styles.sectionHeader}>Sandbox (Touch Bleed)</Text>
71+
<SandboxReactNativeView
72+
style={styles.sandbox}
73+
componentName={'SandboxedDemo'}
74+
jsBundleSource={'sandbox.android.bundle'}
75+
onError={error => console.warn('Sandbox error:', error)}
76+
/>
77+
</View>
78+
79+
<View style={styles.divider} />
80+
81+
{/* ── Test 2: Overlay over Sandbox ── */}
82+
<View style={styles.section}>
83+
<Text style={styles.title}>Test 2: Overlay over Sandbox</Text>
84+
<Text style={styles.description}>
85+
Tests that host views rendered on top of a sandbox correctly receive
86+
touches without bleeding into sandbox buttons.{'\n\n'}
87+
Expected behavior:{'\n'}• Overlay buttons — should fire (count
88+
increments){'\n'}• Card area over sandbox — should NOT fire sandbox
89+
buttons{'\n'}• Grey backdrop — should block sandbox touches{'\n'}
90+
Top-half sandbox buttons (no overlay) — should work normally
91+
</Text>
92+
93+
<TouchableOpacity
94+
style={styles.actionButton}
95+
onLayout={e => {
96+
const tag = (e as any).nativeEvent?.target
97+
if (tag != null) setOverlayBtnTag(tag)
98+
}}
99+
onPress={() => setOverlayVisible(v => !v)}>
100+
<Text style={styles.buttonText}>
101+
{overlayVisible ? 'Hide Overlay' : 'Show Overlay'}
102+
{overlayBtnTag != null ? ` (tag: ${overlayBtnTag})` : ''}
103+
</Text>
104+
</TouchableOpacity>
105+
106+
<Text style={styles.result}>
107+
Overlay presses: {overlayPressCount}
108+
{overlayPressCount > 0 ? ' ✅' : ''}
109+
</Text>
110+
</View>
111+
112+
<View style={styles.sandboxSection}>
113+
<Text style={styles.sectionHeader}>Sandbox (Overlay)</Text>
114+
<View style={{position: 'relative'}}>
115+
<SandboxReactNativeView
116+
style={styles.sandbox}
117+
componentName={'SandboxedDemo'}
118+
jsBundleSource={'sandbox.android.bundle'}
119+
onError={error => console.warn('Sandbox error:', error)}
120+
/>
121+
{overlayVisible && (
122+
<View style={overlayStyles.backdrop}>
123+
<View style={overlayStyles.card}>
124+
<Text style={overlayStyles.cardTitle}>Host Overlay</Text>
125+
<Text style={overlayStyles.cardDesc}>
126+
This view is rendered by the host ON TOP of the sandbox.
127+
Tapping the button below should work normally.
128+
</Text>
129+
<TouchableOpacity
130+
style={overlayStyles.overlayButton}
131+
onPress={() => setOverlayPressCount(c => c + 1)}>
132+
<Text style={styles.buttonText}>
133+
Tap Me (overlay) — count: {overlayPressCount}
134+
</Text>
135+
</TouchableOpacity>
136+
<TouchableOpacity
137+
style={[
138+
overlayStyles.overlayButton,
139+
{backgroundColor: '#ff3b30', marginTop: 8},
140+
]}
141+
onPress={() => setOverlayVisible(false)}>
142+
<Text style={styles.buttonText}>Dismiss</Text>
143+
</TouchableOpacity>
144+
</View>
145+
</View>
146+
)}
147+
</View>
148+
</View>
149+
</ScrollView>
150+
</SafeAreaView>
151+
)
152+
}
153+
154+
// ─── Styles ─────────────────────────────────────────────────────────────────
155+
156+
const overlayStyles = StyleSheet.create({
157+
backdrop: {
158+
position: 'absolute',
159+
top: '50%',
160+
left: '15%',
161+
right: '15%',
162+
bottom: 0,
163+
backgroundColor: 'rgba(0,0,0,0.3)',
164+
justifyContent: 'center',
165+
alignItems: 'center',
166+
zIndex: 10,
167+
elevation: 0,
168+
},
169+
card: {
170+
backgroundColor: '#fff',
171+
borderRadius: 12,
172+
borderWidth: 1,
173+
borderColor: '#000000ff',
174+
padding: 24,
175+
width: '65%',
176+
marginTop: -150,
177+
shadowColor: '#000',
178+
shadowOffset: {width: 0, height: 4},
179+
shadowOpacity: 0.3,
180+
shadowRadius: 8,
181+
elevation: 12,
182+
},
183+
cardTitle: {
184+
fontSize: 18,
185+
fontWeight: '700',
186+
marginBottom: 8,
187+
},
188+
cardDesc: {
189+
fontSize: 14,
190+
color: '#666',
191+
marginBottom: 16,
192+
lineHeight: 20,
193+
},
194+
overlayButton: {
195+
backgroundColor: '#34c759',
196+
paddingVertical: 14,
197+
borderRadius: 8,
198+
alignItems: 'center',
199+
},
200+
})
201+
202+
const styles = StyleSheet.create({
203+
container: {
204+
flex: 1,
205+
backgroundColor: '#fff',
206+
},
207+
section: {
208+
padding: 16,
209+
},
210+
title: {
211+
fontSize: 20,
212+
fontWeight: '700',
213+
marginBottom: 8,
214+
},
215+
sectionHeader: {
216+
fontSize: 16,
217+
fontWeight: '600',
218+
marginBottom: 8,
219+
},
220+
description: {
221+
fontSize: 14,
222+
color: '#666',
223+
marginBottom: 16,
224+
lineHeight: 20,
225+
},
226+
pad: {
227+
height: 1,
228+
},
229+
hostButton: {
230+
backgroundColor: '#007aff',
231+
paddingVertical: 14,
232+
borderRadius: 8,
233+
alignItems: 'center',
234+
marginBottom: 8,
235+
},
236+
actionButton: {
237+
backgroundColor: '#5856d6',
238+
paddingVertical: 12,
239+
borderRadius: 8,
240+
alignItems: 'center',
241+
marginTop: 12,
242+
marginBottom: 8,
243+
},
244+
buttonText: {
245+
color: '#fff',
246+
fontWeight: '600',
247+
fontSize: 16,
248+
},
249+
result: {
250+
fontSize: 15,
251+
marginVertical: 4,
252+
},
253+
sandboxSection: {
254+
padding: 16,
255+
borderTopWidth: 1,
256+
borderTopColor: '#ccc',
257+
},
258+
sandbox: {
259+
height: 400,
260+
borderWidth: 1,
261+
borderColor: '#8232ff',
262+
borderRadius: 4,
263+
},
264+
divider: {
265+
height: 8,
266+
backgroundColor: '#f0f0f0',
267+
marginVertical: 8,
268+
},
269+
})

apps/touch-test-demo/README.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Touch Test Demo
2+
3+
Reproduces and tests touch isolation bugs between host and sandbox React Native surfaces. The app deliberately aligns sandbox button view tags with host button view tags to expose tag collision issues.
4+
5+
## Background: Android vs iOS tag resolution
6+
7+
React Native assigns each native view an integer tag used internally to route touch events. On iOS, each React surface (host and sandbox) gets its own tag namespace — tags are scoped per surface, so collisions between host and sandbox are impossible by design. On Android, view tags are allocated from a single global counter shared across all surfaces in the process, meaning that tags in the host app and in a sandbox could occur.
8+
9+
10+
## What it tests
11+
12+
### Test 1: Touch Bleed
13+
14+
A host button and a sandbox button share the same React view tag (34). When you tap the sandbox button, the test checks whether the host button also receives the touch event. Touch events should be handled only in the proper surface.
15+
16+
### Test 2: Overlay over Sandbox
17+
18+
A host overlay card is rendered on top of the sandbox surface. A sandbox button is padded to share the same tag (92) as the host overlay button. The test checks:
19+
20+
- Overlay buttons receive touches normally
21+
- The overlay card blocks touches from reaching sandbox buttons underneath
22+
- Sandbox buttons not covered by the overlay still work
23+
- The grey backdrop area blocks sandbox touches
24+
25+
## How the tag alignment works
26+
27+
The sandbox component (`Sandbox.tsx`) inserts invisible padding `View` elements before specific buttons to consume tag IDs and push button tags to target values. Each padding view consumes ~2 tags on Android.
28+
29+
Current alignment:
30+
- Sandbox Button 2 → tag 34 (matches host button in Test 1)
31+
- Sandbox Button 3 → tag 92 (matches host overlay button in Test 2)
32+
33+
Tag values may be device/platform-dependent. If they drift after changes, adjust the `PADDING_BEFORE_BUTTON` values in `Sandbox.tsx`.
34+
35+
> **Fragility note:** The padding counts are sensitive to the number of host views rendered before the sandbox surface starts, React Native's internal tag allocation strategy, and the number of views inside the sandbox before each button. These may change across RN versions. The on-screen `(tag: N)` labels in the demo UI make it easy to spot drift — if the displayed tags no longer match the expected collision values, update `PADDING_BEFORE_BUTTON` in `Sandbox.tsx` accordingly.
36+
37+
## Build steps (Android release)
38+
39+
All commands run from the monorepo root (`react-native-sandbox/`).
40+
41+
### 1. Install dependencies
42+
43+
```bash
44+
yarn install
45+
```
46+
47+
### 2. Bundle the sandbox JS
48+
49+
From `apps/touch-test-demo/`:
50+
51+
```bash
52+
npx react-native bundle \
53+
--platform android \
54+
--dev false \
55+
--entry-file sandbox.js \
56+
--bundle-output android/app/src/main/assets/sandbox.android.bundle \
57+
--assets-dest android/app/src/main/res/
58+
```
59+
60+
### 3. Generate codegen artifacts
61+
62+
From `apps/touch-test-demo/android/`:
63+
64+
```bash
65+
./gradlew :callstack_react-native-sandbox:generateCodegenArtifactsFromSchema
66+
```
67+
68+
### 4. Build the release APK
69+
70+
From `apps/touch-test-demo/android/`:
71+
72+
```bash
73+
./gradlew assembleRelease
74+
```
75+
76+
The APK is at `android/app/build/outputs/apk/release/app-release.apk`.
77+
78+
### 5. Install and launch
79+
80+
```bash
81+
adb install android/app/build/outputs/apk/release/app-release.apk
82+
adb shell am start -n com.touchtestdemo/.MainActivity
83+
```

0 commit comments

Comments
 (0)