Summary:
On iOS, a programmatic non-animated scroll — `scrollTo` / `scrollToOffset({ animated: false })`, or any library driving the offset frame-by-frame — cancels every active touch in enclosing scroll views. Two mechanisms combine into this:
1. `scrollToOffset:animated:` calls `_forceDispatchNextScrollEvent` and, for non-animated scrolls, `_handleFinishedScrolling` — so every call emits `onScroll` (twice) plus `onMomentumScrollEnd`, bypassing `scrollEventThrottle` entirely. A per-frame driver produces a continuous stream of unthrottled `topScroll` events (~60/s measured with `scrollEventThrottle={2000}`).
2. In the responder system, any `topScroll` event without `responderIgnoreScroll: true` starts a responder negotiation, and `ScrollView`'s `onScrollShouldSetResponder` answers `true` whenever a finger is down inside it. Each event therefore steals the responder from a pressed `Pressable`/`Touchable` and the press is cancelled — `onPressIn` fires, `onPress` never does.
On Android scroll events carry `responderIgnoreScroll: true`.
I added `responderIgnoreScroll` to the C++ `ScrollEvent` payload and set it to `!_isUserTriggeredScrolling` in `_scrollViewMetrics`. Programmatic scrolls no longer transfer the responder, while user-initiated scrolls (drag, deceleration, scroll-to-top) keep today's behavior.
## Changelog:
[IOS] [FIXED] - Programmatic (non-user-initiated) scrolls no longer cancel active touches in enclosing scroll views
Pull Request resolved: https://github.com/react/react-native/pull/57546
Test Plan:
Reproducible code — an endless marquee `FlatList` nested in a `ScrollView`, driven by `requestAnimationFrame` + `scrollToOffset({ animated: false })`, with a sibling `TouchableOpacity` and a counter proving the touches reach JS:
<details><summary>App.tsx</summary>
```tsx
import { useEffect, useMemo, useRef, useState } from 'react';
import {
FlatList,
ScrollView,
Text,
TouchableOpacity,
View,
} from 'react-native';
const dpPerSecond = 20;
const size = 80;
const gap = 8;
const data = Array.from({ length: 6 }).map((_, i) => ({
id: `id-${i}`,
n: i + 1,
}));
const renderItem = ({ item }: { item: (typeof data)[0] }) => (
<View
style={{
width: size,
height: size,
backgroundColor: 'red',
justifyContent: 'center',
alignItems: 'center',
}}>
<Text>#{item.n}</Text>
</View>
);
const Carousel = ({ paused }: { paused: boolean }) => {
const ref = useRef<FlatList>(null);
const [width, setWidth] = useState(0);
const offset = useRef(0);
useEffect(() => {
if (paused) return;
const x = (size + gap) * data.length;
let last = Date.now();
let raf: number;
const loop = () => {
const now = Date.now();
offset.current =
(offset.current + (dpPerSecond * (now - last)) / 1000) % x;
last = now;
ref.current?.scrollToOffset({ offset: offset.current, animated: false });
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [paused]);
const neededToFill = Math.ceil(width / (size + gap));
const extendedData = useMemo(
() => [
...data,
...data.slice(0, neededToFill).map((d) => ({ ...d, id: d.id + '-dup' })),
],
[neededToFill]
);
return (
<FlatList
scrollEnabled={false}
scrollEventThrottle={2000}
showsHorizontalScrollIndicator={false}
windowSize={3}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
contentContainerStyle={{ gap }}
ref={ref}
horizontal
data={extendedData}
keyExtractor={(item) => item.id}
renderItem={renderItem}
/>
);
};
export default () => {
const [paused, setPaused] = useState(true);
const [touches, setTouches] = useState(0);
return (
<View style={{ flex: 1 }} onTouchStart={() => setTouches((t) => t + 1)}>
<ScrollView contentContainerStyle={{ paddingVertical: 64, gap: 32 }}>
<Carousel paused={paused} />
<TouchableOpacity onPress={() => setPaused((p) => !p)}>
<Text style={{ fontSize: 20 }}>
Try tapping me {paused ? '▶️' : '⏸️'}
</Text>
</TouchableOpacity>
<Text style={{ fontSize: 16 }}>touches seen by JS: {touches}</Text>
</ScrollView>
</View>
);
};
```
</details>
Before this change, only the first tap works (it starts the marquee); every following tap increments the touch counter but never toggles the button — the press is cancelled by the responder transfer. After this change, every tap toggles the marquee.
Recordings of the repro above (every touch is marked with a blue ring and counted on screen):
Before:
https://github.com/user-attachments/assets/bc5a8764-efe3-40ba-a9cb-a5023d140369
After:
https://github.com/user-attachments/assets/b2c1eea1-dcab-422c-9a4a-90fd69061e9d
Reviewed By: cipolleschi
Differential Revision: D116453881
Pulled By: j-piasecki
fbshipit-source-id: eb72fb0f1a4ac9fdde15f621201009d855d63890
scripts/cxx-api
Python build pipeline for React Native's C++ (and Objective-C) API snapshots.
Overview
scripts/cxx-api generates human-readable snapshots of React Native's public C++ API surface. It uses Doxygen to parse C/C++/Objective-C headers and a custom Python parser to produce a simplified, sorted representation of every public symbol.
The pipeline produces one .api snapshot file per configured API view × variant combination:
| Snapshot | Description |
|---|---|
ReactCommonDebugCxx.api |
Platform-independent C++ API (debug) |
ReactCommonReleaseCxx.api |
Platform-independent C++ API (release) |
ReactAndroidDebugCxx.api |
Android-specific C++ API (debug) |
ReactAndroidReleaseCxx.api |
Android-specific C++ API (release) |
ReactAppleDebugCxx.api |
Apple-specific C++/Obj-C API (debug) |
ReactAppleReleaseCxx.api |
Apple-specific C++/Obj-C API (release) |
For each view, debug and release variants are generated with different preprocessor definitions (e.g. REACT_NATIVE_DEBUG vs NDEBUG), since #ifdef guards in the source headers can produce a different public API surface per variant.
Snapshot files are committed to the repo under scripts/cxx-api/api-snapshots/.
Usage
Generate snapshots
Maintainers should run this command whenever making intentional C++ API changes:
python -m scripts.cxx-api.parser
Validate snapshots against committed baseline
This mode generates snapshots to a temporary directory and compares them against the committed .api files. It is designed for CI:
python -m scripts.cxx-api.parser --validate
If any snapshot differs, a unified diff is printed and the process exits with a non-zero status. To fix a failing validation, regenerate the snapshots with python -m scripts.cxx-api.parser and commit the updated .api files.
How it works
The pipeline has two main stages:
1. Doxygen XML generation
Doxygen is configured via a generated config file (built from .doxygen.config.template) with the input directories, exclude patterns, and preprocessor definitions specified in config.yml. It outputs XML describing every symbol found in the headers.
2. Snapshot parsing
The Python parser (parser/) reads the Doxygen XML output and builds a scope tree of the public API surface. The tree is then serialized to a deterministically sorted, human-readable .api text format.
When to use it
The snapshot should be regenerated whenever making intentional changes to the public C++ API surface. This includes additions, removals, and changes to files located in:
xplat/js/react-native-github/xplat/js/react-native-github/ReactCommon/xplat/js/react-native-github/ReactAndroid/xplat/js/react-native-github/ReactApple/xplat/js/react-native-github/Libraries/
Configuration
All API views and their variants are defined in config.yml. Each view specifies:
| Field | Description |
|---|---|
inputs |
Directories to scan for headers |
exclude_patterns |
Glob patterns for files to skip |
definitions |
Preprocessor macros to define |
variants |
Named build variants (e.g. debug/release) with extra definitions |
codegen |
Optional codegen platform (android, ios) to generate TurboModule/Component headers before scanning |
private_directories |
Directories whose headers are scanned (they may be transitively included) but should not contribute public symbols. If any public API entity is defined in a private directory, a warning is printed to help catch accidental API exposure. |
Snapshot format
The .api files use a minimal pseudo-C++ syntax designed for easy diffing:
namespace facebook::react {
class ComponentDescriptor {
public ComponentDescriptor(ComponentDescriptorParameters params);
public ComponentHandle getComponentHandle();
}
enum class AccessibilityRole {
None = 0,
Button = 1,
}
}
- Scopes and members are sorted alphabetically.
- Access specifiers (
public,protected) are preserved. - Template parameters are included.
- Doc comments and source file names are stripped.