RN: Avoid Invalidating ScrollView Children on unstable_subscribeToOnScroll (#45170)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/45170

Reimplements `unstable_subscribeToOnScroll` so it does not invalidate all descendant children upon first invocation per `ScrollView` instance.

Previously, the state update would cause the entire `ScrollView` component to re-render. This refactors the `enableSyncOnScroll` boolean state so that it resides in a lower level component that implicitly memoizes all of its `props` (including the `ScrollView` children).

Changelog:
[Internal]

Reviewed By: javache

Differential Revision: D59033393

fbshipit-source-id: 5a4b75aebdcbd0dd1dfa28511862bee495816250
This commit is contained in:
Tim Yung
2024-06-26 20:17:05 -07:00
committed by Facebook GitHub Bot
parent 18302831c4
commit c128a517b8
4 changed files with 176 additions and 102 deletions
@@ -8,6 +8,10 @@
* @flow strict-local
*/
import type {
TScrollViewNativeComponentInstance,
TScrollViewNativeImperativeHandle,
} from '../../../src/private/core/components/useSyncOnScroll';
import type {HostComponent} from '../../Renderer/shims/ReactNativeTypes';
import type {EdgeInsetsProp} from '../../StyleSheet/EdgeInsetsPropType';
import type {PointProp} from '../../StyleSheet/PointPropType';
@@ -147,9 +151,8 @@ export type ScrollViewImperativeMethods = $ReadOnly<{|
export type DecelerationRateType = 'fast' | 'normal' | number;
export type ScrollResponderType = ScrollViewImperativeMethods;
type NativeScrollViewInstance = React.ElementRef<HostComponent<mixed>>;
type PublicScrollViewInstance = $ReadOnly<{|
...$Exact<NativeScrollViewInstance>,
...$Exact<TScrollViewNativeComponentInstance>,
...ScrollViewImperativeMethods,
|}>;
@@ -657,9 +660,6 @@ export type Props = $ReadOnly<{|
type State = {|
layoutHeight: ?number,
onScrollEmitter: ?EventEmitter<{
scroll: [{x: number, y: number}],
}>,
|};
const IS_ANIMATING_TOUCH_START_THRESHOLD_MS = 16;
@@ -743,9 +743,12 @@ class ScrollView extends React.Component<Props, State> {
_subscriptionKeyboardDidShow: ?EventSubscription = null;
_subscriptionKeyboardDidHide: ?EventSubscription = null;
#onScrollEmitter: ?EventEmitter<{
scroll: [{x: number, y: number}],
}> = null;
state: State = {
layoutHeight: null,
onScrollEmitter: null,
};
componentDidMount() {
@@ -815,7 +818,7 @@ class ScrollView extends React.Component<Props, State> {
this._scrollAnimatedValueAttachment.detach();
}
this.state.onScrollEmitter?.removeAllListeners();
this.#onScrollEmitter?.removeAllListeners();
}
/**
@@ -830,7 +833,7 @@ class ScrollView extends React.Component<Props, State> {
};
getScrollableNode: () => ?number = () => {
return findNodeHandle(this._scrollView.nativeInstance);
return findNodeHandle(this.getNativeScrollRef());
};
getInnerViewNode: () => ?number = () => {
@@ -841,8 +844,9 @@ class ScrollView extends React.Component<Props, State> {
return this._innerView.nativeInstance;
};
getNativeScrollRef: () => NativeScrollViewInstance | null = () => {
return this._scrollView.nativeInstance;
getNativeScrollRef: () => TScrollViewNativeComponentInstance | null = () => {
const {nativeInstance} = this._scrollView;
return nativeInstance == null ? null : nativeInstance.componentRef.current;
};
/**
@@ -893,15 +897,11 @@ class ScrollView extends React.Component<Props, State> {
x = options.x;
animated = options.animated;
}
if (this._scrollView.nativeInstance == null) {
const component = this.getNativeScrollRef();
if (component == null) {
return;
}
Commands.scrollTo(
this._scrollView.nativeInstance,
x || 0,
y || 0,
animated !== false,
);
Commands.scrollTo(component, x || 0, y || 0, animated !== false);
};
/**
@@ -917,10 +917,11 @@ class ScrollView extends React.Component<Props, State> {
) => {
// Default to true
const animated = (options && options.animated) !== false;
if (this._scrollView.nativeInstance == null) {
const component = this.getNativeScrollRef();
if (component == null) {
return;
}
Commands.scrollToEnd(this._scrollView.nativeInstance, animated);
Commands.scrollToEnd(component, animated);
};
/**
@@ -929,48 +930,25 @@ class ScrollView extends React.Component<Props, State> {
* @platform ios
*/
flashScrollIndicators: () => void = () => {
if (this._scrollView.nativeInstance == null) {
const component = this.getNativeScrollRef();
if (component == null) {
return;
}
Commands.flashScrollIndicators(this._scrollView.nativeInstance);
Commands.flashScrollIndicators(component);
};
_subscribeToOnScroll: (
callback: ({x: number, y: number}) => void,
) => EventSubscription = callback => {
// An undefined value means the listener has not been added, yet.
// A null value means the listener has been removed.
let subscription: ?EventSubscription;
this.setState(
({onScrollEmitter}) => {
if (onScrollEmitter) {
return null;
} else {
return {onScrollEmitter: new EventEmitter()};
}
},
() => {
// If `subscription` is null, that means it was removed before we got
// here so do nothing.
if (subscription !== null) {
subscription = nullthrows(this.state.onScrollEmitter).addListener(
'scroll',
callback,
);
}
},
);
return {
remove() {
// If `subscription` was created before this invocation, remove it.
subscription?.remove();
// Record this invocation by setting `subscription` to null, in case it
// ends up being created after this invocation.
subscription = null;
},
};
let onScrollEmitter = this.#onScrollEmitter;
if (onScrollEmitter == null) {
onScrollEmitter = new EventEmitter();
this.#onScrollEmitter = onScrollEmitter;
// This is the first subscription, so make sure the native component is
// also configured to output synchronous scroll events.
this._scrollView.nativeInstance?.unstable_setEnableSyncOnScroll(true);
}
return onScrollEmitter.addListener('scroll', callback);
};
/**
@@ -1052,14 +1030,11 @@ class ScrollView extends React.Component<Props, State> {
);
}
if (this._scrollView.nativeInstance == null) {
const component = this.getNativeScrollRef();
if (component == null) {
return;
}
Commands.zoomToRect(
this._scrollView.nativeInstance,
rect,
animated !== false,
);
Commands.zoomToRect(component, rect, animated !== false);
};
_textInputFocusError() {
@@ -1132,7 +1107,7 @@ class ScrollView extends React.Component<Props, State> {
) {
this._scrollAnimatedValueAttachment =
AnimatedImplementation.attachNativeEvent(
this._scrollView.nativeInstance,
this.getNativeScrollRef(),
'onScroll',
[{nativeEvent: {contentOffset: {y: this._scrollAnimatedValue}}}],
);
@@ -1180,7 +1155,7 @@ class ScrollView extends React.Component<Props, State> {
this._observedScrollSinceBecomingResponder = true;
this.props.onScroll && this.props.onScroll(e);
this.state.onScrollEmitter?.emit('scroll', {
this.#onScrollEmitter?.emit('scroll', {
x: e.nativeEvent.contentOffset.x,
y: e.nativeEvent.contentOffset.y,
});
@@ -1207,41 +1182,44 @@ class ScrollView extends React.Component<Props, State> {
);
_scrollView: RefForwarder<
NativeScrollViewInstance,
PublicScrollViewInstance,
> = createRefForwarder(
(nativeInstance: NativeScrollViewInstance): PublicScrollViewInstance => {
// This is a hack. Ideally we would forwardRef to the underlying
// host component. However, since ScrollView has it's own methods that can be
// called as well, if we used the standard forwardRef then these
// methods wouldn't be accessible and thus be a breaking change.
//
// Therefore we edit ref to include ScrollView's public methods so that
// they are callable from the ref.
TScrollViewNativeImperativeHandle,
PublicScrollViewInstance | null,
> = createRefForwarder(nativeImperativeHandle => {
const nativeInstance = nativeImperativeHandle.componentRef.current;
if (nativeInstance == null) {
return null;
}
// $FlowFixMe[prop-missing] - Known issue with appending custom methods.
const publicInstance: PublicScrollViewInstance = Object.assign(
nativeInstance,
{
getScrollResponder: this.getScrollResponder,
getScrollableNode: this.getScrollableNode,
getInnerViewNode: this.getInnerViewNode,
getInnerViewRef: this.getInnerViewRef,
getNativeScrollRef: this.getNativeScrollRef,
scrollTo: this.scrollTo,
scrollToEnd: this.scrollToEnd,
flashScrollIndicators: this.flashScrollIndicators,
scrollResponderZoomTo: this.scrollResponderZoomTo,
// TODO: Replace unstable_subscribeToOnScroll once scrollView.addEventListener('scroll', (e: ScrollEvent) => {}, {passive: false});
unstable_subscribeToOnScroll: this._subscribeToOnScroll,
scrollResponderScrollNativeHandleToKeyboard:
this.scrollResponderScrollNativeHandleToKeyboard,
},
);
// This is a hack. Ideally we would forwardRef to the underlying
// host component. However, since ScrollView has it's own methods that can be
// called as well, if we used the standard forwardRef then these
// methods wouldn't be accessible and thus be a breaking change.
//
// Therefore we edit ref to include ScrollView's public methods so that
// they are callable from the ref.
return publicInstance;
},
);
// $FlowFixMe[prop-missing] - Known issue with appending custom methods.
const publicInstance: PublicScrollViewInstance = Object.assign(
nativeInstance,
{
getScrollResponder: this.getScrollResponder,
getScrollableNode: this.getScrollableNode,
getInnerViewNode: this.getInnerViewNode,
getInnerViewRef: this.getInnerViewRef,
getNativeScrollRef: this.getNativeScrollRef,
scrollTo: this.scrollTo,
scrollToEnd: this.scrollToEnd,
flashScrollIndicators: this.flashScrollIndicators,
scrollResponderZoomTo: this.scrollResponderZoomTo,
// TODO: Replace unstable_subscribeToOnScroll once scrollView.addEventListener('scroll', (e: ScrollEvent) => {}, {passive: false});
unstable_subscribeToOnScroll: this._subscribeToOnScroll,
scrollResponderScrollNativeHandleToKeyboard:
this.scrollResponderScrollNativeHandleToKeyboard,
},
);
return publicInstance;
});
/**
* Warning, this may be called several times for a single keyboard opening.
@@ -1819,7 +1797,6 @@ class ScrollView extends React.Component<Props, State> {
onScroll: this._handleScroll,
endDraggingSensitivityMultiplier:
experimental_endDraggingSensitivityMultiplier,
enableSyncOnScroll: this.state.onScrollEmitter ? true : undefined,
scrollEventThrottle: hasStickyHeaders
? 1
: this.props.scrollEventThrottle,
@@ -1852,14 +1829,14 @@ class ScrollView extends React.Component<Props, State> {
}
const refreshControl = this.props.refreshControl;
const scrollViewRef = this._scrollView.getForwardingRef(
this.props.scrollViewRef,
);
const scrollViewRef: React.RefSetter<TScrollViewNativeImperativeHandle | null> =
this._scrollView.getForwardingRef(this.props.scrollViewRef);
if (refreshControl) {
if (Platform.OS === 'ios') {
// On iOS the RefreshControl is a child of the ScrollView.
return (
// $FlowFixMe[incompatible-type] - Flow only knows element refs.
<NativeScrollView {...props} ref={scrollViewRef}>
{refreshControl}
{contentContainer}
@@ -1880,6 +1857,7 @@ class ScrollView extends React.Component<Props, State> {
<NativeScrollView
{...props}
style={StyleSheet.compose(baseStyle, inner)}
// $FlowFixMe[incompatible-type] - Flow only knows element refs.
ref={scrollViewRef}>
{contentContainer}
</NativeScrollView>,
@@ -1887,6 +1865,7 @@ class ScrollView extends React.Component<Props, State> {
}
}
return (
// $FlowFixMe[incompatible-type] - Flow only knows element refs.
<NativeScrollView {...props} ref={scrollViewRef}>
{contentContainer}
</NativeScrollView>
@@ -12,18 +12,43 @@
import type {ScrollViewNativeProps} from '../../../../Libraries/Components/ScrollView/ScrollViewNativeComponentType';
import type {ViewProps} from '../../../../Libraries/Components/View/ViewPropTypes';
import type {HostComponent} from '../../../../Libraries/Renderer/shims/ReactNativeTypes';
import type {TScrollViewNativeImperativeHandle} from './useSyncOnScroll';
import AndroidHorizontalScrollViewNativeComponent from '../../../../Libraries/Components/ScrollView/AndroidHorizontalScrollViewNativeComponent';
import ScrollContentViewNativeComponent from '../../../../Libraries/Components/ScrollView/ScrollContentViewNativeComponent';
import ScrollViewNativeComponent from '../../../../Libraries/Components/ScrollView/ScrollViewNativeComponent';
import Platform from '../../../../Libraries/Utilities/Platform';
import AndroidHorizontalScrollContentViewNativeComponent from '../../specs/components/AndroidHorizontalScrollContentViewNativeComponent';
import useSyncOnScroll from './useSyncOnScroll';
import * as React from 'react';
export const HScrollViewNativeComponent: HostComponent<ScrollViewNativeProps> =
const HScrollViewNativeComponentForPlatform =
Platform.OS === 'android'
? AndroidHorizontalScrollViewNativeComponent
: ScrollViewNativeComponent;
export const HScrollViewNativeComponent: React.AbstractComponent<
ScrollViewNativeProps,
TScrollViewNativeImperativeHandle,
// $FlowExpectedError[incompatible-type] - Flow cannot model imperative handles, yet.
> = function HScrollViewNativeComponent(props: {
...ScrollViewNativeProps,
ref?: React.RefSetter<TScrollViewNativeImperativeHandle | null>,
...
}): React.Node {
const [ref, enableSyncOnScroll] = useSyncOnScroll(props.ref);
// NOTE: When `useSyncOnScroll` triggers an update, `props` will not have
// changed. Notably, `props.children` will be the same, allowing React to
// bail out during reconciliation.
return (
<HScrollViewNativeComponentForPlatform
{...props}
ref={ref}
enableSyncOnScroll={enableSyncOnScroll}
/>
);
};
export const HScrollContentViewNativeComponent: HostComponent<ViewProps> =
Platform.OS === 'android'
? AndroidHorizontalScrollContentViewNativeComponent
@@ -12,14 +12,36 @@
import type {ScrollViewNativeProps} from '../../../../Libraries/Components/ScrollView/ScrollViewNativeComponentType';
import type {ViewProps} from '../../../../Libraries/Components/View/ViewPropTypes';
import type {HostComponent} from '../../../../Libraries/Renderer/shims/ReactNativeTypes';
import type {TScrollViewNativeImperativeHandle} from './useSyncOnScroll';
import ScrollContentViewNativeComponent from '../../../../Libraries/Components/ScrollView/ScrollContentViewNativeComponent';
import ScrollViewNativeComponent from '../../../../Libraries/Components/ScrollView/ScrollViewNativeComponent';
import View from '../../../../Libraries/Components/View/View';
import Platform from '../../../../Libraries/Utilities/Platform';
import useSyncOnScroll from './useSyncOnScroll';
import * as React from 'react';
export const VScrollViewNativeComponent: HostComponent<ScrollViewNativeProps> =
ScrollViewNativeComponent;
export const VScrollViewNativeComponent: React.AbstractComponent<
ScrollViewNativeProps,
TScrollViewNativeImperativeHandle,
// $FlowExpectedError[incompatible-type] - Flow cannot model imperative handles, yet.
> = function VScrollViewNativeComponent(props: {
...ScrollViewNativeProps,
ref?: React.RefSetter<TScrollViewNativeImperativeHandle | null>,
...
}): React.Node {
const [ref, enableSyncOnScroll] = useSyncOnScroll(props.ref);
// NOTE: When `useSyncOnScroll` triggers an update, `props` will not have
// changed. Notably, `props.children` will be the same, allowing React to
// bail out during reconciliation.
return (
<ScrollViewNativeComponent
{...props}
ref={ref}
enableSyncOnScroll={enableSyncOnScroll}
/>
);
};
export const VScrollContentViewNativeComponent: HostComponent<ViewProps> =
Platform.OS === 'android' ? View : ScrollContentViewNativeComponent;
@@ -0,0 +1,48 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/
import type {ScrollViewNativeProps} from '../../../../Libraries/Components/ScrollView/ScrollViewNativeComponentType';
import type {HostComponent} from '../../../../Libraries/Renderer/shims/ReactNativeTypes';
import * as React from 'react';
import {useImperativeHandle, useRef, useState} from 'react';
export type TScrollViewNativeComponentInstance = React.ElementRef<
HostComponent<ScrollViewNativeProps>,
>;
export type TScrollViewNativeImperativeHandle = {
componentRef: React.RefObject<TScrollViewNativeComponentInstance | null>,
unstable_setEnableSyncOnScroll: (enabled: true) => void,
};
/**
* Hook used by `HScrollViewNativeComponent` and `VScrollViewNativeComponent`
* to make an implementation of `unstable_setEnableSyncOnScroll` available that
* does not require updating all `ScrollView` children.
*/
export default function useSyncOnScroll(
inputRef: ?React.RefSetter<TScrollViewNativeImperativeHandle | null>,
): [React.RefSetter<TScrollViewNativeComponentInstance | null>, true | void] {
const componentRef = useRef<TScrollViewNativeComponentInstance | null>(null);
const [enableSyncOnScroll, setEnableSyncOnScroll] = useState<true | void>();
useImperativeHandle<TScrollViewNativeImperativeHandle>(inputRef, () => {
return {
componentRef,
unstable_setEnableSyncOnScroll(enabled: true): void {
setEnableSyncOnScroll(enabled);
},
};
}, []);
return [componentRef, enableSyncOnScroll];
}