diff --git a/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js b/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js index 8e6b10479de7..e94067b5b803 100644 --- a/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js +++ b/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js @@ -8,31 +8,161 @@ * @format */ +// Reproducer for https://github.com/react/react-native/issues/58445 +// (and the counter half, https://github.com/react/react/issues/37571). +// +// After a two-finger gesture on ONE view where the fingers land in separate +// events and are lifted together, ResponderEventPlugin's trackedTouchCount is +// left above zero. From then on every keystroke runs a selectionChange +// responder walk, TextInput claims it unconditionally and becomes the JS +// responder with no finger down, and the next tap on a Pressable outside the +// input is dropped. The tap after it works, because the dropped tap's own end +// released the responder. +// +// Physical device required: on the iOS Simulator an Option-pinch lands both +// fingers in ONE event, which does not trigger the drift. + import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; import RNTesterText from '../../components/RNTesterText'; import * as React from 'react'; -import {StyleSheet, View} from 'react-native'; +import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native'; + +// Shadow of ResponderEventPlugin.trackedTouchCount with its exact rule: +// +1 per touch-start EVENT, -1 per touch-end/cancel EVENT (not per touch). +let shadow = 0; function Playground() { + const [, force] = React.useState(0); + const [presses, setPresses] = React.useState(0); + const [lastWalk, setLastWalk] = React.useState(null); + const [log, setLog] = React.useState>([]); + const push = (line: string) => + setLog(prev => [...prev.slice(-11), line]); + return ( - + { + shadow += 1; + push(`START shadow=${shadow} n=${e.nativeEvent.touches.length}`); + force(x => x + 1); + return false; + }} + onTouchEnd={e => { + if (shadow >= 0) shadow -= 1; + push(`END shadow=${shadow} n=${e.nativeEvent.touches.length}`); + force(x => x + 1); + }} + onTouchCancel={e => { + if (shadow >= 0) shadow -= 1; + push(`CANCEL shadow=${shadow} n=${e.nativeEvent.touches.length}`); + force(x => x + 1); + }} + // The selectionChange responder walk only runs while the plugin's + // trackedTouchCount is above zero. Seeing it here with zero active + // touches is the defect made visible. + onSelectionChangeShouldSetResponderCapture={e => { + // $FlowFixMe[prop-missing] touchHistory is attached by the plugin + const active = e.touchHistory?.numberActiveTouches ?? -1; + setLastWalk(`selectionChange walk with ${active} active touches`); + return false; + }}> + + Dead first tap after typing (#58445 / #37571) + - Edit "RNTesterPlayground.js" to change this file + 1. Put two fingers on the blue box one after the other (a fraction of + a second apart), then lift BOTH at the same time.{'\n'} + 2. Tap the input and type one character.{'\n'} + 3. Tap "Press me" once.{'\n'} + Expected: the counter goes up. Actual: nothing; a second tap works. + {'\n'} + Control: repeat, but lift the fingers ONE AT A TIME — the first tap + works. + + + + + + Two fingers here, land separately, lift together + + + + + + + setPresses(p => p + 1)}> + Press me ({presses}) + + + 0 ? styles.bad : styles.ok}> + shadow of trackedTouchCount: {shadow} (should be 0 with no finger down) + + {lastWalk ?? 'no selectionChange responder walk yet'} + + + + {log.map((l, i) => ( + + {l} + + ))} + ); } const styles = StyleSheet.create({ - container: { + root: {flex: 1, padding: 10}, + h: {fontSize: 16, fontWeight: '600', marginBottom: 6}, + box: { + height: 160, + borderRadius: 10, + backgroundColor: '#dbeafe', + marginVertical: 10, + }, + caption: {position: 'absolute', left: 10, top: 8}, + captionText: {fontSize: 12, color: '#334'}, + input: { + borderWidth: 1, + borderColor: '#999', + borderRadius: 6, padding: 10, + marginBottom: 10, + }, + button: { + backgroundColor: '#2563eb', + borderRadius: 8, + padding: 12, + alignItems: 'center', + marginBottom: 10, + }, + buttonText: {color: 'white', fontWeight: '600'}, + ok: {color: '#15803d'}, + bad: {color: '#b91c1c', fontWeight: '600'}, + log: { + backgroundColor: '#111', + borderRadius: 8, + padding: 8, + marginTop: 8, }, + mono: {fontFamily: 'Menlo', fontSize: 11, color: '#9ef'}, }); export default { title: 'Playground', name: 'playground', - description: 'Test out new features and ideas.', + description: 'Reproducer for react-native#58445 / react#37571.', render: (): React.Node => , } as RNTesterModuleExample;