From 03ddd95590d29348a5a8296961748cc738cf3063 Mon Sep 17 00:00:00 2001 From: LucioChavezFuentes Date: Mon, 27 Feb 2023 14:32:21 -0600 Subject: [PATCH 01/13] [fix] layout measurement API consistency with React Native * Ignore CSS transforms in measurement. * Cancel measurement if elements are unmounted. Close #2501 Fix #1254 --- .../src/exports/UIManager/index.js | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/react-native-web/src/exports/UIManager/index.js b/packages/react-native-web/src/exports/UIManager/index.js index 8833347bd3..77eefe435b 100644 --- a/packages/react-native-web/src/exports/UIManager/index.js +++ b/packages/react-native-web/src/exports/UIManager/index.js @@ -7,28 +7,38 @@ * @noflow */ -import getBoundingClientRect from '../../modules/getBoundingClientRect'; import setValueForStyles from '../../modules/setValueForStyles'; const getRect = (node) => { - // Unlike the DOM's getBoundingClientRect, React Native layout measurements - // for "height" and "width" ignore scale transforms. - // https://developer.mozilla.org/en-US/docs/Web/API/CSS_Object_Model/Determining_the_dimensions_of_elements - const { x, y, top, left } = getBoundingClientRect(node); - const width = node.offsetWidth; const height = node.offsetHeight; - return { x, y, width, height, top, left }; + const width = node.offsetWidth; + let left = node.offsetLeft; + let top = node.offsetTop; + node = node.offsetParent; + + while (node && node.nodeType === 1 /* Node.ELEMENT_NODE */) { + left += node.offsetLeft + node.clientLeft - node.scrollLeft; + top += node.offsetTop + node.clientTop - node.scrollTop; + node = node.offsetParent; + } + + top -= window.scrollY; + left -= window.scrollX; + + return { width, height, top, left }; }; const measureLayout = (node, relativeToNativeNode, callback) => { const relativeNode = relativeToNativeNode || (node && node.parentNode); if (node && relativeNode) { setTimeout(() => { - const relativeRect = getBoundingClientRect(relativeNode); - const { height, left, top, width } = getRect(node); - const x = left - relativeRect.left; - const y = top - relativeRect.top; - callback(x, y, width, height, left, top); + if (node.isConnected && relativeNode.isConnected) { + const relativeRect = getRect(relativeNode); + const { height, left, top, width } = getRect(node); + const x = left - relativeRect.left; + const y = top - relativeRect.top; + callback(x, y, width, height, left, top); + } }, 0); } }; From 985c79f9247b53fce7a728d68c6cacebd506ef2c Mon Sep 17 00:00:00 2001 From: rory Date: Tue, 22 Nov 2022 14:02:28 -0800 Subject: [PATCH 02/13] [fix] Nested scroll in inverted VirtualizedList Close #2436 Fix #2435 --- .../react-native/VirtualizedList/index.js | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/react-native-web/src/vendor/react-native/VirtualizedList/index.js b/packages/react-native-web/src/vendor/react-native/VirtualizedList/index.js index 25b07d3656..916770be43 100644 --- a/packages/react-native-web/src/vendor/react-native/VirtualizedList/index.js +++ b/packages/react-native-web/src/vendor/react-native/VirtualizedList/index.js @@ -732,12 +732,29 @@ class VirtualizedList extends React.PureComponent { // REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller. // For issue https://github.com/necolas/react-native-web/issues/995 this.invertedWheelEventHandler = (ev: any) => { + const scrollOffset = this.props.horizontal ? ev.target.scrollLeft : ev.target.scrollTop; + const scrollLength = this.props.horizontal ? ev.target.scrollWidth : ev.target.scrollHeight; + const clientLength = this.props.horizontal ? ev.target.clientWidth : ev.target.clientHeight; + const isEventTargetScrollable = scrollLength > clientLength; + const delta = this.props.horizontal + ? ev.deltaX || ev.wheelDeltaX + : ev.deltaY || ev.wheelDeltaY; + let leftoverDelta = delta; + if (isEventTargetScrollable) { + leftoverDelta = delta < 0 + ? Math.min(delta + scrollOffset, 0) + : Math.max(delta - (scrollLength - clientLength - scrollOffset), 0); + } + const targetDelta = delta - leftoverDelta; + if (this.props.inverted && this._scrollRef && this._scrollRef.getScrollableNode) { const node = (this._scrollRef: any).getScrollableNode(); if (this.props.horizontal) { - node.scrollLeft -= ev.deltaX || ev.wheelDeltaX + ev.target.scrollLeft += targetDelta; + node.scrollLeft -= leftoverDelta; } else { - node.scrollTop -= ev.deltaY || ev.wheelDeltaY + ev.target.scrollTop += targetDelta; + node.scrollTop -= leftoverDelta; } ev.preventDefault(); } From 1c5119b7e1638a22210291ef9ede5d9ab599ec55 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Wed, 5 Apr 2023 10:15:23 -0700 Subject: [PATCH 03/13] [fix] Reintroduce BackHandler with console.error This API will be removed in a future version. --- .../src/moduleMap.js | 1 + .../src/exports/BackHandler/index.js | 26 +++++++++++++++++++ packages/react-native-web/src/index.js | 1 + 3 files changed, 28 insertions(+) create mode 100644 packages/react-native-web/src/exports/BackHandler/index.js diff --git a/packages/babel-plugin-react-native-web/src/moduleMap.js b/packages/babel-plugin-react-native-web/src/moduleMap.js index ad701cf822..a83dd77e1d 100644 --- a/packages/babel-plugin-react-native-web/src/moduleMap.js +++ b/packages/babel-plugin-react-native-web/src/moduleMap.js @@ -7,6 +7,7 @@ module.exports = { AppRegistry: true, AppState: true, Appearance: true, + BackHandler: true, Button: true, CheckBox: true, Clipboard: true, diff --git a/packages/react-native-web/src/exports/BackHandler/index.js b/packages/react-native-web/src/exports/BackHandler/index.js new file mode 100644 index 0000000000..205fc58a9d --- /dev/null +++ b/packages/react-native-web/src/exports/BackHandler/index.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) Nicolas Gallagher. + * 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 + */ + +function emptyFunction() {} + +const BackHandler = { + exitApp: emptyFunction, + addEventListener(): {| remove: () => void |} { + console.error( + 'BackHandler is not supported on web and should not be used.' + ); + return { + remove: emptyFunction + }; + }, + removeEventListener: emptyFunction +}; + +export default BackHandler; diff --git a/packages/react-native-web/src/index.js b/packages/react-native-web/src/index.js index d61a6ba6e7..f1a6f23fca 100644 --- a/packages/react-native-web/src/index.js +++ b/packages/react-native-web/src/index.js @@ -13,6 +13,7 @@ export { default as Animated } from './exports/Animated'; export { default as Appearance } from './exports/Appearance'; export { default as AppRegistry } from './exports/AppRegistry'; export { default as AppState } from './exports/AppState'; +export { default as BackHandler } from './exports/BackHandler'; export { default as Clipboard } from './exports/Clipboard'; export { default as Dimensions } from './exports/Dimensions'; export { default as Easing } from './exports/Easing'; From 5ace60eb7e74f497c00fab1d867eb0516dc23739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucio=20Bertin=20Ch=C3=A1vez=20Fuentes?= Date: Mon, 27 Mar 2023 22:08:09 -0600 Subject: [PATCH 04/13] [fix] VirtualizedList sync from react-native Fix #2432 Close #2167 Close #2502 --- configs/.flowconfig | 5 +- package-lock.json | 24 + packages/react-native-web/package.json | 2 + .../vendor/react-native/Batchinator/index.js | 4 +- .../react-native/FillRateHelper/index.js | 58 +- .../src/vendor/react-native/FlatList/index.js | 184 +- .../react-native/Types/CoreEventTypes.js | 160 +- .../vendor/react-native/Utilities/clamp.js | 23 + .../react-native/ViewabilityHelper/index.js | 100 +- .../react-native/VirtualizeUtils/index.js | 81 +- .../VirtualizedList/CellRenderMask.js | 155 ++ .../VirtualizedList/ChildListCollection.js | 72 + .../VirtualizedList/StateSafePureComponent.js | 85 + .../VirtualizedListCellRenderer.js | 247 +++ .../VirtualizedList/VirtualizedListContext.js | 71 +- .../VirtualizedList/VirtualizedListProps.js | 307 +++ .../react-native/VirtualizedList/index.js | 1742 ++++++++--------- .../VirtualizedSectionList/index.js | 49 +- .../src/vendor/react-native/infoLog/index.js | 6 +- 19 files changed, 2191 insertions(+), 1184 deletions(-) create mode 100644 packages/react-native-web/src/vendor/react-native/Utilities/clamp.js create mode 100644 packages/react-native-web/src/vendor/react-native/VirtualizedList/CellRenderMask.js create mode 100644 packages/react-native-web/src/vendor/react-native/VirtualizedList/ChildListCollection.js create mode 100644 packages/react-native-web/src/vendor/react-native/VirtualizedList/StateSafePureComponent.js create mode 100644 packages/react-native-web/src/vendor/react-native/VirtualizedList/VirtualizedListCellRenderer.js create mode 100644 packages/react-native-web/src/vendor/react-native/VirtualizedList/VirtualizedListProps.js diff --git a/configs/.flowconfig b/configs/.flowconfig index e2348f7f51..303c45a0fb 100644 --- a/configs/.flowconfig +++ b/configs/.flowconfig @@ -6,11 +6,14 @@ /packages/.*/dist/.* /packages/react-native-web-docs/.* /packages/react-native-web-examples/.* -.*/node_modules/.*/.*.json [include] +[declarations] +.*/node_modules/.* + [libs] [options] +indexed_access=true munge_underscores=true diff --git a/package-lock.json b/package-lock.json index 4af96be556..814c4ed16b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11179,6 +11179,11 @@ "dev": true, "license": "MIT" }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "node_modules/memorystream": { "version": "0.3.1", "dev": true, @@ -11736,6 +11741,11 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==" + }, "node_modules/nunjucks": { "version": "3.2.3", "dev": true, @@ -15472,7 +15482,9 @@ "@babel/runtime": "^7.18.6", "fbjs": "^3.0.4", "inline-style-prefixer": "^6.0.1", + "memoize-one": "^6.0.0", "normalize-css-color": "^1.0.2", + "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.2" }, @@ -22680,6 +22692,11 @@ "version": "1.0.1", "dev": true }, + "memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "memorystream": { "version": "0.3.1", "dev": true @@ -23016,6 +23033,11 @@ "boolbase": "^1.0.0" } }, + "nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==" + }, "nunjucks": { "version": "3.2.3", "dev": true, @@ -23607,7 +23629,9 @@ "@babel/runtime": "^7.18.6", "fbjs": "^3.0.4", "inline-style-prefixer": "^6.0.1", + "memoize-one": "^6.0.0", "normalize-css-color": "^1.0.2", + "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.2" } diff --git a/packages/react-native-web/package.json b/packages/react-native-web/package.json index 861439ab5e..23d183fb9a 100644 --- a/packages/react-native-web/package.json +++ b/packages/react-native-web/package.json @@ -25,7 +25,9 @@ "@babel/runtime": "^7.18.6", "fbjs": "^3.0.4", "inline-style-prefixer": "^6.0.1", + "memoize-one": "^6.0.0", "normalize-css-color": "^1.0.2", + "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.2" }, diff --git a/packages/react-native-web/src/vendor/react-native/Batchinator/index.js b/packages/react-native-web/src/vendor/react-native/Batchinator/index.js index 47071a96a1..4ed33030af 100644 --- a/packages/react-native-web/src/vendor/react-native/Batchinator/index.js +++ b/packages/react-native-web/src/vendor/react-native/Batchinator/index.js @@ -37,7 +37,7 @@ import InteractionManager from '../../../exports/InteractionManager'; class Batchinator { _callback: () => void; _delay: number; - _taskHandle: ?{cancel: () => void}; + _taskHandle: ?{cancel: () => void, ...}; constructor(callback: () => void, delayMS: number) { this._delay = delayMS; this._callback = callback; @@ -48,7 +48,7 @@ class Batchinator { * By default, if there is a pending task the callback is run immediately. Set the option abort to * true to not call the callback if it was pending. */ - dispose(options: {abort: boolean} = {abort: false}) { + dispose(options: {abort: boolean, ...} = {abort: false}) { if (this._taskHandle) { this._taskHandle.cancel(); if (!options.abort) { diff --git a/packages/react-native-web/src/vendor/react-native/FillRateHelper/index.js b/packages/react-native-web/src/vendor/react-native/FillRateHelper/index.js index e5353013b2..a0c78d2450 100644 --- a/packages/react-native-web/src/vendor/react-native/FillRateHelper/index.js +++ b/packages/react-native-web/src/vendor/react-native/FillRateHelper/index.js @@ -10,6 +10,8 @@ 'use strict'; +import type {FrameMetricProps} from '../VirtualizedList/VirtualizedListProps'; + export type FillRateInfo = Info; class Info { @@ -47,16 +49,17 @@ let _sampleRate = DEBUG ? 1 : null; * `SceneTracker.getActiveScene` to determine the context of the events. */ class FillRateHelper { - _anyBlankStartTime = (null: ?number); + _anyBlankStartTime: ?number = null; _enabled = false; - _getFrameMetrics: (index: number) => ?FrameMetrics; - _info = new Info(); - _mostlyBlankStartTime = (null: ?number); - _samplesStartTime = (null: ?number); - - static addListener( - callback: FillRateInfo => void, - ): {remove: () => void, ...} { + _getFrameMetrics: (index: number, props: FrameMetricProps) => ?FrameMetrics; + _info: Info = new Info(); + _mostlyBlankStartTime: ?number = null; + _samplesStartTime: ?number = null; + + static addListener(callback: FillRateInfo => void): { + remove: () => void, + ... + } { if (_sampleRate === null) { console.warn('Call `FillRateHelper.setSampleRate` before `addListener`.'); } @@ -76,7 +79,9 @@ class FillRateHelper { _minSampleCount = minSampleCount; } - constructor(getFrameMetrics: (index: number) => ?FrameMetrics) { + constructor( + getFrameMetrics: (index: number, props: FrameMetricProps) => ?FrameMetrics, + ) { this._getFrameMetrics = getFrameMetrics; this._enabled = (_sampleRate || 0) > Math.random(); this._resetData(); @@ -123,6 +128,7 @@ class FillRateHelper { mostly_blank_time_frac: this._info.mostly_blank_ms / total_time_spent, }; for (const key in derived) { + // $FlowFixMe[prop-missing] derived[key] = Math.round(1000 * derived[key]) / 1000; } console.debug('FillRateHelper deactivateAndFlush: ', {derived, info}); @@ -133,12 +139,11 @@ class FillRateHelper { computeBlankness( props: { - data: any, - getItemCount: (data: any) => number, - initialNumToRender: number, + ...FrameMetricProps, + initialNumToRender?: ?number, ... }, - state: { + cellsAroundViewport: { first: number, last: number, ... @@ -154,6 +159,7 @@ class FillRateHelper { if ( !this._enabled || props.getItemCount(props.data) === 0 || + cellsAroundViewport.last < cellsAroundViewport.first || this._samplesStartTime == null ) { return 0; @@ -179,10 +185,13 @@ class FillRateHelper { this._mostlyBlankStartTime = null; let blankTop = 0; - let first = state.first; - let firstFrame = this._getFrameMetrics(first); - while (first <= state.last && (!firstFrame || !firstFrame.inLayout)) { - firstFrame = this._getFrameMetrics(first); + let first = cellsAroundViewport.first; + let firstFrame = this._getFrameMetrics(first, props); + while ( + first <= cellsAroundViewport.last && + (!firstFrame || !firstFrame.inLayout) + ) { + firstFrame = this._getFrameMetrics(first, props); first++; } // Only count blankTop if we aren't rendering the first item, otherwise we will count the header @@ -194,10 +203,13 @@ class FillRateHelper { ); } let blankBottom = 0; - let last = state.last; - let lastFrame = this._getFrameMetrics(last); - while (last >= state.first && (!lastFrame || !lastFrame.inLayout)) { - lastFrame = this._getFrameMetrics(last); + let last = cellsAroundViewport.last; + let lastFrame = this._getFrameMetrics(last, props); + while ( + last >= cellsAroundViewport.first && + (!lastFrame || !lastFrame.inLayout) + ) { + lastFrame = this._getFrameMetrics(last, props); last--; } // Only count blankBottom if we aren't rendering the last item, otherwise we will count the @@ -238,4 +250,4 @@ class FillRateHelper { } } -export default FillRateHelper; +export default FillRateHelper; \ No newline at end of file diff --git a/packages/react-native-web/src/vendor/react-native/FlatList/index.js b/packages/react-native-web/src/vendor/react-native/FlatList/index.js index 72360e5683..89272f343b 100644 --- a/packages/react-native-web/src/vendor/react-native/FlatList/index.js +++ b/packages/react-native-web/src/vendor/react-native/FlatList/index.js @@ -8,33 +8,34 @@ * @format */ -import Platform from '../../../exports/Platform'; -import deepDiffer from '../deepDiffer'; -import * as React from 'react'; import View, { type ViewProps } from '../../../exports/View'; -import VirtualizedList from '../VirtualizedList'; import StyleSheet from '../../../exports/StyleSheet'; - +import deepDiffer from '../deepDiffer'; +import Platform from '../../../exports/Platform'; import invariant from 'fbjs/lib/invariant'; +import * as React from 'react'; type ScrollViewNativeComponent = any; -type ScrollResponderType = any; type ViewStyleProp = $PropertyType; import type { ViewToken, ViewabilityConfigCallbackPair, } from '../ViewabilityHelper'; import type {RenderItemType, RenderItemProps} from '../VirtualizedList'; +type ScrollResponderType = any; +import VirtualizedList from '../VirtualizedList'; import {keyExtractor as defaultKeyExtractor} from '../VirtualizeUtils'; +import memoizeOne from 'memoize-one'; + type $FlowFixMe = any; type RequiredProps = {| /** - * For simplicity, data is just a plain array. If you want to use something else, like an - * immutable list, use the underlying `VirtualizedList` directly. + * An array (or array-like list) of items to render. Other data types can be + * used by targetting VirtualizedList directly. */ - data: ?$ReadOnlyArray, + data: ?$ArrayLike, |}; type OptionalProps = {| /** @@ -89,7 +90,7 @@ type OptionalProps = {| * specify `ItemSeparatorComponent`. */ getItemLayout?: ( - data: ?Array, + data: ?$ArrayLike, index: number, ) => { length: number, @@ -143,6 +144,10 @@ type OptionalProps = {| * See `ScrollView` for flow type and further documentation. */ fadingEdgeLength?: ?number, + /** + * Enable an optimization to memoize the item renderer to prevent unnecessary rerenders. + */ + strictMode?: boolean, |}; /** @@ -160,6 +165,11 @@ function numColumnsOrDefault(numColumns: ?number) { return numColumns ?? 1; } +function isArrayLike(data: mixed): boolean { + // $FlowExpectedError[incompatible-use] + return typeof Object(data).length === 'number'; +} + type FlatListProps = {| ...RequiredProps, ...OptionalProps, @@ -331,6 +341,7 @@ class FlatList extends React.PureComponent, void> { scrollToItem(params: { animated?: ?boolean, item: ItemT, + viewOffset?: number, viewPosition?: number, ... }) { @@ -424,6 +435,7 @@ class FlatList extends React.PureComponent, void> { } } + // $FlowFixMe[missing-local-annot] componentDidUpdate(prevProps: Props) { invariant( prevProps.numColumns === this.props.numColumns, @@ -450,10 +462,11 @@ class FlatList extends React.PureComponent, void> { _listRef: ?React.ElementRef; _virtualizedListPairs: Array = []; - _captureRef = ref => { + _captureRef = (ref: ?React.ElementRef) => { this._listRef = ref; }; + // $FlowFixMe[missing-local-annot] _checkProps(props: Props) { const { // $FlowFixMe[prop-missing] this prop doesn't exist, is only used for an invariant @@ -485,13 +498,17 @@ class FlatList extends React.PureComponent, void> { ); } - _getItem = (data: Array, index: number) => { + _getItem = ( + data: $ArrayLike, + index: number, + ): ?(ItemT | $ReadOnlyArray) => { const numColumns = numColumnsOrDefault(this.props.numColumns); if (numColumns > 1) { const ret = []; for (let kk = 0; kk < numColumns; kk++) { - const item = data[index * numColumns + kk]; - if (item != null) { + const itemIndex = index * numColumns + kk; + if (itemIndex < data.length) { + const item = data[itemIndex]; ret.push(item); } } @@ -501,8 +518,14 @@ class FlatList extends React.PureComponent, void> { } }; - _getItemCount = (data: ?Array): number => { - if (data) { + _getItemCount = (data: ?$ArrayLike): number => { + // Legacy behavior of FlatList was to forward "undefined" length if invalid + // data like a non-arraylike object is passed. VirtualizedList would then + // coerce this, and the math would work out to no-op. For compatibility, if + // invalid data is passed, we tell VirtualizedList there are zero items + // available to prevent it from trying to read from the invalid data + // (without propagating invalidly typed data). + if (data != null && isArrayLike(data)) { const numColumns = numColumnsOrDefault(this.props.numColumns); return numColumns > 1 ? Math.ceil(data.length / numColumns) : data.length; } else { @@ -510,29 +533,26 @@ class FlatList extends React.PureComponent, void> { } }; - _keyExtractor = (items: ItemT | Array, index: number) => { + _keyExtractor = (items: ItemT | Array, index: number): string => { const numColumns = numColumnsOrDefault(this.props.numColumns); const keyExtractor = this.props.keyExtractor ?? defaultKeyExtractor; if (numColumns > 1) { - if (Array.isArray(items)) { - return items - .map((item, kk) => - keyExtractor(((item: $FlowFixMe): ItemT), index * numColumns + kk), - ) - .join(':'); - } else { - invariant( - Array.isArray(items), - 'FlatList: Encountered internal consistency error, expected each item to consist of an ' + - 'array with 1-%s columns; instead, received a single item.', - numColumns, - ); - } - } else { - // $FlowFixMe[incompatible-call] Can't call keyExtractor with an array - return keyExtractor(items, index); + invariant( + Array.isArray(items), + 'FlatList: Encountered internal consistency error, expected each item to consist of an ' + + 'array with 1-%s columns; instead, received a single item.', + numColumns, + ); + return items + .map((item, kk) => + keyExtractor(((item: $FlowFixMe): ItemT), index * numColumns + kk), + ) + .join(':'); } + + // $FlowFixMe[incompatible-call] Can't call keyExtractor with an array + return keyExtractor(items, index); }; _pushMultiColumnViewable(arr: Array, v: ViewToken): void { @@ -551,6 +571,7 @@ class FlatList extends React.PureComponent, void> { changed: Array, ... }) => void, + // $FlowFixMe[missing-local-annot] ) { return (info: { viewableItems: Array, @@ -560,8 +581,8 @@ class FlatList extends React.PureComponent, void> { const numColumns = numColumnsOrDefault(this.props.numColumns); if (onViewableItemsChanged) { if (numColumns > 1) { - const changed = []; - const viewableItems = []; + const changed: Array = []; + const viewableItems: Array = []; info.viewableItems.forEach(v => this._pushMultiColumnViewable(viewableItems, v), ); @@ -574,15 +595,17 @@ class FlatList extends React.PureComponent, void> { }; } - _renderer = () => { - const {ListItemComponent, renderItem, columnWrapperStyle} = this.props; - const numColumns = numColumnsOrDefault(this.props.numColumns); - - let virtualizedListRenderKey = ListItemComponent - ? 'ListItemComponent' - : 'renderItem'; + _renderer = ( + ListItemComponent: ?(React.ComponentType | React.Element), + renderItem: ?RenderItemType, + columnWrapperStyle: ?ViewStyleProp, + numColumns: ?number, + extraData: ?any, + // $FlowFixMe[missing-local-annot] + ) => { + const cols = numColumnsOrDefault(numColumns); - const renderer = (props): React.Node => { + const render = (props: RenderItemProps): React.Node => { if (ListItemComponent) { // $FlowFixMe[not-a-component] Component isn't valid // $FlowFixMe[incompatible-type-arg] Component isn't valid @@ -596,47 +619,54 @@ class FlatList extends React.PureComponent, void> { } }; - return { - /* $FlowFixMe[invalid-computed-prop] (>=0.111.0 site=react_native_fb) - * This comment suppresses an error found when Flow v0.111 was deployed. - * To see the error, delete this comment and run Flow. */ - [virtualizedListRenderKey]: (info: RenderItemProps) => { - if (numColumns > 1) { - const {item, index} = info; - invariant( - Array.isArray(item), - 'Expected array of items with numColumns > 1', - ); - return ( - - {item.map((it, kk) => { - const element = renderer({ - item: it, - index: index * numColumns + kk, - separators: info.separators, - }); - return element != null ? ( - {element} - ) : null; - })} - - ); - } else { - return renderer(info); - } - }, + const renderProp = (info: RenderItemProps) => { + if (cols > 1) { + const {item, index} = info; + invariant( + Array.isArray(item), + 'Expected array of items with numColumns > 1', + ); + return ( + + {item.map((it, kk) => { + const element = render({ + // $FlowFixMe[incompatible-call] + item: it, + index: index * cols + kk, + separators: info.separators, + }); + return element != null ? ( + {element} + ) : null; + })} + + ); + } else { + return render(info); + } }; + + return ListItemComponent + ? {ListItemComponent: renderProp} + : {renderItem: renderProp}; }; + // $FlowFixMe[missing-local-annot] + _memoizedRenderer = memoizeOne(this._renderer); + render(): React.Node { const { numColumns, columnWrapperStyle, removeClippedSubviews: _removeClippedSubviews, + strictMode = false, ...restProps } = this.props; + const renderer = strictMode ? this._memoizedRenderer : this._renderer; + return ( + // $FlowFixMe[incompatible-exact] - `restProps` (`Props`) is inexact. extends React.PureComponent, void> { removeClippedSubviews={removeClippedSubviewsOrDefault( _removeClippedSubviews, )} - {...this._renderer()} + {...renderer( + this.props.ListItemComponent, + this.props.renderItem, + columnWrapperStyle, + numColumns, + this.props.extraData, + )} /> ); } diff --git a/packages/react-native-web/src/vendor/react-native/Types/CoreEventTypes.js b/packages/react-native-web/src/vendor/react-native/Types/CoreEventTypes.js index 3dd26e4187..c35d2d0303 100644 --- a/packages/react-native-web/src/vendor/react-native/Types/CoreEventTypes.js +++ b/packages/react-native-web/src/vendor/react-native/Types/CoreEventTypes.js @@ -10,7 +10,7 @@ 'use strict'; -export type SyntheticEvent = $ReadOnly<{| +export type SyntheticEvent<+T> = $ReadOnly<{| bubbles: ?boolean, cancelable: ?boolean, currentTarget: HTMLElement, @@ -82,6 +82,144 @@ export type TextLayoutEvent = SyntheticEvent< |}>, >; +/** + * https://developer.mozilla.org/en-US/docs/Web/API/UIEvent + */ +export interface NativeUIEvent { + /** + * Returns a long with details about the event, depending on the event type. + */ + +detail: number; +} + +/** + * https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent + */ +export interface NativeMouseEvent extends NativeUIEvent { + /** + * The X coordinate of the mouse pointer in global (screen) coordinates. + */ + +screenX: number; + /** + * The Y coordinate of the mouse pointer in global (screen) coordinates. + */ + +screenY: number; + /** + * The X coordinate of the mouse pointer relative to the whole document. + */ + +pageX: number; + /** + * The Y coordinate of the mouse pointer relative to the whole document. + */ + +pageY: number; + /** + * The X coordinate of the mouse pointer in local (DOM content) coordinates. + */ + +clientX: number; + /** + * The Y coordinate of the mouse pointer in local (DOM content) coordinates. + */ + +clientY: number; + /** + * Alias for NativeMouseEvent.clientX + */ + +x: number; + /** + * Alias for NativeMouseEvent.clientY + */ + +y: number; + /** + * Returns true if the control key was down when the mouse event was fired. + */ + +ctrlKey: boolean; + /** + * Returns true if the shift key was down when the mouse event was fired. + */ + +shiftKey: boolean; + /** + * Returns true if the alt key was down when the mouse event was fired. + */ + +altKey: boolean; + /** + * Returns true if the meta key was down when the mouse event was fired. + */ + +metaKey: boolean; + /** + * The button number that was pressed (if applicable) when the mouse event was fired. + */ + +button: number; + /** + * The buttons being depressed (if any) when the mouse event was fired. + */ + +buttons: number; + /** + * The secondary target for the event, if there is one. + */ + +relatedTarget: HTMLElement; + // offset is proposed: https://drafts.csswg.org/cssom-view/#extensions-to-the-mouseevent-interface + /** + * The X coordinate of the mouse pointer between that event and the padding edge of the target node + */ + +offsetX: number; + /** + * The Y coordinate of the mouse pointer between that event and the padding edge of the target node + */ + +offsetY: number; +} + +/** + * https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent + */ +export interface NativePointerEvent extends NativeMouseEvent { + /** + * A unique identifier for the pointer causing the event. + */ + +pointerId: number; + /** + * The width (magnitude on the X axis), in CSS pixels, of the contact geometry of the pointer + */ + +width: number; + /** + * The height (magnitude on the Y axis), in CSS pixels, of the contact geometry of the pointer. + */ + +height: number; + /** + * The normalized pressure of the pointer input in the range 0 to 1, where 0 and 1 represent + * the minimum and maximum pressure the hardware is capable of detecting, respectively. + */ + +pressure: number; + /** + * The normalized tangential pressure of the pointer input (also known as barrel pressure or + * cylinder stress) in the range -1 to 1, where 0 is the neutral position of the control. + */ + +tangentialPressure: number; + /** + * The plane angle (in degrees, in the range of -90 to 90) between the Y–Z plane and the plane + * containing both the pointer (e.g. pen stylus) axis and the Y axis. + */ + +tiltX: number; + /** + * The plane angle (in degrees, in the range of -90 to 90) between the X–Z plane and the plane + * containing both the pointer (e.g. pen stylus) axis and the X axis. + */ + +tiltY: number; + /** + * The clockwise rotation of the pointer (e.g. pen stylus) around its major axis in degrees, + * with a value in the range 0 to 359. + */ + +twist: number; + /** + * Indicates the device type that caused the event (mouse, pen, touch, etc.) + */ + +pointerType: string; + /** + * Indicates if the pointer represents the primary pointer of this pointer type. + */ + +isPrimary: boolean; +} + +export type PointerEvent = SyntheticEvent; + export type PressEvent = ResponderSyntheticEvent< $ReadOnly<{| changedTouches: $ReadOnlyArray<$PropertyType>, @@ -130,8 +268,24 @@ export type ScrollEvent = SyntheticEvent< |}>, >; -export type SwitchChangeEvent = SyntheticEvent< +export type BlurEvent = SyntheticEvent< $ReadOnly<{| - value: boolean, + target: number, + |}>, +>; + +export type FocusEvent = SyntheticEvent< + $ReadOnly<{| + target: number, + |}>, +>; + +export type MouseEvent = SyntheticEvent< + $ReadOnly<{| + clientX: number, + clientY: number, + pageX: number, + pageY: number, + timestamp: number, |}>, >; diff --git a/packages/react-native-web/src/vendor/react-native/Utilities/clamp.js b/packages/react-native-web/src/vendor/react-native/Utilities/clamp.js new file mode 100644 index 0000000000..7bed615751 --- /dev/null +++ b/packages/react-native-web/src/vendor/react-native/Utilities/clamp.js @@ -0,0 +1,23 @@ +/** + * 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. + * + * @format + * @flow strict + */ + +'use strict'; + +function clamp(min: number, value: number, max: number): number { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +} + +module.exports = clamp; diff --git a/packages/react-native-web/src/vendor/react-native/ViewabilityHelper/index.js b/packages/react-native-web/src/vendor/react-native/ViewabilityHelper/index.js index b0ed947f05..850118e85c 100644 --- a/packages/react-native-web/src/vendor/react-native/ViewabilityHelper/index.js +++ b/packages/react-native-web/src/vendor/react-native/ViewabilityHelper/index.js @@ -7,8 +7,11 @@ * @flow * @format */ + 'use strict'; +import type {FrameMetricProps} from '../VirtualizedList/VirtualizedListProps'; + import invariant from 'fbjs/lib/invariant'; export type ViewToken = { @@ -17,6 +20,7 @@ export type ViewToken = { index: ?number, isViewable: boolean, section?: any, + ... }; export type ViewabilityConfigCallbackPair = { @@ -24,7 +28,9 @@ export type ViewabilityConfigCallbackPair = { onViewableItemsChanged: (info: { viewableItems: Array, changed: Array, + ... }) => void, + ... }; export type ViewabilityConfig = {| @@ -71,7 +77,7 @@ export type ViewabilityConfig = {| class ViewabilityHelper { _config: ViewabilityConfig; _hasInteracted: boolean = false; - _timers: Set = new Set(); + _timers: Set = new Set(); _viewableIndices: Array = []; _viewableItems: Map = new Map(); @@ -85,6 +91,9 @@ class ViewabilityHelper { * Cleanup, e.g. on unmount. Clears any pending timers. */ dispose() { + /* $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This + * comment suppresses an error found when Flow v0.63 was deployed. To see + * the error delete this comment and run Flow. */ this._timers.forEach(clearTimeout); } @@ -92,16 +101,27 @@ class ViewabilityHelper { * Determines which items are viewable based on the current metrics and config. */ computeViewableItems( - itemCount: number, + props: FrameMetricProps, scrollOffset: number, viewportHeight: number, - getFrameMetrics: (index: number) => ?{length: number, offset: number}, - renderRange?: {first: number, last: number}, // Optional optimization to reduce the scan size + getFrameMetrics: ( + index: number, + props: FrameMetricProps, + ) => ?{ + length: number, + offset: number, + ... + }, + // Optional optimization to reduce the scan size + renderRange?: { + first: number, + last: number, + ... + }, ): Array { - const { - itemVisiblePercentThreshold, - viewAreaCoveragePercentThreshold, - } = this._config; + const itemCount = props.getItemCount(props.data); + const {itemVisiblePercentThreshold, viewAreaCoveragePercentThreshold} = + this._config; const viewAreaMode = viewAreaCoveragePercentThreshold != null; const viewablePercentThreshold = viewAreaMode ? viewAreaCoveragePercentThreshold @@ -126,7 +146,7 @@ class ViewabilityHelper { return []; } for (let idx = first; idx <= last; idx++) { - const metrics = getFrameMetrics(idx); + const metrics = getFrameMetrics(idx, props); if (!metrics) { continue; } @@ -158,28 +178,46 @@ class ViewabilityHelper { * `onViewableItemsChanged` as appropriate. */ onUpdate( - itemCount: number, + props: FrameMetricProps, scrollOffset: number, viewportHeight: number, - getFrameMetrics: (index: number) => ?{length: number, offset: number}, - createViewToken: (index: number, isViewable: boolean) => ViewToken, + getFrameMetrics: ( + index: number, + props: FrameMetricProps, + ) => ?{ + length: number, + offset: number, + ... + }, + createViewToken: ( + index: number, + isViewable: boolean, + props: FrameMetricProps, + ) => ViewToken, onViewableItemsChanged: ({ viewableItems: Array, changed: Array, + ... }) => void, - renderRange?: {first: number, last: number}, // Optional optimization to reduce the scan size + // Optional optimization to reduce the scan size + renderRange?: { + first: number, + last: number, + ... + }, ): void { + const itemCount = props.getItemCount(props.data); if ( (this._config.waitForInteraction && !this._hasInteracted) || itemCount === 0 || - !getFrameMetrics(0) + !getFrameMetrics(0, props) ) { return; } - let viewableIndices = []; + let viewableIndices: Array = []; if (itemCount) { viewableIndices = this.computeViewableItems( - itemCount, + props, scrollOffset, viewportHeight, getFrameMetrics, @@ -196,17 +234,25 @@ class ViewabilityHelper { } this._viewableIndices = viewableIndices; if (this._config.minimumViewTime) { - const handle = setTimeout(() => { + const handle: TimeoutID = setTimeout(() => { + /* $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This + * comment suppresses an error found when Flow v0.63 was deployed. To + * see the error delete this comment and run Flow. */ this._timers.delete(handle); this._onUpdateSync( + props, viewableIndices, onViewableItemsChanged, createViewToken, ); }, this._config.minimumViewTime); + /* $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This + * comment suppresses an error found when Flow v0.63 was deployed. To see + * the error delete this comment and run Flow. */ this._timers.add(handle); } else { this._onUpdateSync( + props, viewableIndices, onViewableItemsChanged, createViewToken, @@ -229,12 +275,18 @@ class ViewabilityHelper { } _onUpdateSync( - // $FlowFixMe - viewableIndicesToCheck, - // $FlowFixMe - onViewableItemsChanged, - // $FlowFixMe - createViewToken, + props: FrameMetricProps, + viewableIndicesToCheck: Array, + onViewableItemsChanged: ({ + changed: Array, + viewableItems: Array, + ... + }) => void, + createViewToken: ( + index: number, + isViewable: boolean, + props: FrameMetricProps, + ) => ViewToken, ) { // Filter out indices that have gone out of view since this call was scheduled. viewableIndicesToCheck = viewableIndicesToCheck.filter(ii => @@ -243,7 +295,7 @@ class ViewabilityHelper { const prevItems = this._viewableItems; const nextItems = new Map( viewableIndicesToCheck.map(ii => { - const viewable = createViewToken(ii, true); + const viewable = createViewToken(ii, true, props); return [viewable.key, viewable]; }), ); diff --git a/packages/react-native-web/src/vendor/react-native/VirtualizeUtils/index.js b/packages/react-native-web/src/vendor/react-native/VirtualizeUtils/index.js index f696472a00..9a2dbd1266 100644 --- a/packages/react-native-web/src/vendor/react-native/VirtualizeUtils/index.js +++ b/packages/react-native-web/src/vendor/react-native/VirtualizeUtils/index.js @@ -10,7 +10,7 @@ 'use strict'; -import invariant from 'fbjs/lib/invariant'; +import type {FrameMetricProps} from '../VirtualizedList/VirtualizedListProps'; /** * Used to find the indices of the frames that overlap the given offsets. Useful for finding the @@ -19,34 +19,48 @@ import invariant from 'fbjs/lib/invariant'; */ export function elementsThatOverlapOffsets( offsets: Array, - itemCount: number, - getFrameMetrics: (index: number) => { + props: FrameMetricProps, + getFrameMetrics: ( + index: number, + props: FrameMetricProps, + ) => { length: number, offset: number, ... }, + zoomScale: number = 1, ): Array { - const out = []; - let outLength = 0; - for (let ii = 0; ii < itemCount; ii++) { - const frame = getFrameMetrics(ii); - const trailingOffset = frame.offset + frame.length; - for (let kk = 0; kk < offsets.length; kk++) { - if (out[kk] == null && trailingOffset >= offsets[kk]) { - out[kk] = ii; - outLength++; - if (kk === offsets.length - 1) { - invariant( - outLength === offsets.length, - 'bad offsets input, should be in increasing order: %s', - JSON.stringify(offsets), - ); - return out; - } + const itemCount = props.getItemCount(props.data); + const result = []; + for (let offsetIndex = 0; offsetIndex < offsets.length; offsetIndex++) { + const currentOffset = offsets[offsetIndex]; + let left = 0; + let right = itemCount - 1; + + while (left <= right) { + // eslint-disable-next-line no-bitwise + const mid = left + ((right - left) >>> 1); + const frame = getFrameMetrics(mid, props); + const scaledOffsetStart = frame.offset * zoomScale; + const scaledOffsetEnd = (frame.offset + frame.length) * zoomScale; + + // We want the first frame that contains the offset, with inclusive bounds. Thus, for the + // first frame the scaledOffsetStart is inclusive, while for other frames it is exclusive. + if ( + (mid === 0 && currentOffset < scaledOffsetStart) || + (mid !== 0 && currentOffset <= scaledOffsetStart) + ) { + right = mid - 1; + } else if (currentOffset > scaledOffsetEnd) { + left = mid + 1; + } else { + result[offsetIndex] = mid; + break; } } } - return out; + + return result; } /** @@ -85,16 +99,17 @@ export function newRangeCount( * biased in the direction of scroll. */ export function computeWindowedRenderLimits( - data: any, - getItemCount: (data: any) => number, + props: FrameMetricProps, maxToRenderPerBatch: number, windowSize: number, prev: { first: number, last: number, - ... }, - getFrameMetricsApprox: (index: number) => { + getFrameMetricsApprox: ( + index: number, + props: FrameMetricProps, + ) => { length: number, offset: number, ... @@ -104,18 +119,18 @@ export function computeWindowedRenderLimits( offset: number, velocity: number, visibleLength: number, + zoomScale: number, ... }, ): { first: number, last: number, - ... } { - const itemCount = getItemCount(data); + const itemCount = props.getItemCount(props.data); if (itemCount === 0) { - return prev; + return {first: 0, last: -1}; } - const {offset, velocity, visibleLength} = scrollMetrics; + const {offset, velocity, visibleLength, zoomScale = 1} = scrollMetrics; // Start with visible area, then compute maximum overscan region by expanding from there, biased // in the direction of scroll. Total overscan area is capped, which should cap memory consumption @@ -136,7 +151,8 @@ export function computeWindowedRenderLimits( ); const overscanEnd = Math.max(0, visibleEnd + leadFactor * overscanLength); - const lastItemOffset = getFrameMetricsApprox(itemCount - 1).offset; + const lastItemOffset = + getFrameMetricsApprox(itemCount - 1, props).offset * zoomScale; if (lastItemOffset < overscanBegin) { // Entire list is before our overscan window return { @@ -148,8 +164,9 @@ export function computeWindowedRenderLimits( // Find the indices that correspond to the items at the render boundaries we're targeting. let [overscanFirst, first, last, overscanLast] = elementsThatOverlapOffsets( [overscanBegin, visibleBegin, visibleEnd, overscanEnd], - itemCount, + props, getFrameMetricsApprox, + zoomScale, ); overscanFirst = overscanFirst == null ? 0 : overscanFirst; first = first == null ? Math.max(0, overscanFirst) : first; @@ -238,4 +255,4 @@ export function keyExtractor(item: any, index: number): string { return item.id; } return String(index); -} \ No newline at end of file +} diff --git a/packages/react-native-web/src/vendor/react-native/VirtualizedList/CellRenderMask.js b/packages/react-native-web/src/vendor/react-native/VirtualizedList/CellRenderMask.js new file mode 100644 index 0000000000..a234607ca6 --- /dev/null +++ b/packages/react-native-web/src/vendor/react-native/VirtualizedList/CellRenderMask.js @@ -0,0 +1,155 @@ +/** + * 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 + * @format + */ + +import invariant from 'fbjs/lib/invariant'; + +export type CellRegion = { + first: number, + last: number, + isSpacer: boolean, +}; + +export class CellRenderMask { + _numCells: number; + _regions: Array; + + constructor(numCells: number) { + invariant( + numCells >= 0, + 'CellRenderMask must contain a non-negative number os cells', + ); + + this._numCells = numCells; + + if (numCells === 0) { + this._regions = []; + } else { + this._regions = [ + { + first: 0, + last: numCells - 1, + isSpacer: true, + }, + ]; + } + } + + enumerateRegions(): $ReadOnlyArray { + return this._regions; + } + + addCells(cells: {first: number, last: number}): void { + invariant( + cells.first >= 0 && + cells.first < this._numCells && + cells.last >= -1 && + cells.last < this._numCells && + cells.last >= cells.first - 1, + 'CellRenderMask.addCells called with invalid cell range', + ); + + // VirtualizedList uses inclusive ranges, where zero-count states are + // possible. E.g. [0, -1] for no cells, starting at 0. + if (cells.last < cells.first) { + return; + } + + const [firstIntersect, firstIntersectIdx] = this._findRegion(cells.first); + const [lastIntersect, lastIntersectIdx] = this._findRegion(cells.last); + + // Fast-path if the cells to add are already all present in the mask. We + // will otherwise need to do some mutation. + if (firstIntersectIdx === lastIntersectIdx && !firstIntersect.isSpacer) { + return; + } + + // We need to replace the existing covered regions with 1-3 new regions + // depending whether we need to split spacers out of overlapping regions. + const newLeadRegion: Array = []; + const newTailRegion: Array = []; + const newMainRegion: CellRegion = { + ...cells, + isSpacer: false, + }; + + if (firstIntersect.first < newMainRegion.first) { + if (firstIntersect.isSpacer) { + newLeadRegion.push({ + first: firstIntersect.first, + last: newMainRegion.first - 1, + isSpacer: true, + }); + } else { + newMainRegion.first = firstIntersect.first; + } + } + + if (lastIntersect.last > newMainRegion.last) { + if (lastIntersect.isSpacer) { + newTailRegion.push({ + first: newMainRegion.last + 1, + last: lastIntersect.last, + isSpacer: true, + }); + } else { + newMainRegion.last = lastIntersect.last; + } + } + + const replacementRegions: Array = [ + ...newLeadRegion, + newMainRegion, + ...newTailRegion, + ]; + const numRegionsToDelete = lastIntersectIdx - firstIntersectIdx + 1; + this._regions.splice( + firstIntersectIdx, + numRegionsToDelete, + ...replacementRegions, + ); + } + + numCells(): number { + return this._numCells; + } + + equals(other: CellRenderMask): boolean { + return ( + this._numCells === other._numCells && + this._regions.length === other._regions.length && + this._regions.every( + (region, i) => + region.first === other._regions[i].first && + region.last === other._regions[i].last && + region.isSpacer === other._regions[i].isSpacer, + ) + ); + } + + _findRegion(cellIdx: number): [CellRegion, number] { + let firstIdx = 0; + let lastIdx = this._regions.length - 1; + + while (firstIdx <= lastIdx) { + const middleIdx = Math.floor((firstIdx + lastIdx) / 2); + const middleRegion = this._regions[middleIdx]; + + if (cellIdx >= middleRegion.first && cellIdx <= middleRegion.last) { + return [middleRegion, middleIdx]; + } else if (cellIdx < middleRegion.first) { + lastIdx = middleIdx - 1; + } else if (cellIdx > middleRegion.last) { + firstIdx = middleIdx + 1; + } + } + + invariant(false, `A region was not found containing cellIdx ${cellIdx}`); + } +} diff --git a/packages/react-native-web/src/vendor/react-native/VirtualizedList/ChildListCollection.js b/packages/react-native-web/src/vendor/react-native/VirtualizedList/ChildListCollection.js new file mode 100644 index 0000000000..dc8cfe7c1f --- /dev/null +++ b/packages/react-native-web/src/vendor/react-native/VirtualizedList/ChildListCollection.js @@ -0,0 +1,72 @@ +/** + * 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 + * @format + */ + +import invariant from 'fbjs/lib/invariant'; + +export default class ChildListCollection { + _cellKeyToChildren: Map> = new Map(); + _childrenToCellKey: Map = new Map(); + + add(list: TList, cellKey: string): void { + invariant( + !this._childrenToCellKey.has(list), + 'Trying to add already present child list', + ); + + const cellLists = this._cellKeyToChildren.get(cellKey) ?? new Set(); + cellLists.add(list); + this._cellKeyToChildren.set(cellKey, cellLists); + + this._childrenToCellKey.set(list, cellKey); + } + + remove(list: TList): void { + const cellKey = this._childrenToCellKey.get(list); + invariant(cellKey != null, 'Trying to remove non-present child list'); + this._childrenToCellKey.delete(list); + + const cellLists = this._cellKeyToChildren.get(cellKey); + invariant(cellLists, '_cellKeyToChildren should contain cellKey'); + cellLists.delete(list); + + if (cellLists.size === 0) { + this._cellKeyToChildren.delete(cellKey); + } + } + + forEach(fn: TList => void): void { + for (const listSet of this._cellKeyToChildren.values()) { + for (const list of listSet) { + fn(list); + } + } + } + + forEachInCell(cellKey: string, fn: TList => void): void { + const listSet = this._cellKeyToChildren.get(cellKey) ?? []; + for (const list of listSet) { + fn(list); + } + } + + anyInCell(cellKey: string, fn: TList => boolean): boolean { + const listSet = this._cellKeyToChildren.get(cellKey) ?? []; + for (const list of listSet) { + if (fn(list)) { + return true; + } + } + return false; + } + + size(): number { + return this._childrenToCellKey.size; + } +} diff --git a/packages/react-native-web/src/vendor/react-native/VirtualizedList/StateSafePureComponent.js b/packages/react-native-web/src/vendor/react-native/VirtualizedList/StateSafePureComponent.js new file mode 100644 index 0000000000..145a4ebce0 --- /dev/null +++ b/packages/react-native-web/src/vendor/react-native/VirtualizedList/StateSafePureComponent.js @@ -0,0 +1,85 @@ +/** + * 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 + * @format + */ + +import invariant from 'fbjs/lib/invariant'; +import * as React from 'react'; + +/** + * `setState` is called asynchronously, and should not rely on the value of + * `this.props` or `this.state`: + * https://reactjs.org/docs/state-and-lifecycle.html#state-updates-may-be-asynchronous + * + * SafePureComponent adds runtime enforcement, to catch cases where these + * variables are read in a state updater function, instead of the ones passed + * in. + */ +export default class StateSafePureComponent< + Props, + State: interface {}, +> extends React.PureComponent { + _inAsyncStateUpdate = false; + + constructor(props: Props) { + super(props); + this._installSetStateHooks(); + } + + setState( + partialState: ?($Shape | ((State, Props) => ?$Shape)), + callback?: () => mixed, + ): void { + if (typeof partialState === 'function') { + super.setState((state, props) => { + this._inAsyncStateUpdate = true; + let ret; + try { + ret = partialState(state, props); + } catch (err) { + throw err; + } finally { + this._inAsyncStateUpdate = false; + } + return ret; + }, callback); + } else { + super.setState(partialState, callback); + } + } + + _installSetStateHooks() { + const that = this; + let {props, state} = this; + + Object.defineProperty(this, 'props', { + get() { + invariant( + !that._inAsyncStateUpdate, + '"this.props" should not be accessed during state updates', + ); + return props; + }, + set(newProps: Props) { + props = newProps; + }, + }); + Object.defineProperty(this, 'state', { + get() { + invariant( + !that._inAsyncStateUpdate, + '"this.state" should not be acceessed during state updates', + ); + return state; + }, + set(newState: State) { + state = newState; + }, + }); + } +} diff --git a/packages/react-native-web/src/vendor/react-native/VirtualizedList/VirtualizedListCellRenderer.js b/packages/react-native-web/src/vendor/react-native/VirtualizedList/VirtualizedListCellRenderer.js new file mode 100644 index 0000000000..b251487513 --- /dev/null +++ b/packages/react-native-web/src/vendor/react-native/VirtualizedList/VirtualizedListCellRenderer.js @@ -0,0 +1,247 @@ +/** + * 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 + * @format + */ + +import type { LayoutEvent } from '../../../types'; +import type { + FocusEvent, +} from '../Types/CoreEventTypes'; +import type {CellRendererProps, RenderItemType} from './VirtualizedListProps'; + +import View, { type ViewProps } from '../../../exports/View'; +import StyleSheet from '../../../exports/StyleSheet'; +import {VirtualizedListCellContextProvider} from './VirtualizedListContext.js'; +import invariant from 'fbjs/lib/invariant'; +import * as React from 'react'; + +type ViewStyleProp = $PropertyType; + +export type Props = { + CellRendererComponent?: ?React.ComponentType>, + ItemSeparatorComponent: ?React.ComponentType< + any | {highlighted: boolean, leadingItem: ?ItemT}, + >, + ListItemComponent?: ?(React.ComponentType | React.Element), + cellKey: string, + horizontal: ?boolean, + index: number, + inversionStyle: ViewStyleProp, + item: ItemT, + onCellLayout?: (event: LayoutEvent, cellKey: string, index: number) => void, + onCellFocusCapture?: (event: FocusEvent) => void, + onUnmount: (cellKey: string) => void, + onUpdateSeparators: ( + cellKeys: Array, + props: $Shape>, + ) => void, + prevCellKey: ?string, + renderItem?: ?RenderItemType, + ... +}; + +type SeparatorProps = $ReadOnly<{| + highlighted: boolean, + leadingItem: ?ItemT, +|}>; + +type State = { + separatorProps: SeparatorProps, + ... +}; + +export default class CellRenderer extends React.Component< + Props, + State, +> { + state: State = { + separatorProps: { + highlighted: false, + leadingItem: this.props.item, + }, + }; + + static getDerivedStateFromProps( + props: Props, + prevState: State, + ): ?State { + return { + separatorProps: { + ...prevState.separatorProps, + leadingItem: props.item, + }, + }; + } + + // TODO: consider factoring separator stuff out of VirtualizedList into FlatList since it's not + // reused by SectionList and we can keep VirtualizedList simpler. + // $FlowFixMe[missing-local-annot] + _separators = { + highlight: () => { + const {cellKey, prevCellKey} = this.props; + this.props.onUpdateSeparators([cellKey, prevCellKey], { + highlighted: true, + }); + }, + unhighlight: () => { + const {cellKey, prevCellKey} = this.props; + this.props.onUpdateSeparators([cellKey, prevCellKey], { + highlighted: false, + }); + }, + updateProps: ( + select: 'leading' | 'trailing', + newProps: SeparatorProps, + ) => { + const {cellKey, prevCellKey} = this.props; + this.props.onUpdateSeparators( + [select === 'leading' ? prevCellKey : cellKey], + newProps, + ); + }, + }; + + updateSeparatorProps(newProps: SeparatorProps) { + this.setState(state => ({ + separatorProps: {...state.separatorProps, ...newProps}, + })); + } + + componentWillUnmount() { + this.props.onUnmount(this.props.cellKey); + } + + _onLayout = (nativeEvent: LayoutEvent): void => { + this.props.onCellLayout && + this.props.onCellLayout( + nativeEvent, + this.props.cellKey, + this.props.index, + ); + }; + + _renderElement( + renderItem: ?RenderItemType, + ListItemComponent: any, + item: ItemT, + index: number, + ): React.Node { + if (renderItem && ListItemComponent) { + console.warn( + 'VirtualizedList: Both ListItemComponent and renderItem props are present. ListItemComponent will take' + + ' precedence over renderItem.', + ); + } + + if (ListItemComponent) { + /* $FlowFixMe[not-a-component] (>=0.108.0 site=react_native_fb) This + * comment suppresses an error found when Flow v0.108 was deployed. To + * see the error, delete this comment and run Flow. */ + /* $FlowFixMe[incompatible-type-arg] (>=0.108.0 site=react_native_fb) + * This comment suppresses an error found when Flow v0.108 was deployed. + * To see the error, delete this comment and run Flow. */ + return React.createElement(ListItemComponent, { + item, + index, + separators: this._separators, + }); + } + + if (renderItem) { + return renderItem({ + item, + index, + separators: this._separators, + }); + } + + invariant( + false, + 'VirtualizedList: Either ListItemComponent or renderItem props are required but none were found.', + ); + } + + render(): React.Node { + const { + CellRendererComponent, + ItemSeparatorComponent, + ListItemComponent, + cellKey, + horizontal, + item, + index, + inversionStyle, + onCellFocusCapture, + onCellLayout, + renderItem, + } = this.props; + const element = this._renderElement( + renderItem, + ListItemComponent, + item, + index, + ); + + // NOTE: that when this is a sticky header, `onLayout` will get automatically extracted and + // called explicitly by `ScrollViewStickyHeader`. + const itemSeparator: React.Node = React.isValidElement( + ItemSeparatorComponent, + ) + ? // $FlowFixMe[incompatible-type] + ItemSeparatorComponent + : // $FlowFixMe[incompatible-type] + ItemSeparatorComponent && ( + + ); + const cellStyle = inversionStyle + ? horizontal + ? [styles.rowReverse, inversionStyle] + : [styles.columnReverse, inversionStyle] + : horizontal + ? [styles.row, inversionStyle] + : inversionStyle; + const result = !CellRendererComponent ? ( + + {element} + {itemSeparator} + + ) : ( + + {element} + {itemSeparator} + + ); + + return ( + + {result} + + ); + } +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + }, + rowReverse: { + flexDirection: 'row-reverse', + }, + columnReverse: { + flexDirection: 'column-reverse', + }, +}); diff --git a/packages/react-native-web/src/vendor/react-native/VirtualizedList/VirtualizedListContext.js b/packages/react-native-web/src/vendor/react-native/VirtualizedList/VirtualizedListContext.js index 9f71928159..6b7b9108ab 100644 --- a/packages/react-native-web/src/vendor/react-native/VirtualizedList/VirtualizedListContext.js +++ b/packages/react-native-web/src/vendor/react-native/VirtualizedList/VirtualizedListContext.js @@ -8,37 +8,13 @@ * @format */ -import type VirtualizedList from './'; +import typeof VirtualizedList from '../VirtualizedList'; + import * as React from 'react'; -import {useMemo, useContext} from 'react'; +import {useContext, useMemo} from 'react'; const __DEV__ = process.env.NODE_ENV !== 'production'; -type Frame = $ReadOnly<{ - offset: number, - length: number, - index: number, - inLayout: boolean, -}>; - -export type ChildListState = $ReadOnly<{ - first: number, - last: number, - frames: {[key: number]: Frame}, -}>; - -// Data propagated through nested lists (regardless of orientation) that is -// useful for producing diagnostics for usage errors involving nesting (e.g -// missing/duplicate keys). -export type ListDebugInfo = $ReadOnly<{ - cellKey: string, - listKey: string, - parent: ?ListDebugInfo, - // We include all ancestors regardless of orientation, so this is not always - // identical to the child's orientation. - horizontal: boolean, -}>; - type Context = $ReadOnly<{ cellKey: ?string, getScrollMetrics: () => { @@ -49,26 +25,21 @@ type Context = $ReadOnly<{ timestamp: number, velocity: number, visibleLength: number, + zoomScale: number, }, horizontal: ?boolean, - getOutermostParentListRef: () => VirtualizedList, - getNestedChildState: string => ?ChildListState, + getOutermostParentListRef: () => React.ElementRef, registerAsNestedChild: ({ cellKey: string, - key: string, - ref: VirtualizedList, - parentDebugInfo: ListDebugInfo, - }) => ?ChildListState, + ref: React.ElementRef, + }) => void, unregisterAsNestedChild: ({ - key: string, - state: ChildListState, + ref: React.ElementRef, }) => void, - debugInfo: ListDebugInfo, }>; -export const VirtualizedListContext: React.Context = React.createContext( - null, -); +export const VirtualizedListContext: React.Context = + React.createContext(null); if (__DEV__) { VirtualizedListContext.displayName = 'VirtualizedListContext'; } @@ -105,27 +76,15 @@ export function VirtualizedListContextProvider({ getScrollMetrics: value.getScrollMetrics, horizontal: value.horizontal, getOutermostParentListRef: value.getOutermostParentListRef, - getNestedChildState: value.getNestedChildState, registerAsNestedChild: value.registerAsNestedChild, unregisterAsNestedChild: value.unregisterAsNestedChild, - debugInfo: { - cellKey: value.debugInfo.cellKey, - horizontal: value.debugInfo.horizontal, - listKey: value.debugInfo.listKey, - parent: value.debugInfo.parent, - }, }), [ value.getScrollMetrics, value.horizontal, value.getOutermostParentListRef, - value.getNestedChildState, value.registerAsNestedChild, value.unregisterAsNestedChild, - value.debugInfo.cellKey, - value.debugInfo.horizontal, - value.debugInfo.listKey, - value.debugInfo.parent, ], ); return ( @@ -145,10 +104,14 @@ export function VirtualizedListCellContextProvider({ cellKey: string, children: React.Node, }): React.Node { - const context = useContext(VirtualizedListContext); + // Avoid setting a newly created context object if the values are identical. + const currContext = useContext(VirtualizedListContext); + const context = useMemo( + () => (currContext == null ? null : {...currContext, cellKey}), + [currContext, cellKey], + ); return ( - + {children} ); diff --git a/packages/react-native-web/src/vendor/react-native/VirtualizedList/VirtualizedListProps.js b/packages/react-native-web/src/vendor/react-native/VirtualizedList/VirtualizedListProps.js new file mode 100644 index 0000000000..e2d511ccc7 --- /dev/null +++ b/packages/react-native-web/src/vendor/react-native/VirtualizedList/VirtualizedListProps.js @@ -0,0 +1,307 @@ +/** + * 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 + * @format + */ + +import ScrollView from '../../../exports/ScrollView'; +import type { LayoutEvent } from '../../../types'; +import type { + FocusEvent, +} from '../Types/CoreEventTypes'; +import { type ViewProps } from '../../../exports/View'; +type ViewStyleProp = $PropertyType; +import type { + ViewabilityConfig, + ViewabilityConfigCallbackPair, + ViewToken, +} from '../ViewabilityHelper'; + +import * as React from 'react'; + +export type Item = any; + +export type Separators = { + highlight: () => void, + unhighlight: () => void, + updateProps: (select: 'leading' | 'trailing', newProps: Object) => void, + ... +}; + +export type RenderItemProps = { + item: ItemT, + index: number, + separators: Separators, + ... +}; + +export type CellRendererProps = $ReadOnly<{ + cellKey: string, + children: React.Node, + index: number, + item: ItemT, + onFocusCapture?: (event: FocusEvent) => void, + onLayout?: (event: LayoutEvent) => void, + style: ViewStyleProp, +}>; + +export type RenderItemType = ( + info: RenderItemProps, +) => React.Node; + +type RequiredProps = {| + /** + * The default accessor functions assume this is an Array<{key: string} | {id: string}> but you can override + * getItem, getItemCount, and keyExtractor to handle any type of index-based data. + */ + data?: any, + /** + * A generic accessor for extracting an item from any sort of data blob. + */ + getItem: (data: any, index: number) => ?Item, + /** + * Determines how many items are in the data blob. + */ + getItemCount: (data: any) => number, +|}; +type OptionalProps = {| + renderItem?: ?RenderItemType, + /** + * `debug` will turn on extra logging and visual overlays to aid with debugging both usage and + * implementation, but with a significant perf hit. + */ + debug?: ?boolean, + /** + * DEPRECATED: Virtualization provides significant performance and memory optimizations, but fully + * unmounts react instances that are outside of the render window. You should only need to disable + * this for debugging purposes. Defaults to false. + */ + disableVirtualization?: ?boolean, + /** + * A marker property for telling the list to re-render (since it implements `PureComponent`). If + * any of your `renderItem`, Header, Footer, etc. functions depend on anything outside of the + * `data` prop, stick it here and treat it immutably. + */ + extraData?: any, + // e.g. height, y + getItemLayout?: ( + data: any, + index: number, + ) => { + length: number, + offset: number, + index: number, + ... + }, + horizontal?: ?boolean, + /** + * How many items to render in the initial batch. This should be enough to fill the screen but not + * much more. Note these items will never be unmounted as part of the windowed rendering in order + * to improve perceived performance of scroll-to-top actions. + */ + initialNumToRender?: ?number, + /** + * Instead of starting at the top with the first item, start at `initialScrollIndex`. This + * disables the "scroll to top" optimization that keeps the first `initialNumToRender` items + * always rendered and immediately renders the items starting at this initial index. Requires + * `getItemLayout` to be implemented. + */ + initialScrollIndex?: ?number, + /** + * Reverses the direction of scroll. Uses scale transforms of -1. + */ + inverted?: ?boolean, + keyExtractor?: ?(item: Item, index: number) => string, + /** + * CellRendererComponent allows customizing how cells rendered by + * `renderItem`/`ListItemComponent` are wrapped when placed into the + * underlying ScrollView. This component must accept event handlers which + * notify VirtualizedList of changes within the cell. + */ + CellRendererComponent?: ?React.ComponentType>, + /** + * Rendered in between each item, but not at the top or bottom. By default, `highlighted` and + * `leadingItem` props are provided. `renderItem` provides `separators.highlight`/`unhighlight` + * which will update the `highlighted` prop, but you can also add custom props with + * `separators.updateProps`. + */ + ItemSeparatorComponent?: ?React.ComponentType, + /** + * Takes an item from `data` and renders it into the list. Example usage: + * + * ( + * + * )} + * data={[{title: 'Title Text', key: 'item1'}]} + * ListItemComponent={({item, separators}) => ( + * this._onPress(item)} + * onShowUnderlay={separators.highlight} + * onHideUnderlay={separators.unhighlight}> + * + * {item.title} + * + * + * )} + * /> + * + * Provides additional metadata like `index` if you need it, as well as a more generic + * `separators.updateProps` function which let's you set whatever props you want to change the + * rendering of either the leading separator or trailing separator in case the more common + * `highlight` and `unhighlight` (which set the `highlighted: boolean` prop) are insufficient for + * your use-case. + */ + ListItemComponent?: ?(React.ComponentType | React.Element), + /** + * Rendered when the list is empty. Can be a React Component Class, a render function, or + * a rendered element. + */ + ListEmptyComponent?: ?(React.ComponentType | React.Element), + /** + * Rendered at the bottom of all the items. Can be a React Component Class, a render function, or + * a rendered element. + */ + ListFooterComponent?: ?(React.ComponentType | React.Element), + /** + * Styling for internal View for ListFooterComponent + */ + ListFooterComponentStyle?: ViewStyleProp, + /** + * Rendered at the top of all the items. Can be a React Component Class, a render function, or + * a rendered element. + */ + ListHeaderComponent?: ?(React.ComponentType | React.Element), + /** + * Styling for internal View for ListHeaderComponent + */ + ListHeaderComponentStyle?: ViewStyleProp, + /** + * The maximum number of items to render in each incremental render batch. The more rendered at + * once, the better the fill rate, but responsiveness may suffer because rendering content may + * interfere with responding to button taps or other interactions. + */ + maxToRenderPerBatch?: ?number, + /** + * Called once when the scroll position gets within within `onEndReachedThreshold` + * from the logical end of the list. + */ + onEndReached?: ?(info: {distanceFromEnd: number, ...}) => void, + /** + * How far from the end (in units of visible length of the list) the trailing edge of the + * list must be from the end of the content to trigger the `onEndReached` callback. + * Thus, a value of 0.5 will trigger `onEndReached` when the end of the content is + * within half the visible length of the list. + */ + onEndReachedThreshold?: ?number, + /** + * If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make + * sure to also set the `refreshing` prop correctly. + */ + onRefresh?: ?() => void, + /** + * Used to handle failures when scrolling to an index that has not been measured yet. Recommended + * action is to either compute your own offset and `scrollTo` it, or scroll as far as possible and + * then try again after more items have been rendered. + */ + onScrollToIndexFailed?: ?(info: { + index: number, + highestMeasuredFrameIndex: number, + averageItemLength: number, + ... + }) => void, + /** + * Called once when the scroll position gets within within `onStartReachedThreshold` + * from the logical start of the list. + */ + onStartReached?: ?(info: {distanceFromStart: number, ...}) => void, + /** + * How far from the start (in units of visible length of the list) the leading edge of the + * list must be from the start of the content to trigger the `onStartReached` callback. + * Thus, a value of 0.5 will trigger `onStartReached` when the start of the content is + * within half the visible length of the list. + */ + onStartReachedThreshold?: ?number, + /** + * Called when the viewability of rows changes, as defined by the + * `viewabilityConfig` prop. + */ + onViewableItemsChanged?: ?(info: { + viewableItems: Array, + changed: Array, + ... + }) => void, + persistentScrollbar?: ?boolean, + /** + * Set this when offset is needed for the loading indicator to show correctly. + */ + progressViewOffset?: number, + /** + * A custom refresh control element. When set, it overrides the default + * component built internally. The onRefresh and refreshing + * props are also ignored. Only works for vertical VirtualizedList. + */ + refreshControl?: ?React.Element, + /** + * Set this true while waiting for new data from a refresh. + */ + refreshing?: ?boolean, + /** + * Note: may have bugs (missing content) in some circumstances - use at your own risk. + * + * This may improve scroll performance for large lists. + */ + removeClippedSubviews?: boolean, + /** + * Render a custom scroll component, e.g. with a differently styled `RefreshControl`. + */ + renderScrollComponent?: (props: Object) => React.Element, + /** + * Amount of time between low-pri item render batches, e.g. for rendering items quite a ways off + * screen. Similar fill rate/responsiveness tradeoff as `maxToRenderPerBatch`. + */ + updateCellsBatchingPeriod?: ?number, + /** + * See `ViewabilityHelper` for flow type and further documentation. + */ + viewabilityConfig?: ViewabilityConfig, + /** + * List of ViewabilityConfig/onViewableItemsChanged pairs. A specific onViewableItemsChanged + * will be called when its corresponding ViewabilityConfig's conditions are met. + */ + viewabilityConfigCallbackPairs?: Array, + /** + * Determines the maximum number of items rendered outside of the visible area, in units of + * visible lengths. So if your list fills the screen, then `windowSize={21}` (the default) will + * render the visible screen area plus up to 10 screens above and 10 below the viewport. Reducing + * this number will reduce memory consumption and may improve performance, but will increase the + * chance that fast scrolling may reveal momentary blank areas of unrendered content. + */ + windowSize?: ?number, + /** + * The legacy implementation is no longer supported. + */ + legacyImplementation?: empty, +|}; + +export type Props = {| + ...React.ElementConfig, + ...RequiredProps, + ...OptionalProps, +|}; + +/** + * Subset of properties needed to calculate frame metrics + */ +export type FrameMetricProps = { + data: RequiredProps['data'], + getItemCount: RequiredProps['getItemCount'], + getItem: RequiredProps['getItem'], + getItemLayout?: OptionalProps['getItemLayout'], + keyExtractor?: OptionalProps['keyExtractor'], + ... +}; diff --git a/packages/react-native-web/src/vendor/react-native/VirtualizedList/index.js b/packages/react-native-web/src/vendor/react-native/VirtualizedList/index.js index 916770be43..c7d68bb716 100644 --- a/packages/react-native-web/src/vendor/react-native/VirtualizedList/index.js +++ b/packages/react-native-web/src/vendor/react-native/VirtualizedList/index.js @@ -8,61 +8,59 @@ * @format */ -import Batchinator from '../Batchinator'; -import FillRateHelper from '../FillRateHelper'; +import type { LayoutEvent } from '../../../types'; +import type { + ScrollEvent, +} from '../Types/CoreEventTypes'; +import type {ViewToken} from '../ViewabilityHelper'; +import type { + FrameMetricProps, + Item, + Props, + RenderItemProps, + RenderItemType, + Separators, +} from './VirtualizedListProps'; + import RefreshControl from '../../../exports/RefreshControl'; import ScrollView from '../../../exports/ScrollView'; +import View, { type ViewProps } from '../../../exports/View'; import StyleSheet from '../../../exports/StyleSheet'; -import View from '../../../exports/View'; -import ViewabilityHelper from '../ViewabilityHelper'; +import findNodeHandle from '../../../exports/findNodeHandle'; -const flattenStyle = StyleSheet.flatten; +import Batchinator from '../Batchinator'; +import clamp from '../Utilities/clamp'; import infoLog from '../infoLog'; -import invariant from 'fbjs/lib/invariant'; - -import { - keyExtractor as defaultKeyExtractor, - computeWindowedRenderLimits, -} from '../VirtualizeUtils'; - -import * as React from 'react'; -type ScrollResponderType = any; -import type { ViewProps } from '../../../exports/View'; -type ViewStyleProp = $PropertyType; -import type { - ViewabilityConfig, - ViewToken, - ViewabilityConfigCallbackPair, -} from '../ViewabilityHelper'; +import {CellRenderMask} from './CellRenderMask'; +import ChildListCollection from './ChildListCollection'; +import FillRateHelper from '../FillRateHelper'; +import StateSafePureComponent from './StateSafePureComponent'; +import ViewabilityHelper from '../ViewabilityHelper'; +import CellRenderer from './VirtualizedListCellRenderer'; import { VirtualizedListCellContextProvider, VirtualizedListContext, VirtualizedListContextProvider, - type ChildListState, - type ListDebugInfo, } from './VirtualizedListContext.js'; +import { + computeWindowedRenderLimits, + keyExtractor as defaultKeyExtractor, +} from '../VirtualizeUtils'; +import invariant from 'fbjs/lib/invariant'; +import nullthrows from 'nullthrows'; +import * as React from 'react'; -type Item = any; +export type {RenderItemProps, RenderItemType, Separators}; const __DEV__ = process.env.NODE_ENV !== 'production'; -export type Separators = { - highlight: () => void, - unhighlight: () => void, - updateProps: (select: 'leading' | 'trailing', newProps: Object) => void, - ... -}; +const ON_EDGE_REACHED_EPSILON = 0.001; -export type RenderItemProps = { - item: ItemT, - index: number, - separators: Separators, - ... -}; +let _usedIndexForKey = false; +let _keylessItemComponentName: string = ''; -export type RenderItemType = ( - info: RenderItemProps, -) => React.Node; +type ScrollResponderType = any; +type ViewStyleProp = $PropertyType; type ViewabilityHelperCallbackTuple = { viewabilityHelper: ViewabilityHelper, @@ -74,245 +72,9 @@ type ViewabilityHelperCallbackTuple = { ... }; -type RequiredProps = {| - /** - * The default accessor functions assume this is an Array<{key: string} | {id: string}> but you can override - * getItem, getItemCount, and keyExtractor to handle any type of index-based data. - */ - data?: any, - /** - * A generic accessor for extracting an item from any sort of data blob. - */ - getItem: (data: any, index: number) => ?Item, - /** - * Determines how many items are in the data blob. - */ - getItemCount: (data: any) => number, -|}; -type OptionalProps = {| - renderItem?: ?RenderItemType, - /** - * `debug` will turn on extra logging and visual overlays to aid with debugging both usage and - * implementation, but with a significant perf hit. - */ - debug?: ?boolean, - /** - * DEPRECATED: Virtualization provides significant performance and memory optimizations, but fully - * unmounts react instances that are outside of the render window. You should only need to disable - * this for debugging purposes. Defaults to false. - */ - disableVirtualization?: ?boolean, - /** - * A marker property for telling the list to re-render (since it implements `PureComponent`). If - * any of your `renderItem`, Header, Footer, etc. functions depend on anything outside of the - * `data` prop, stick it here and treat it immutably. - */ - extraData?: any, - // e.g. height, y - getItemLayout?: ( - data: any, - index: number, - ) => { - length: number, - offset: number, - index: number, - ... - }, - horizontal?: ?boolean, - /** - * How many items to render in the initial batch. This should be enough to fill the screen but not - * much more. Note these items will never be unmounted as part of the windowed rendering in order - * to improve perceived performance of scroll-to-top actions. - */ - initialNumToRender?: ?number, - /** - * Instead of starting at the top with the first item, start at `initialScrollIndex`. This - * disables the "scroll to top" optimization that keeps the first `initialNumToRender` items - * always rendered and immediately renders the items starting at this initial index. Requires - * `getItemLayout` to be implemented. - */ - initialScrollIndex?: ?number, - /** - * Reverses the direction of scroll. Uses scale transforms of -1. - */ - inverted?: ?boolean, - keyExtractor?: ?(item: Item, index: number) => string, - /** - * Each cell is rendered using this element. Can be a React Component Class, - * or a render function. Defaults to using View. - */ - CellRendererComponent?: ?React.ComponentType, - /** - * Rendered in between each item, but not at the top or bottom. By default, `highlighted` and - * `leadingItem` props are provided. `renderItem` provides `separators.highlight`/`unhighlight` - * which will update the `highlighted` prop, but you can also add custom props with - * `separators.updateProps`. - */ - ItemSeparatorComponent?: ?React.ComponentType, - /** - * Takes an item from `data` and renders it into the list. Example usage: - * - * ( - * - * )} - * data={[{title: 'Title Text', key: 'item1'}]} - * ListItemComponent={({item, separators}) => ( - * this._onPress(item)} - * onShowUnderlay={separators.highlight} - * onHideUnderlay={separators.unhighlight}> - * - * {item.title} - * - * - * )} - * /> - * - * Provides additional metadata like `index` if you need it, as well as a more generic - * `separators.updateProps` function which let's you set whatever props you want to change the - * rendering of either the leading separator or trailing separator in case the more common - * `highlight` and `unhighlight` (which set the `highlighted: boolean` prop) are insufficient for - * your use-case. - */ - ListItemComponent?: ?(React.ComponentType | React.Element), - /** - * Rendered when the list is empty. Can be a React Component Class, a render function, or - * a rendered element. - */ - ListEmptyComponent?: ?(React.ComponentType | React.Element), - /** - * Rendered at the bottom of all the items. Can be a React Component Class, a render function, or - * a rendered element. - */ - ListFooterComponent?: ?(React.ComponentType | React.Element), - /** - * Styling for internal View for ListFooterComponent - */ - ListFooterComponentStyle?: ViewStyleProp, - /** - * Rendered at the top of all the items. Can be a React Component Class, a render function, or - * a rendered element. - */ - ListHeaderComponent?: ?(React.ComponentType | React.Element), - /** - * Styling for internal View for ListHeaderComponent - */ - ListHeaderComponentStyle?: ViewStyleProp, - /** - * A unique identifier for this list. If there are multiple VirtualizedLists at the same level of - * nesting within another VirtualizedList, this key is necessary for virtualization to - * work properly. - */ - listKey?: string, - /** - * The maximum number of items to render in each incremental render batch. The more rendered at - * once, the better the fill rate, but responsiveness may suffer because rendering content may - * interfere with responding to button taps or other interactions. - */ - maxToRenderPerBatch?: ?number, - /** - * Called once when the scroll position gets within `onEndReachedThreshold` of the rendered - * content. - */ - onEndReached?: ?(info: {distanceFromEnd: number, ...}) => void, - /** - * How far from the end (in units of visible length of the list) the bottom edge of the - * list must be from the end of the content to trigger the `onEndReached` callback. - * Thus a value of 0.5 will trigger `onEndReached` when the end of the content is - * within half the visible length of the list. - */ - onEndReachedThreshold?: ?number, - /** - * If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make - * sure to also set the `refreshing` prop correctly. - */ - onRefresh?: ?() => void, - /** - * Used to handle failures when scrolling to an index that has not been measured yet. Recommended - * action is to either compute your own offset and `scrollTo` it, or scroll as far as possible and - * then try again after more items have been rendered. - */ - onScrollToIndexFailed?: ?(info: { - index: number, - highestMeasuredFrameIndex: number, - averageItemLength: number, - ... - }) => void, - /** - * Called when the viewability of rows changes, as defined by the - * `viewabilityConfig` prop. - */ - onViewableItemsChanged?: ?(info: { - viewableItems: Array, - changed: Array, - ... - }) => void, - persistentScrollbar?: ?boolean, - /** - * Set this when offset is needed for the loading indicator to show correctly. - */ - progressViewOffset?: number, - /** - * A custom refresh control element. When set, it overrides the default - * component built internally. The onRefresh and refreshing - * props are also ignored. Only works for vertical VirtualizedList. - */ - refreshControl?: ?React.Element, - /** - * Set this true while waiting for new data from a refresh. - */ - refreshing?: ?boolean, - /** - * Note: may have bugs (missing content) in some circumstances - use at your own risk. - * - * This may improve scroll performance for large lists. - */ - removeClippedSubviews?: boolean, - /** - * Render a custom scroll component, e.g. with a differently styled `RefreshControl`. - */ - renderScrollComponent?: (props: Object) => React.Element, - /** - * Amount of time between low-pri item render batches, e.g. for rendering items quite a ways off - * screen. Similar fill rate/responsiveness tradeoff as `maxToRenderPerBatch`. - */ - updateCellsBatchingPeriod?: ?number, - /** - * See `ViewabilityHelper` for flow type and further documentation. - */ - viewabilityConfig?: ViewabilityConfig, - /** - * List of ViewabilityConfig/onViewableItemsChanged pairs. A specific onViewableItemsChanged - * will be called when its corresponding ViewabilityConfig's conditions are met. - */ - viewabilityConfigCallbackPairs?: Array, - /** - * Determines the maximum number of items rendered outside of the visible area, in units of - * visible lengths. So if your list fills the screen, then `windowSize={21}` (the default) will - * render the visible screen area plus up to 10 screens above and 10 below the viewport. Reducing - * this number will reduce memory consumption and may improve performance, but will increase the - * chance that fast scrolling may reveal momentary blank areas of unrendered content. - */ - windowSize?: ?number, - /** - * The legacy implementation is no longer supported. - */ - legacyImplementation?: empty, -|}; - -type Props = {| - ...React.ElementConfig, - ...RequiredProps, - ...OptionalProps, -|}; - -let _usedIndexForKey = false; -let _keylessItemComponentName: string = ''; - type State = { - first: number, - last: number, + renderMask: CellRenderMask, + cellsAroundViewport: {first: number, last: number}, }; /** @@ -325,7 +87,7 @@ function horizontalOrDefault(horizontal: ?boolean) { return horizontal ?? false; } -// initialNumToRenderOrDefault(this.props.initialNumToRenderOrDefault) +// initialNumToRenderOrDefault(this.props.initialNumToRender) function initialNumToRenderOrDefault(initialNumToRender: ?number) { return initialNumToRender ?? 10; } @@ -335,11 +97,21 @@ function maxToRenderPerBatchOrDefault(maxToRenderPerBatch: ?number) { return maxToRenderPerBatch ?? 10; } +// onStartReachedThresholdOrDefault(this.props.onStartReachedThreshold) +function onStartReachedThresholdOrDefault(onStartReachedThreshold: ?number) { + return onStartReachedThreshold ?? 2; +} + // onEndReachedThresholdOrDefault(this.props.onEndReachedThreshold) function onEndReachedThresholdOrDefault(onEndReachedThreshold: ?number) { return onEndReachedThreshold ?? 2; } +// getScrollingThreshold(visibleLength, onEndReachedThreshold) +function getScrollingThreshold(threshold: number, visibleLength: number) { + return (threshold * visibleLength) / 2; +} + // scrollEventThrottleOrDefault(this.props.scrollEventThrottle) function scrollEventThrottleOrDefault(scrollEventThrottle: ?number) { return scrollEventThrottle ?? 50; @@ -350,6 +122,19 @@ function windowSizeOrDefault(windowSize: ?number) { return windowSize ?? 21; } +function findLastWhere( + arr: $ReadOnlyArray, + predicate: (element: T) => boolean, +): T | null { + for (let i = arr.length - 1; i >= 0; i--) { + if (predicate(arr[i])) { + return arr[i]; + } + } + + return null; +} + /** * Base implementation for the more convenient [``](https://reactnative.dev/docs/flatlist) * and [``](https://reactnative.dev/docs/sectionlist) components, which are also better @@ -379,14 +164,17 @@ function windowSizeOrDefault(windowSize: ?number) { * - As an effort to remove defaultProps, use helper functions when referencing certain props * */ -class VirtualizedList extends React.PureComponent { +class VirtualizedList extends StateSafePureComponent { static contextType: typeof VirtualizedListContext = VirtualizedListContext; // scrollToEnd may be janky without getItemLayout prop scrollToEnd(params?: ?{animated?: ?boolean, ...}) { const animated = params ? params.animated : true; const veryLast = this.props.getItemCount(this.props.data) - 1; - const frame = this._getFrameMetricsApprox(veryLast); + if (veryLast < 0) { + return; + } + const frame = this.__getFrameMetricsApprox(veryLast, this.props); const offset = Math.max( 0, frame.offset + @@ -422,7 +210,7 @@ class VirtualizedList extends React.PureComponent { viewOffset?: number, viewPosition?: number, ... - }) { + }): $FlowFixMe { const { data, horizontal, @@ -460,11 +248,11 @@ class VirtualizedList extends React.PureComponent { }); return; } - const frame = this._getFrameMetricsApprox(index); + const frame = this.__getFrameMetricsApprox(Math.floor(index), this.props); const offset = Math.max( 0, - frame.offset - + this._getOffsetApprox(index, this.props) - (viewPosition || 0) * (this._scrollMetrics.visibleLength - frame.length), ) - (viewOffset || 0); @@ -492,6 +280,7 @@ class VirtualizedList extends React.PureComponent { scrollToItem(params: { animated?: ?boolean, item: Item, + viewOffset?: number, viewPosition?: number, ... }) { @@ -541,12 +330,12 @@ class VirtualizedList extends React.PureComponent { recordInteraction() { this._nestedChildLists.forEach(childList => { - childList.ref && childList.ref.recordInteraction(); + childList.recordInteraction(); }); this._viewabilityTuples.forEach(t => { t.viewabilityHelper.recordInteraction(); }); - this._updateViewableItems(this.props.data); + this._updateViewableItems(this.props, this.state.cellsAroundViewport); } flashScrollIndicators() { @@ -590,19 +379,7 @@ class VirtualizedList extends React.PureComponent { return this.context?.cellKey || 'rootList'; } - _getListKey(): string { - return this.props.listKey || this._getCellKey(); - } - - _getDebugInfo(): ListDebugInfo { - return { - listKey: this._getListKey(), - cellKey: this._getCellKey(), - horizontal: horizontalOrDefault(this.props.horizontal), - parent: this.context?.debugInfo, - }; - } - + // $FlowFixMe[missing-local-annot] _getScrollMetrics = () => { return this._scrollMetrics; }; @@ -611,6 +388,7 @@ class VirtualizedList extends React.PureComponent { return this._hasMore; } + // $FlowFixMe[missing-local-annot] _getOutermostParentListRef = () => { if (this._isNestedWithSameOrientation()) { return this.context.getOutermostParentListRef(); @@ -619,75 +397,30 @@ class VirtualizedList extends React.PureComponent { } }; - _getNestedChildState = (key: string): ?ChildListState => { - const existingChildData = this._nestedChildLists.get(key); - return existingChildData && existingChildData.state; - }; - _registerAsNestedChild = (childList: { cellKey: string, - key: string, - ref: VirtualizedList, - parentDebugInfo: ListDebugInfo, - ... - }): ?ChildListState => { - // Register the mapping between this child key and the cellKey for its cell - const childListsInCell = - this._cellKeysToChildListKeys.get(childList.cellKey) || new Set(); - childListsInCell.add(childList.key); - this._cellKeysToChildListKeys.set(childList.cellKey, childListsInCell); - const existingChildData = this._nestedChildLists.get(childList.key); - if (existingChildData && existingChildData.ref !== null) { - console.error( - 'A VirtualizedList contains a cell which itself contains ' + - 'more than one VirtualizedList of the same orientation as the parent ' + - 'list. You must pass a unique listKey prop to each sibling list.\n\n' + - describeNestedLists({ - ...childList, - // We're called from the child's componentDidMount, so it's safe to - // read the child's props here (albeit weird). - horizontal: !!childList.ref.props.horizontal, - }), - ); - } - this._nestedChildLists.set(childList.key, { - ref: childList.ref, - state: null, - }); - + ref: React.ElementRef, + }): void => { + this._nestedChildLists.add(childList.ref, childList.cellKey); if (this._hasInteracted) { childList.ref.recordInteraction(); } }; _unregisterAsNestedChild = (childList: { - key: string, - state: ChildListState, - ... + ref: React.ElementRef, }): void => { - this._nestedChildLists.set(childList.key, { - ref: null, - state: childList.state, - }); + this._nestedChildLists.remove(childList.ref); }; state: State; - // REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller. - invertedWheelEventHandler: ?(ev: any) => void; + // REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller. + invertedWheelEventHandler: ?(ev: any) => void; constructor(props: Props) { super(props); - invariant( - // $FlowFixMe[prop-missing] - !props.onScroll || !props.onScroll.__isNative, - 'Components based on VirtualizedList must be wrapped with Animated.createAnimatedComponent ' + - 'to support native onScroll events with useNativeDriver', - ); - invariant( - windowSizeOrDefault(props.windowSize) > 0, - 'VirtualizedList: The windowSize prop must be present and set to a value greater than 0.', - ); + this._checkProps(props); this._fillRateHelper = new FillRateHelper(this._getFrameMetrics); this._updateCellsToRenderBatcher = new Batchinator( @@ -702,34 +435,24 @@ class VirtualizedList extends React.PureComponent { onViewableItemsChanged: pair.onViewableItemsChanged, }), ); - } else if (this.props.onViewableItemsChanged) { - this._viewabilityTuples.push({ - viewabilityHelper: new ViewabilityHelper(this.props.viewabilityConfig), - // $FlowFixMe[incompatible-call] - onViewableItemsChanged: this.props.onViewableItemsChanged, - }); + } else { + const {onViewableItemsChanged, viewabilityConfig} = this.props; + if (onViewableItemsChanged) { + this._viewabilityTuples.push({ + viewabilityHelper: new ViewabilityHelper(viewabilityConfig), + onViewableItemsChanged: onViewableItemsChanged, + }); + } } - let initialState = { - first: this.props.initialScrollIndex || 0, - last: - Math.min( - this.props.getItemCount(this.props.data), - (this.props.initialScrollIndex || 0) + - initialNumToRenderOrDefault(this.props.initialNumToRender), - ) - 1, - }; + const initialRenderRegion = VirtualizedList._initialRenderRegion(props); - if (this._isNestedWithSameOrientation()) { - const storedState = this.context.getNestedChildState(this._getListKey()); - if (storedState) { - initialState = storedState; - this.state = storedState; - this._frames = storedState.frames; - } - } + this.state = { + cellsAroundViewport: initialRenderRegion, + renderMask: VirtualizedList._createRenderMask(props, initialRenderRegion), + }; - // REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller. + // REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller. // For issue https://github.com/necolas/react-native-web/issues/995 this.invertedWheelEventHandler = (ev: any) => { const scrollOffset = this.props.horizontal ? ev.target.scrollLeft : ev.target.scrollTop; @@ -751,29 +474,253 @@ class VirtualizedList extends React.PureComponent { const node = (this._scrollRef: any).getScrollableNode(); if (this.props.horizontal) { ev.target.scrollLeft += targetDelta; - node.scrollLeft -= leftoverDelta; + const nextScrollLeft = node.scrollLeft - leftoverDelta; + node.scrollLeft = !this.props.getItemLayout ? Math.min(nextScrollLeft, this._totalCellLength) : nextScrollLeft; } else { ev.target.scrollTop += targetDelta; - node.scrollTop -= leftoverDelta; + const nextScrollTop = node.scrollTop - leftoverDelta; + node.scrollTop = !this.props.getItemLayout ? Math.min(nextScrollTop, this._totalCellLength) : nextScrollTop; } ev.preventDefault(); } }; + } + + _checkProps(props: Props) { + const {onScroll, windowSize, getItemCount, data, initialScrollIndex} = + props; + + invariant( + // $FlowFixMe[prop-missing] + !onScroll || !onScroll.__isNative, + 'Components based on VirtualizedList must be wrapped with Animated.createAnimatedComponent ' + + 'to support native onScroll events with useNativeDriver', + ); + invariant( + windowSizeOrDefault(windowSize) > 0, + 'VirtualizedList: The windowSize prop must be present and set to a value greater than 0.', + ); + + invariant( + getItemCount, + 'VirtualizedList: The "getItemCount" prop must be provided', + ); + + const itemCount = getItemCount(data); + + if ( + initialScrollIndex != null && + !this._hasTriggeredInitialScrollToIndex && + (initialScrollIndex < 0 || + (itemCount > 0 && initialScrollIndex >= itemCount)) && + !this._hasWarned.initialScrollIndex + ) { + console.warn( + `initialScrollIndex "${initialScrollIndex}" is not valid (list has ${itemCount} items)`, + ); + this._hasWarned.initialScrollIndex = true; + } + + if (__DEV__ && !this._hasWarned.flexWrap) { + // $FlowFixMe[underconstrained-implicit-instantiation] + const flatStyles = StyleSheet.flatten(this.props.contentContainerStyle); + if (flatStyles != null && flatStyles.flexWrap === 'wrap') { + console.warn( + '`flexWrap: `wrap`` is not supported with the `VirtualizedList` components.' + + 'Consider using `numColumns` with `FlatList` instead.', + ); + this._hasWarned.flexWrap = true; + } + } + } + + static _createRenderMask( + props: Props, + cellsAroundViewport: {first: number, last: number}, + additionalRegions?: ?$ReadOnlyArray<{first: number, last: number}>, + ): CellRenderMask { + const itemCount = props.getItemCount(props.data); + + invariant( + cellsAroundViewport.first >= 0 && + cellsAroundViewport.last >= cellsAroundViewport.first - 1 && + cellsAroundViewport.last < itemCount, + `Invalid cells around viewport "[${cellsAroundViewport.first}, ${cellsAroundViewport.last}]" was passed to VirtualizedList._createRenderMask`, + ); + + const renderMask = new CellRenderMask(itemCount); + + if (itemCount > 0) { + const allRegions = [cellsAroundViewport, ...(additionalRegions ?? [])]; + for (const region of allRegions) { + renderMask.addCells(region); + } + + // The initially rendered cells are retained as part of the + // "scroll-to-top" optimization + if (props.initialScrollIndex == null || props.initialScrollIndex <= 0) { + const initialRegion = VirtualizedList._initialRenderRegion(props); + renderMask.addCells(initialRegion); + } + + // The layout coordinates of sticker headers may be off-screen while the + // actual header is on-screen. Keep the most recent before the viewport + // rendered, even if its layout coordinates are not in viewport. + const stickyIndicesSet = new Set(props.stickyHeaderIndices); + VirtualizedList._ensureClosestStickyHeader( + props, + stickyIndicesSet, + renderMask, + cellsAroundViewport.first, + ); + } + + return renderMask; + } + + static _initialRenderRegion(props: Props): {first: number, last: number} { + const itemCount = props.getItemCount(props.data); + + const firstCellIndex = Math.max( + 0, + Math.min(itemCount - 1, Math.floor(props.initialScrollIndex ?? 0)), + ); + + const lastCellIndex = + Math.min( + itemCount, + firstCellIndex + initialNumToRenderOrDefault(props.initialNumToRender), + ) - 1; + + return { + first: firstCellIndex, + last: lastCellIndex, + }; + } - this.state = initialState; + static _ensureClosestStickyHeader( + props: Props, + stickyIndicesSet: Set, + renderMask: CellRenderMask, + cellIdx: number, + ) { + const stickyOffset = props.ListHeaderComponent ? 1 : 0; + + for (let itemIdx = cellIdx - 1; itemIdx >= 0; itemIdx--) { + if (stickyIndicesSet.has(itemIdx + stickyOffset)) { + renderMask.addCells({first: itemIdx, last: itemIdx}); + break; + } + } + } + + _adjustCellsAroundViewport( + props: Props, + cellsAroundViewport: {first: number, last: number}, + ): {first: number, last: number} { + const {data, getItemCount} = props; + const onEndReachedThreshold = onEndReachedThresholdOrDefault( + props.onEndReachedThreshold, + ); + const {contentLength, offset, visibleLength} = this._scrollMetrics; + const distanceFromEnd = contentLength - visibleLength - offset; + + // Wait until the scroll view metrics have been set up. And until then, + // we will trust the initialNumToRender suggestion + if (visibleLength <= 0 || contentLength <= 0) { + return cellsAroundViewport.last >= getItemCount(data) + ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) + : cellsAroundViewport; + } + + let newCellsAroundViewport: {first: number, last: number}; + if (props.disableVirtualization) { + const renderAhead = + distanceFromEnd < onEndReachedThreshold * visibleLength + ? maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch) + : 0; + + newCellsAroundViewport = { + first: 0, + last: Math.min( + cellsAroundViewport.last + renderAhead, + getItemCount(data) - 1, + ), + }; + } else { + // If we have a non-zero initialScrollIndex and run this before we've scrolled, + // we'll wipe out the initialNumToRender rendered elements starting at initialScrollIndex. + // So let's wait until we've scrolled the view to the right place. And until then, + // we will trust the initialScrollIndex suggestion. + + // Thus, we want to recalculate the windowed render limits if any of the following hold: + // - initialScrollIndex is undefined or is 0 + // - initialScrollIndex > 0 AND scrolling is complete + // - initialScrollIndex > 0 AND the end of the list is visible (this handles the case + // where the list is shorter than the visible area) + if ( + props.initialScrollIndex && + !this._scrollMetrics.offset && + Math.abs(distanceFromEnd) >= Number.EPSILON + ) { + return cellsAroundViewport.last >= getItemCount(data) + ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) + : cellsAroundViewport; + } + + newCellsAroundViewport = computeWindowedRenderLimits( + props, + maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch), + windowSizeOrDefault(props.windowSize), + cellsAroundViewport, + this.__getFrameMetricsApprox, + this._scrollMetrics, + ); + invariant( + newCellsAroundViewport.last < getItemCount(data), + 'computeWindowedRenderLimits() should return range in-bounds', + ); + } + + if (this._nestedChildLists.size() > 0) { + // If some cell in the new state has a child list in it, we should only render + // up through that item, so that we give that list a chance to render. + // Otherwise there's churn from multiple child lists mounting and un-mounting + // their items. + + // Will this prevent rendering if the nested list doesn't realize the end? + const childIdx = this._findFirstChildWithMore( + newCellsAroundViewport.first, + newCellsAroundViewport.last, + ); + + newCellsAroundViewport.last = childIdx ?? newCellsAroundViewport.last; + } + + return newCellsAroundViewport; + } + + _findFirstChildWithMore(first: number, last: number): number | null { + for (let ii = first; ii <= last; ii++) { + const cellKeyForIndex = this._indicesToKeys.get(ii); + if ( + cellKeyForIndex != null && + this._nestedChildLists.anyInCell(cellKeyForIndex, childList => + childList.hasMore(), + ) + ) { + return ii; + } + } + + return null; } componentDidMount() { if (this._isNestedWithSameOrientation()) { this.context.registerAsNestedChild({ - cellKey: this._getCellKey(), - key: this._getListKey(), ref: this, - // NOTE: When the child mounts (here) it's not necessarily safe to read - // the parent's props. This is why we explicitly propagate debugInfo - // "down" via context and "up" again via this method call on the - // parent. - parentDebugInfo: this.context.debugInfo, + cellKey: this.context.cellKey, }); } @@ -783,16 +730,8 @@ class VirtualizedList extends React.PureComponent { componentWillUnmount() { if (this._isNestedWithSameOrientation()) { - this.context.unregisterAsNestedChild({ - key: this._getListKey(), - state: { - first: this.state.first, - last: this.state.last, - frames: this._frames, - }, - }); + this.context.unregisterAsNestedChild({ref: this}); } - this._updateViewableItems(null); this._updateCellsToRenderBatcher.dispose({abort: true}); this._viewabilityTuples.forEach(tuple => { tuple.viewabilityHelper.dispose(); @@ -825,18 +764,21 @@ class VirtualizedList extends React.PureComponent { } static getDerivedStateFromProps(newProps: Props, prevState: State): State { - const {data, getItemCount} = newProps; - const maxToRenderPerBatch = maxToRenderPerBatchOrDefault( - newProps.maxToRenderPerBatch, - ); // first and last could be stale (e.g. if a new, shorter items props is passed in), so we make // sure we're rendering a reasonable range here. + const itemCount = newProps.getItemCount(newProps.data); + if (itemCount === prevState.renderMask.numCells()) { + return prevState; + } + + const constrainedCells = VirtualizedList._constrainToItemCount( + prevState.cellsAroundViewport, + newProps, + ); + return { - first: Math.max( - 0, - Math.min(prevState.first, getItemCount(data) - 1 - maxToRenderPerBatch), - ), - last: Math.max(0, Math.min(prevState.last, getItemCount(data) - 1)), + cellsAroundViewport: constrainedCells, + renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells), }; } @@ -851,28 +793,39 @@ class VirtualizedList extends React.PureComponent { const { CellRendererComponent, ItemSeparatorComponent, + ListHeaderComponent, + ListItemComponent, data, + debug, getItem, getItemCount, + getItemLayout, horizontal, + renderItem, } = this.props; - const stickyOffset = this.props.ListHeaderComponent ? 1 : 0; + const stickyOffset = ListHeaderComponent ? 1 : 0; const end = getItemCount(data) - 1; let prevCellKey; last = Math.min(end, last); + for (let ii = first; ii <= last; ii++) { const item = getItem(data, ii); - const key = this._keyExtractor(item, ii); + const key = this._keyExtractor(item, ii, this.props); + this._indicesToKeys.set(ii, key); if (stickyIndicesFromProps.has(ii + stickyOffset)) { stickyHeaderIndices.push(cells.length); } + + const shouldListenForLayout = + getItemLayout == null || debug || this._fillRateHelper.enabled(); + cells.push( { key={key} prevCellKey={prevCellKey} onUpdateSeparators={this._onUpdateSeparators} - onLayout={e => this._onCellLayout(e, key, ii)} + onCellFocusCapture={e => this._onCellFocusCapture(key)} onUnmount={this._onCellUnmount} - parentProps={this.props} ref={ref => { this._cellRefs[key] = ref; }} + renderItem={renderItem} + {...(shouldListenForLayout && { + onCellLayout: this._onCellLayout, + })} />, ); prevCellKey = key; } } + static _constrainToItemCount( + cells: {first: number, last: number}, + props: Props, + ): {first: number, last: number} { + const itemCount = props.getItemCount(props.data); + const last = Math.min(itemCount - 1, cells.last); + + const maxToRenderPerBatch = maxToRenderPerBatchOrDefault( + props.maxToRenderPerBatch, + ); + + return { + first: clamp(0, itemCount - 1 - maxToRenderPerBatch, cells.first), + last, + }; + } + _onUpdateSeparators = (keys: Array, newProps: Object) => { keys.forEach(key => { const ref = key != null && this._cellRefs[key]; @@ -899,10 +872,6 @@ class VirtualizedList extends React.PureComponent { }); }; - _isVirtualizationDisabled(): boolean { - return this.props.disableVirtualization || false; - } - _isNestedWithSameOrientation(): boolean { const nestedContext = this.context; return !!( @@ -914,9 +883,17 @@ class VirtualizedList extends React.PureComponent { _getSpacerKey = (isVertical: boolean): string => isVertical ? 'height' : 'width'; - _keyExtractor(item: Item, index: number) { - if (this.props.keyExtractor != null) { - return this.props.keyExtractor(item, index); + _keyExtractor( + item: Item, + index: number, + props: { + keyExtractor?: ?(item: Item, index: number) => string, + ... + }, + // $FlowFixMe[missing-local-annot] + ) { + if (props.keyExtractor != null) { + return props.keyExtractor(item, index); } const key = defaultKeyExtractor(item, index); @@ -930,27 +907,20 @@ class VirtualizedList extends React.PureComponent { } render(): React.Node { - if (__DEV__) { - const flatStyles = flattenStyle(this.props.contentContainerStyle); - if (flatStyles != null && flatStyles.flexWrap === 'wrap') { - console.warn( - '`flexWrap: `wrap`` is not supported with the `VirtualizedList` components.' + - 'Consider using `numColumns` with `FlatList` instead.', - ); - } - } + this._checkProps(this.props); const {ListEmptyComponent, ListFooterComponent, ListHeaderComponent} = this.props; const {data, horizontal} = this.props; - const isVirtualizationDisabled = this._isVirtualizationDisabled(); const inversionStyle = this.props.inverted ? horizontalOrDefault(this.props.horizontal) ? styles.horizontallyInverted : styles.verticallyInverted : null; - const cells = []; + const cells: Array = []; const stickyIndicesFromProps = new Set(this.props.stickyHeaderIndices); const stickyHeaderIndices = []; + + // 1. Add cell for ListHeaderComponent if (ListHeaderComponent) { if (stickyIndicesFromProps.has(0)) { stickyHeaderIndices.push(0); @@ -980,77 +950,91 @@ class VirtualizedList extends React.PureComponent { , ); } + + // 2a. Add a cell for ListEmptyComponent if applicable const itemCount = this.props.getItemCount(data); + if (itemCount === 0 && ListEmptyComponent) { + const element: React.Element = ((React.isValidElement( + ListEmptyComponent, + ) ? ( + ListEmptyComponent + ) : ( + // $FlowFixMe[not-a-component] + // $FlowFixMe[incompatible-type-arg] + + )): any); + cells.push( + + {React.cloneElement(element, { + onLayout: (event: LayoutEvent) => { + this._onLayoutEmpty(event); + if (element.props.onLayout) { + element.props.onLayout(event); + } + }, + style: [inversionStyle, element.props.style], + })} + , + ); + } + + // 2b. Add cells and spacers for each item if (itemCount > 0) { _usedIndexForKey = false; _keylessItemComponentName = ''; const spacerKey = this._getSpacerKey(!horizontal); - const lastInitialIndex = this.props.initialScrollIndex - ? -1 - : initialNumToRenderOrDefault(this.props.initialNumToRender) - 1; - const {first, last} = this.state; - this._pushCells( - cells, - stickyHeaderIndices, - stickyIndicesFromProps, - 0, - lastInitialIndex, - inversionStyle, - ); - const firstAfterInitial = Math.max(lastInitialIndex + 1, first); - if (!isVirtualizationDisabled && first > lastInitialIndex + 1) { - let insertedStickySpacer = false; - if (stickyIndicesFromProps.size > 0) { - const stickyOffset = ListHeaderComponent ? 1 : 0; - // See if there are any sticky headers in the virtualized space that we need to render. - for (let ii = firstAfterInitial - 1; ii > lastInitialIndex; ii--) { - if (stickyIndicesFromProps.has(ii + stickyOffset)) { - const initBlock = this._getFrameMetricsApprox(lastInitialIndex); - const stickyBlock = this._getFrameMetricsApprox(ii); - const leadSpace = - stickyBlock.offset - - initBlock.offset - - (this.props.initialScrollIndex ? 0 : initBlock.length); - cells.push( - , - ); - this._pushCells( - cells, - stickyHeaderIndices, - stickyIndicesFromProps, - ii, - ii, - inversionStyle, - ); - const trailSpace = - this._getFrameMetricsApprox(first).offset - - (stickyBlock.offset + stickyBlock.length); - cells.push( - , - ); - insertedStickySpacer = true; - break; - } + + const renderRegions = this.state.renderMask.enumerateRegions(); + const lastSpacer = findLastWhere(renderRegions, r => r.isSpacer); + + for (const section of renderRegions) { + if (section.isSpacer) { + // Legacy behavior is to avoid spacers when virtualization is + // disabled (including head spacers on initial render). + if (this.props.disableVirtualization) { + continue; } - } - if (!insertedStickySpacer) { - const initBlock = this._getFrameMetricsApprox(lastInitialIndex); - const firstSpace = - this._getFrameMetricsApprox(first).offset - - (initBlock.offset + initBlock.length); + + // Without getItemLayout, we limit our tail spacer to the _highestMeasuredFrameIndex to + // prevent the user for hyperscrolling into un-measured area because otherwise content will + // likely jump around as it renders in above the viewport. + const isLastSpacer = section === lastSpacer; + const constrainToMeasured = isLastSpacer && !this.props.getItemLayout; + const last = constrainToMeasured + ? clamp( + section.first - 1, + section.last, + this._highestMeasuredFrameIndex, + ) + : section.last; + + const firstMetrics = this.__getFrameMetricsApprox( + section.first, + this.props, + ); + const lastMetrics = this.__getFrameMetricsApprox(last, this.props); + const spacerSize = + lastMetrics.offset + lastMetrics.length - firstMetrics.offset; cells.push( - , + , + ); + } else { + this._pushCells( + cells, + stickyHeaderIndices, + stickyIndicesFromProps, + section.first, + section.last, + inversionStyle, ); } } - this._pushCells( - cells, - stickyHeaderIndices, - stickyIndicesFromProps, - firstAfterInitial, - last, - inversionStyle, - ); + if (!this._hasWarned.keys && _usedIndexForKey) { console.warn( 'VirtualizedList: missing keys for items, make sure to specify a key or id property on each ' + @@ -1059,46 +1043,9 @@ class VirtualizedList extends React.PureComponent { ); this._hasWarned.keys = true; } - if (!isVirtualizationDisabled && last < itemCount - 1) { - const lastFrame = this._getFrameMetricsApprox(last); - // Without getItemLayout, we limit our tail spacer to the _highestMeasuredFrameIndex to - // prevent the user for hyperscrolling into un-measured area because otherwise content will - // likely jump around as it renders in above the viewport. - const end = this.props.getItemLayout - ? itemCount - 1 - : Math.min(itemCount - 1, this._highestMeasuredFrameIndex); - const endFrame = this._getFrameMetricsApprox(end); - const tailSpacerLength = - endFrame.offset + - endFrame.length - - (lastFrame.offset + lastFrame.length); - cells.push( - , - ); - } - } else if (ListEmptyComponent) { - const element: React.Element = ((React.isValidElement( - ListEmptyComponent, - ) ? ( - ListEmptyComponent - ) : ( - // $FlowFixMe[not-a-component] - // $FlowFixMe[incompatible-type-arg] - - )): any); - cells.push( - React.cloneElement(element, { - key: '$empty', - onLayout: event => { - this._onLayoutEmpty(event); - if (element.props.onLayout) { - element.props.onLayout(event); - } - }, - style: [inversionStyle, element.props.style], - }), - ); } + + // 3. Add cell for ListFooterComponent if (ListFooterComponent) { const element = React.isValidElement(ListFooterComponent) ? ( ListFooterComponent @@ -1125,6 +1072,8 @@ class VirtualizedList extends React.PureComponent { , ); } + + // 4. Render the ScrollView const scrollProps = { ...this.props, onContentSizeChange: this._onContentSizeChange, @@ -1147,8 +1096,7 @@ class VirtualizedList extends React.PureComponent { : this.props.style, }; - this._hasMore = - this.state.last < this.props.getItemCount(this.props.data) - 1; + this._hasMore = this.state.cellsAroundViewport.last < itemCount - 1; const innerRet = ( { getScrollMetrics: this._getScrollMetrics, horizontal: horizontalOrDefault(this.props.horizontal), getOutermostParentListRef: this._getOutermostParentListRef, - getNestedChildState: this._getNestedChildState, registerAsNestedChild: this._registerAsNestedChild, unregisterAsNestedChild: this._unregisterAsNestedChild, - debugInfo: this._getDebugInfo(), }}> {React.cloneElement( ( @@ -1174,7 +1120,7 @@ class VirtualizedList extends React.PureComponent { )} ); - let ret = innerRet; + let ret: React.Node = innerRet; /* https://github.com/necolas/react-native-web/issues/2239: Re-enable when ScrollView.Context.Consumer is available. if (__DEV__) { ret = ( @@ -1185,7 +1131,8 @@ class VirtualizedList extends React.PureComponent { !scrollContext.horizontal === !horizontalOrDefault(this.props.horizontal) && !this._hasWarned.nesting && - this.context == null + this.context == null && + this.props.scrollEnabled !== false ) { // TODO (T46547044): use React.warn once 16.9 is sync'd: https://github.com/facebook/react/pull/15170 console.error( @@ -1237,30 +1184,32 @@ class VirtualizedList extends React.PureComponent { } _averageCellLength = 0; - // Maps a cell key to the set of keys for all outermost child lists within that cell - _cellKeysToChildListKeys: Map> = new Map(); - _cellRefs = {}; + _cellRefs: {[string]: null | CellRenderer} = {}; _fillRateHelper: FillRateHelper; - _frames = {}; + _frames: { + [string]: { + inLayout?: boolean, + index: number, + length: number, + offset: number, + }, + } = {}; _footerLength = 0; - _hasDoneInitialScroll = false; + // Used for preventing scrollToIndex from being called multiple times for initialScrollIndex + _hasTriggeredInitialScrollToIndex = false; _hasInteracted = false; _hasMore = false; - _hasWarned = {}; + _hasWarned: {[string]: boolean} = {}; _headerLength = 0; _hiPriInProgress: boolean = false; // flag to prevent infinite hiPri cell limit update _highestMeasuredFrameIndex = 0; _indicesToKeys: Map = new Map(); - _nestedChildLists: Map< - string, - { - ref: ?VirtualizedList, - state: ?ChildListState, - ... - }, - > = new Map(); + _lastFocusedCellKey: ?string = null; + _nestedChildLists: ChildListCollection = + new ChildListCollection(); _offsetFromParentVirtualizedList: number = 0; _prevParentOffset: number = 0; + // $FlowFixMe[missing-local-annot] _scrollMetrics = { contentLength: 0, dOffset: 0, @@ -1269,14 +1218,18 @@ class VirtualizedList extends React.PureComponent { timestamp: 0, velocity: 0, visibleLength: 0, + zoomScale: 1, }; _scrollRef: ?React.ElementRef = null; + _sentStartForContentLength = 0; _sentEndForContentLength = 0; _totalCellLength = 0; _totalCellsMeasured = 0; _updateCellsToRenderBatcher: Batchinator; _viewabilityTuples: Array = []; + /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's + * LTI update could not be added via codemod */ _captureScrollRef = ref => { this._scrollRef = ref; }; @@ -1284,11 +1237,13 @@ class VirtualizedList extends React.PureComponent { _computeBlankness() { this._fillRateHelper.computeBlankness( this.props, - this.state, + this.state.cellsAroundViewport, this._scrollMetrics, ); } + /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's + * LTI update could not be added via codemod */ _defaultRenderScrollComponent = props => { const onRefresh = props.onRefresh; if (this._isNestedWithSameOrientation()) { @@ -1303,11 +1258,13 @@ class VirtualizedList extends React.PureComponent { ); return ( // $FlowFixMe[prop-missing] Invalid prop usage + // $FlowFixMe[incompatible-use] { ); } else { // $FlowFixMe[prop-missing] Invalid prop usage + // $FlowFixMe[incompatible-use] return ; } }; - _onCellLayout(e, cellKey, index) { + _onCellLayout = (e: LayoutEvent, cellKey: string, index: number): void => { const layout = e.nativeEvent.layout; const next = { offset: this._selectOffset(layout), @@ -1356,10 +1314,16 @@ class VirtualizedList extends React.PureComponent { this._triggerRemeasureForChildListsInCell(cellKey); this._computeBlankness(); - this._updateViewableItems(this.props.data); + this._updateViewableItems(this.props, this.state.cellsAroundViewport); + }; + + _onCellFocusCapture(cellKey: string) { + this._lastFocusedCellKey = cellKey; + this._updateCellsToRender(); } _onCellUnmount = (cellKey: string) => { + delete this._cellRefs[cellKey]; const curr = this._frames[cellKey]; if (curr) { this._frames[cellKey] = {...curr, inLayout: false}; @@ -1367,15 +1331,9 @@ class VirtualizedList extends React.PureComponent { }; _triggerRemeasureForChildListsInCell(cellKey: string): void { - const childListKeys = this._cellKeysToChildListKeys.get(cellKey); - if (childListKeys) { - for (let childKey of childListKeys) { - const childList = this._nestedChildLists.get(childKey); - childList && - childList.ref && - childList.ref.measureLayoutRelativeToContainingList(); - } - } + this._nestedChildLists.forEachInCell(cellKey, childList => { + childList.measureLayoutRelativeToContainingList(); + }); } measureLayoutRelativeToContainingList(): void { @@ -1409,15 +1367,8 @@ class VirtualizedList extends React.PureComponent { // If metrics of the scrollView changed, then we triggered remeasure for child list // to ensure VirtualizedList has the right information. - this._cellKeysToChildListKeys.forEach(childListKeys => { - if (childListKeys) { - for (let childKey of childListKeys) { - const childList = this._nestedChildLists.get(childKey); - childList && - childList.ref && - childList.ref.measureLayoutRelativeToContainingList(); - } - } + this._nestedChildLists.forEach(childList => { + childList.measureLayoutRelativeToContainingList(); }); } }, @@ -1436,7 +1387,7 @@ class VirtualizedList extends React.PureComponent { } } - _onLayout = (e: Object) => { + _onLayout = (e: LayoutEvent) => { if (this._isNestedWithSameOrientation()) { // Need to adjust our scroll metrics to be relative to our containing // VirtualizedList before we can make claims about list item viewability @@ -1448,10 +1399,10 @@ class VirtualizedList extends React.PureComponent { } this.props.onLayout && this.props.onLayout(e); this._scheduleCellsToRenderUpdate(); - this._maybeCallOnEndReached(); + this._maybeCallOnEdgeReached(); }; - _onLayoutEmpty = e => { + _onLayoutEmpty = (e: LayoutEvent) => { this.props.onLayout && this.props.onLayout(e); }; @@ -1459,15 +1410,16 @@ class VirtualizedList extends React.PureComponent { return this._getCellKey() + '-footer'; } - _onLayoutFooter = e => { + _onLayoutFooter = (e: LayoutEvent) => { this._triggerRemeasureForChildListsInCell(this._getFooterCellKey()); this._footerLength = this._selectLength(e.nativeEvent.layout); }; - _onLayoutHeader = e => { + _onLayoutHeader = (e: LayoutEvent) => { this._headerLength = this._selectLength(e.nativeEvent.layout); }; + // $FlowFixMe[missing-local-annot] _renderDebugOverlay() { const normalize = this._scrollMetrics.visibleLength / @@ -1475,7 +1427,7 @@ class VirtualizedList extends React.PureComponent { const framesInLayout = []; const itemCount = this.props.getItemCount(this.props.data); for (let ii = 0; ii < itemCount; ii++) { - const frame = this._getFrameMetricsApprox(ii); + const frame = this.__getFrameMetricsApprox(ii, this.props); /* $FlowFixMe[prop-missing] (>=0.68.0 site=react_native_fb) This comment * suppresses an error found when Flow v0.68 was deployed. To see the * error delete this comment and run Flow. */ @@ -1483,8 +1435,14 @@ class VirtualizedList extends React.PureComponent { framesInLayout.push(frame); } } - const windowTop = this._getFrameMetricsApprox(this.state.first).offset; - const frameLast = this._getFrameMetricsApprox(this.state.last); + const windowTop = this.__getFrameMetricsApprox( + this.state.cellsAroundViewport.first, + this.props, + ).offset; + const frameLast = this.__getFrameMetricsApprox( + this.state.cellsAroundViewport.last, + this.props, + ); const windowLen = frameLast.offset + frameLast.length - windowTop; const visTop = this._scrollMetrics.offset; const visLen = this._scrollMetrics.visibleLength; @@ -1550,26 +1508,86 @@ class VirtualizedList extends React.PureComponent { return !horizontalOrDefault(this.props.horizontal) ? metrics.y : metrics.x; } - _maybeCallOnEndReached() { - const {data, getItemCount, onEndReached, onEndReachedThreshold} = - this.props; + _maybeCallOnEdgeReached() { + const { + data, + getItemCount, + onStartReached, + onStartReachedThreshold, + onEndReached, + onEndReachedThreshold, + initialScrollIndex, + } = this.props; const {contentLength, visibleLength, offset} = this._scrollMetrics; - const distanceFromEnd = contentLength - visibleLength - offset; - const threshold = - onEndReachedThreshold != null ? onEndReachedThreshold * visibleLength : 2; + let distanceFromStart = offset; + let distanceFromEnd = contentLength - visibleLength - offset; + + // Especially when oERT is zero it's necessary to 'floor' very small distance values to be 0 + // since debouncing causes us to not fire this event for every single "pixel" we scroll and can thus + // be at the edge of the list with a distance approximating 0 but not quite there. + if (distanceFromStart < ON_EDGE_REACHED_EPSILON) { + distanceFromStart = 0; + } + if (distanceFromEnd < ON_EDGE_REACHED_EPSILON) { + distanceFromEnd = 0; + } + + // TODO: T121172172 Look into why we're "defaulting" to a threshold of 2px + // when oERT is not present (different from 2 viewports used elsewhere) + const DEFAULT_THRESHOLD_PX = 2; + + const startThreshold = + onStartReachedThreshold != null + ? onStartReachedThreshold * visibleLength + : DEFAULT_THRESHOLD_PX; + const endThreshold = + onEndReachedThreshold != null + ? onEndReachedThreshold * visibleLength + : DEFAULT_THRESHOLD_PX; + const isWithinStartThreshold = distanceFromStart <= startThreshold; + const isWithinEndThreshold = distanceFromEnd <= endThreshold; + + // First check if the user just scrolled within the end threshold + // and call onEndReached only once for a given content length, + // and only if onStartReached is not being executed if ( onEndReached && - this.state.last === getItemCount(data) - 1 && - distanceFromEnd < threshold && + this.state.cellsAroundViewport.last === getItemCount(data) - 1 && + isWithinEndThreshold && this._scrollMetrics.contentLength !== this._sentEndForContentLength ) { - // Only call onEndReached once for a given content length this._sentEndForContentLength = this._scrollMetrics.contentLength; onEndReached({distanceFromEnd}); - } else if (distanceFromEnd > threshold) { - // If the user scrolls away from the end and back again cause - // an onEndReached to be triggered again - this._sentEndForContentLength = 0; + } + + // Next check if the user just scrolled within the start threshold + // and call onStartReached only once for a given content length, + // and only if onEndReached is not being executed + else if ( + onStartReached != null && + this.state.cellsAroundViewport.first === 0 && + isWithinStartThreshold && + this._scrollMetrics.contentLength !== this._sentStartForContentLength + ) { + // On initial mount when using initialScrollIndex the offset will be 0 initially + // and will trigger an unexpected onStartReached. To avoid this we can use + // timestamp to differentiate between the initial scroll metrics and when we actually + // received the first scroll event. + if (!initialScrollIndex || this._scrollMetrics.timestamp !== 0) { + this._sentStartForContentLength = this._scrollMetrics.contentLength; + onStartReached({distanceFromStart}); + } + } + + // If the user scrolls away from the start or end and back again, + // cause onStartReached or onEndReached to be triggered again + else { + this._sentStartForContentLength = isWithinStartThreshold + ? this._sentStartForContentLength + : 0; + this._sentEndForContentLength = isWithinEndThreshold + ? this._sentEndForContentLength + : 0; } } @@ -1579,22 +1597,29 @@ class VirtualizedList extends React.PureComponent { height > 0 && this.props.initialScrollIndex != null && this.props.initialScrollIndex > 0 && - !this._hasDoneInitialScroll + !this._hasTriggeredInitialScrollToIndex ) { if (this.props.contentOffset == null) { - this.scrollToIndex({ - animated: false, - index: this.props.initialScrollIndex, - }); + if ( + this.props.initialScrollIndex < + this.props.getItemCount(this.props.data) + ) { + this.scrollToIndex({ + animated: false, + index: nullthrows(this.props.initialScrollIndex), + }); + } else { + this.scrollToEnd({animated: false}); + } } - this._hasDoneInitialScroll = true; + this._hasTriggeredInitialScrollToIndex = true; } if (this.props.onContentSizeChange) { this.props.onContentSizeChange(width, height); } this._scrollMetrics.contentLength = this._selectLength({height, width}); this._scheduleCellsToRenderUpdate(); - this._maybeCallOnEndReached(); + this._maybeCallOnEdgeReached(); }; /* Translates metrics from a scroll event in a parent VirtualizedList into @@ -1604,7 +1629,7 @@ class VirtualizedList extends React.PureComponent { visibleLength: number, offset: number, ... - }) => { + }): $FlowFixMe => { // Offset of the top of the nested list relative to the top of its parent's viewport const offset = metrics.offset - this._offsetFromParentVirtualizedList; // Child's visible length is the same as its parent's @@ -1622,7 +1647,7 @@ class VirtualizedList extends React.PureComponent { _onScroll = (e: Object) => { this._nestedChildLists.forEach(childList => { - childList.ref && childList.ref._onScroll(e); + childList._onScroll(e); }); if (this.props.onScroll) { this.props.onScroll(e); @@ -1665,6 +1690,9 @@ class VirtualizedList extends React.PureComponent { ); this._hasWarned.perf = true; } + + // For invalid negative values (w/ RTL), set this to 1. + const zoomScale = e.nativeEvent.zoomScale < 0 ? 1 : e.nativeEvent.zoomScale; this._scrollMetrics = { contentLength, dt, @@ -1673,12 +1701,13 @@ class VirtualizedList extends React.PureComponent { timestamp, velocity, visibleLength, + zoomScale, }; - this._updateViewableItems(this.props.data); + this._updateViewableItems(this.props, this.state.cellsAroundViewport); if (!this.props) { return; } - this._maybeCallOnEndReached(); + this._maybeCallOnEdgeReached(); if (velocity !== 0) { this._fillRateHelper.activate(); } @@ -1687,30 +1716,38 @@ class VirtualizedList extends React.PureComponent { }; _scheduleCellsToRenderUpdate() { - const {first, last} = this.state; + const {first, last} = this.state.cellsAroundViewport; const {offset, visibleLength, velocity} = this._scrollMetrics; const itemCount = this.props.getItemCount(this.props.data); let hiPri = false; + const onStartReachedThreshold = onStartReachedThresholdOrDefault( + this.props.onStartReachedThreshold, + ); const onEndReachedThreshold = onEndReachedThresholdOrDefault( this.props.onEndReachedThreshold, ); - const scrollingThreshold = (onEndReachedThreshold * visibleLength) / 2; // Mark as high priority if we're close to the start of the first item // But only if there are items before the first rendered item if (first > 0) { - const distTop = offset - this._getFrameMetricsApprox(first).offset; + const distTop = + offset - this.__getFrameMetricsApprox(first, this.props).offset; hiPri = - hiPri || distTop < 0 || (velocity < -2 && distTop < scrollingThreshold); + distTop < 0 || + (velocity < -2 && + distTop < + getScrollingThreshold(onStartReachedThreshold, visibleLength)); } // Mark as high priority if we're close to the end of the last item // But only if there are items after the last rendered item - if (last < itemCount - 1) { + if (!hiPri && last >= 0 && last < itemCount - 1) { const distBottom = - this._getFrameMetricsApprox(last).offset - (offset + visibleLength); + this.__getFrameMetricsApprox(last, this.props).offset - + (offset + visibleLength); hiPri = - hiPri || distBottom < 0 || - (velocity > 2 && distBottom < scrollingThreshold); + (velocity > 2 && + distBottom < + getScrollingThreshold(onEndReachedThreshold, visibleLength)); } // Only trigger high-priority updates if we've actually rendered cells, // and with that size estimate, accurately compute how many cells we should render. @@ -1735,9 +1772,9 @@ class VirtualizedList extends React.PureComponent { } } - _onScrollBeginDrag = (e): void => { + _onScrollBeginDrag = (e: ScrollEvent): void => { this._nestedChildLists.forEach(childList => { - childList.ref && childList.ref._onScrollBeginDrag(e); + childList._onScrollBeginDrag(e); }); this._viewabilityTuples.forEach(tuple => { tuple.viewabilityHelper.recordInteraction(); @@ -1746,9 +1783,9 @@ class VirtualizedList extends React.PureComponent { this.props.onScrollBeginDrag && this.props.onScrollBeginDrag(e); }; - _onScrollEndDrag = (e): void => { + _onScrollEndDrag = (e: ScrollEvent): void => { this._nestedChildLists.forEach(childList => { - childList.ref && childList.ref._onScrollEndDrag(e); + childList._onScrollEndDrag(e); }); const {velocity} = e.nativeEvent; if (velocity) { @@ -1758,16 +1795,16 @@ class VirtualizedList extends React.PureComponent { this.props.onScrollEndDrag && this.props.onScrollEndDrag(e); }; - _onMomentumScrollBegin = (e): void => { + _onMomentumScrollBegin = (e: ScrollEvent): void => { this._nestedChildLists.forEach(childList => { - childList.ref && childList.ref._onMomentumScrollBegin(e); + childList._onMomentumScrollBegin(e); }); this.props.onMomentumScrollBegin && this.props.onMomentumScrollBegin(e); }; - _onMomentumScrollEnd = (e): void => { + _onMomentumScrollEnd = (e: ScrollEvent): void => { this._nestedChildLists.forEach(childList => { - childList.ref && childList.ref._onMomentumScrollEnd(e); + childList._onMomentumScrollEnd(e); }); this._scrollMetrics.velocity = 0; this._computeBlankness(); @@ -1775,116 +1812,82 @@ class VirtualizedList extends React.PureComponent { }; _updateCellsToRender = () => { - const { - data, - getItemCount, - onEndReachedThreshold: _onEndReachedThreshold, - } = this.props; - const onEndReachedThreshold = onEndReachedThresholdOrDefault( - _onEndReachedThreshold, - ); - const isVirtualizationDisabled = this._isVirtualizationDisabled(); - this._updateViewableItems(data); - if (!data) { - return; - } - this.setState(state => { - let newState; - const {contentLength, offset, visibleLength} = this._scrollMetrics; - if (!isVirtualizationDisabled) { - // If we run this with bogus data, we'll force-render window {first: 0, last: 0}, - // and wipe out the initialNumToRender rendered elements. - // So let's wait until the scroll view metrics have been set up. And until then, - // we will trust the initialNumToRender suggestion - if (visibleLength > 0 && contentLength > 0) { - // If we have a non-zero initialScrollIndex and run this before we've scrolled, - // we'll wipe out the initialNumToRender rendered elements starting at initialScrollIndex. - // So let's wait until we've scrolled the view to the right place. And until then, - // we will trust the initialScrollIndex suggestion. - if (!this.props.initialScrollIndex || this._scrollMetrics.offset) { - newState = computeWindowedRenderLimits( - this.props.data, - this.props.getItemCount, - maxToRenderPerBatchOrDefault(this.props.maxToRenderPerBatch), - windowSizeOrDefault(this.props.windowSize), - state, - this._getFrameMetricsApprox, - this._scrollMetrics, - ); - } - } - } else { - const distanceFromEnd = contentLength - visibleLength - offset; - const renderAhead = - distanceFromEnd < onEndReachedThreshold * visibleLength - ? maxToRenderPerBatchOrDefault(this.props.maxToRenderPerBatch) - : 0; - newState = { - first: 0, - last: Math.min(state.last + renderAhead, getItemCount(data) - 1), - }; - } - if (newState && this._nestedChildLists.size > 0) { - const newFirst = newState.first; - const newLast = newState.last; - // If some cell in the new state has a child list in it, we should only render - // up through that item, so that we give that list a chance to render. - // Otherwise there's churn from multiple child lists mounting and un-mounting - // their items. - for (let ii = newFirst; ii <= newLast; ii++) { - const cellKeyForIndex = this._indicesToKeys.get(ii); - const childListKeys = - cellKeyForIndex && - this._cellKeysToChildListKeys.get(cellKeyForIndex); - if (!childListKeys) { - continue; - } - let someChildHasMore = false; - // For each cell, need to check whether any child list in it has more elements to render - for (let childKey of childListKeys) { - const childList = this._nestedChildLists.get(childKey); - if (childList && childList.ref && childList.ref.hasMore()) { - someChildHasMore = true; - break; - } - } - if (someChildHasMore) { - // $FlowFixMe[incompatible-use] The newState definitely exists past "if (newState &&" - newState.last = ii; - break; - } - } - } + this._updateViewableItems(this.props, this.state.cellsAroundViewport); + + this.setState((state, props) => { + const cellsAroundViewport = this._adjustCellsAroundViewport( + props, + state.cellsAroundViewport, + ); + const renderMask = VirtualizedList._createRenderMask( + props, + cellsAroundViewport, + this._getNonViewportRenderRegions(props), + ); + if ( - newState != null && - newState.first === state.first && - newState.last === state.last + cellsAroundViewport.first === state.cellsAroundViewport.first && + cellsAroundViewport.last === state.cellsAroundViewport.last && + renderMask.equals(state.renderMask) ) { - newState = null; + return null; } - return newState; + + return {cellsAroundViewport, renderMask}; }); }; - _createViewToken = (index: number, isViewable: boolean) => { - const {data, getItem} = this.props; + _createViewToken = ( + index: number, + isViewable: boolean, + props: FrameMetricProps, + // $FlowFixMe[missing-local-annot] + ) => { + const {data, getItem} = props; const item = getItem(data, index); - return {index, item, key: this._keyExtractor(item, index), isViewable}; + return { + index, + item, + key: this._keyExtractor(item, index, props), + isViewable, + }; + }; + + /** + * Gets an approximate offset to an item at a given index. Supports + * fractional indices. + */ + _getOffsetApprox = (index: number, props: FrameMetricProps): number => { + if (Number.isInteger(index)) { + return this.__getFrameMetricsApprox(index, props).offset; + } else { + const frameMetrics = this.__getFrameMetricsApprox( + Math.floor(index), + props, + ); + const remainder = index - Math.floor(index); + return frameMetrics.offset + remainder * frameMetrics.length; + } }; - _getFrameMetricsApprox = ( + __getFrameMetricsApprox: ( index: number, - ): { + props: FrameMetricProps, + ) => { length: number, offset: number, ... - } => { - const frame = this._getFrameMetrics(index); + } = (index, props) => { + const frame = this._getFrameMetrics(index, props); if (frame && frame.index === index) { // check for invalid frames due to row re-ordering return frame; } else { - const {getItemLayout} = this.props; + const {data, getItemCount, getItemLayout} = props; + invariant( + index >= 0 && index < getItemCount(data), + 'Tried to get frame for out of range index ' + index, + ); invariant( !getItemLayout, 'Should not have to estimate frames when a measurement metrics function is provided', @@ -1898,6 +1901,7 @@ class VirtualizedList extends React.PureComponent { _getFrameMetrics = ( index: number, + props: FrameMetricProps, ): ?{ length: number, offset: number, @@ -1905,265 +1909,106 @@ class VirtualizedList extends React.PureComponent { inLayout?: boolean, ... } => { - const {data, getItem, getItemCount, getItemLayout} = this.props; + const {data, getItem, getItemCount, getItemLayout} = props; invariant( - getItemCount(data) > index, + index >= 0 && index < getItemCount(data), 'Tried to get frame for out of range index ' + index, ); const item = getItem(data, index); - let frame = item && this._frames[this._keyExtractor(item, index)]; + const frame = this._frames[this._keyExtractor(item, index, props)]; if (!frame || frame.index !== index) { if (getItemLayout) { - frame = getItemLayout(data, index); + /* $FlowFixMe[prop-missing] (>=0.63.0 site=react_native_fb) This comment + * suppresses an error found when Flow v0.63 was deployed. To see the error + * delete this comment and run Flow. */ + return getItemLayout(data, index); } } - /* $FlowFixMe[prop-missing] (>=0.63.0 site=react_native_fb) This comment - * suppresses an error found when Flow v0.63 was deployed. To see the error - * delete this comment and run Flow. */ return frame; }; - _updateViewableItems(data: any) { - const {getItemCount} = this.props; + _getNonViewportRenderRegions = ( + props: FrameMetricProps, + ): $ReadOnlyArray<{ + first: number, + last: number, + }> => { + // Keep a viewport's worth of content around the last focused cell to allow + // random navigation around it without any blanking. E.g. tabbing from one + // focused item out of viewport to another. + if ( + !(this._lastFocusedCellKey && this._cellRefs[this._lastFocusedCellKey]) + ) { + return []; + } + + const lastFocusedCellRenderer = this._cellRefs[this._lastFocusedCellKey]; + const focusedCellIndex = lastFocusedCellRenderer.props.index; + const itemCount = props.getItemCount(props.data); + + // The last cell we rendered may be at a new index. Bail if we don't know + // where it is. + if ( + focusedCellIndex >= itemCount || + this._keyExtractor( + props.getItem(props.data, focusedCellIndex), + focusedCellIndex, + props, + ) !== this._lastFocusedCellKey + ) { + return []; + } + + let first = focusedCellIndex; + let heightOfCellsBeforeFocused = 0; + for ( + let i = first - 1; + i >= 0 && heightOfCellsBeforeFocused < this._scrollMetrics.visibleLength; + i-- + ) { + first--; + heightOfCellsBeforeFocused += this.__getFrameMetricsApprox( + i, + props, + ).length; + } + + let last = focusedCellIndex; + let heightOfCellsAfterFocused = 0; + for ( + let i = last + 1; + i < itemCount && + heightOfCellsAfterFocused < this._scrollMetrics.visibleLength; + i++ + ) { + last++; + heightOfCellsAfterFocused += this.__getFrameMetricsApprox( + i, + props, + ).length; + } + + return [{first, last}]; + }; + _updateViewableItems( + props: FrameMetricProps, + cellsAroundViewport: {first: number, last: number}, + ) { this._viewabilityTuples.forEach(tuple => { tuple.viewabilityHelper.onUpdate( - getItemCount(data), + props, this._scrollMetrics.offset, this._scrollMetrics.visibleLength, this._getFrameMetrics, this._createViewToken, tuple.onViewableItemsChanged, - this.state, + cellsAroundViewport, ); }); } } -type CellRendererProps = { - CellRendererComponent?: ?React.ComponentType, - ItemSeparatorComponent: ?React.ComponentType< - any | {highlighted: boolean, leadingItem: ?Item}, - >, - cellKey: string, - fillRateHelper: FillRateHelper, - horizontal: ?boolean, - index: number, - inversionStyle: ViewStyleProp, - item: Item, - // This is extracted by ScrollViewStickyHeader - onLayout: (event: Object) => void, - onUnmount: (cellKey: string) => void, - onUpdateSeparators: (cellKeys: Array, props: Object) => void, - parentProps: { - // e.g. height, y, - getItemLayout?: ( - data: any, - index: number, - ) => { - length: number, - offset: number, - index: number, - ... - }, - renderItem?: ?RenderItemType, - ListItemComponent?: ?(React.ComponentType | React.Element), - ... - }, - prevCellKey: ?string, - ... -}; - -type CellRendererState = { - separatorProps: $ReadOnly<{| - highlighted: boolean, - leadingItem: ?Item, - |}>, - ... -}; - -class CellRenderer extends React.Component< - CellRendererProps, - CellRendererState, -> { - state = { - separatorProps: { - highlighted: false, - leadingItem: this.props.item, - }, - }; - - static getDerivedStateFromProps( - props: CellRendererProps, - prevState: CellRendererState, - ): ?CellRendererState { - return { - separatorProps: { - ...prevState.separatorProps, - leadingItem: props.item, - }, - }; - } - - // TODO: consider factoring separator stuff out of VirtualizedList into FlatList since it's not - // reused by SectionList and we can keep VirtualizedList simpler. - _separators = { - highlight: () => { - const {cellKey, prevCellKey} = this.props; - this.props.onUpdateSeparators([cellKey, prevCellKey], { - highlighted: true, - }); - }, - unhighlight: () => { - const {cellKey, prevCellKey} = this.props; - this.props.onUpdateSeparators([cellKey, prevCellKey], { - highlighted: false, - }); - }, - updateProps: (select: 'leading' | 'trailing', newProps: Object) => { - const {cellKey, prevCellKey} = this.props; - this.props.onUpdateSeparators( - [select === 'leading' ? prevCellKey : cellKey], - newProps, - ); - }, - }; - - updateSeparatorProps(newProps: Object) { - this.setState(state => ({ - separatorProps: {...state.separatorProps, ...newProps}, - })); - } - - componentWillUnmount() { - this.props.onUnmount(this.props.cellKey); - } - - _renderElement(renderItem, ListItemComponent, item, index) { - if (renderItem && ListItemComponent) { - console.warn( - 'VirtualizedList: Both ListItemComponent and renderItem props are present. ListItemComponent will take' + - ' precedence over renderItem.', - ); - } - - if (ListItemComponent) { - /* $FlowFixMe[not-a-component] (>=0.108.0 site=react_native_fb) This - * comment suppresses an error found when Flow v0.108 was deployed. To - * see the error, delete this comment and run Flow. */ - /* $FlowFixMe[incompatible-type-arg] (>=0.108.0 site=react_native_fb) - * This comment suppresses an error found when Flow v0.108 was deployed. - * To see the error, delete this comment and run Flow. */ - return React.createElement(ListItemComponent, { - item, - index, - separators: this._separators, - }); - } - - if (renderItem) { - return renderItem({ - item, - index, - separators: this._separators, - }); - } - - invariant( - false, - 'VirtualizedList: Either ListItemComponent or renderItem props are required but none were found.', - ); - } - - render() { - const { - CellRendererComponent, - ItemSeparatorComponent, - fillRateHelper, - horizontal, - item, - index, - inversionStyle, - parentProps, - } = this.props; - const {renderItem, getItemLayout, ListItemComponent} = parentProps; - const element = this._renderElement( - renderItem, - ListItemComponent, - item, - index, - ); - - const onLayout = - /* $FlowFixMe[prop-missing] (>=0.68.0 site=react_native_fb) This comment - * suppresses an error found when Flow v0.68 was deployed. To see the - * error delete this comment and run Flow. */ - getItemLayout && !parentProps.debug && !fillRateHelper.enabled() - ? undefined - : this.props.onLayout; - // NOTE: that when this is a sticky header, `onLayout` will get automatically extracted and - // called explicitly by `ScrollViewStickyHeader`. - const itemSeparator = ItemSeparatorComponent && ( - - ); - const cellStyle = inversionStyle - ? horizontal - ? [styles.rowReverse, inversionStyle] - : [styles.columnReverse, inversionStyle] - : horizontal - ? [styles.row, inversionStyle] - : inversionStyle; - const result = !CellRendererComponent ? ( - /* $FlowFixMe[incompatible-type-arg] (>=0.89.0 site=react_native_fb) * - This comment suppresses an error found when Flow v0.89 was deployed. * - To see the error, delete this comment and run Flow. */ - - {element} - {itemSeparator} - - ) : ( - - {element} - {itemSeparator} - - ); - - return ( - - {result} - - ); - } -} - -function describeNestedLists(childList: { - +cellKey: string, - +key: string, - +ref: VirtualizedList, - +parentDebugInfo: ListDebugInfo, - +horizontal: boolean, - ... -}) { - let trace = - 'VirtualizedList trace:\n' + - ` Child (${childList.horizontal ? 'horizontal' : 'vertical'}):\n` + - ` listKey: ${childList.key}\n` + - ` cellKey: ${childList.cellKey}`; - - let debugInfo = childList.parentDebugInfo; - while (debugInfo) { - trace += - `\n Parent (${debugInfo.horizontal ? 'horizontal' : 'vertical'}):\n` + - ` listKey: ${debugInfo.listKey}\n` + - ` cellKey: ${debugInfo.cellKey}`; - debugInfo = debugInfo.parent; - } - return trace; -} - const styles = StyleSheet.create({ verticallyInverted: { transform: 'scaleY(-1)', @@ -2171,15 +2016,6 @@ const styles = StyleSheet.create({ horizontallyInverted: { transform: 'scaleX(-1)', }, - row: { - flexDirection: 'row', - }, - rowReverse: { - flexDirection: 'row-reverse', - }, - columnReverse: { - flexDirection: 'column-reverse', - }, debug: { flex: 1, }, @@ -2210,4 +2046,4 @@ const styles = StyleSheet.create({ }, }); -export default VirtualizedList; +export default VirtualizedList; \ No newline at end of file diff --git a/packages/react-native-web/src/vendor/react-native/VirtualizedSectionList/index.js b/packages/react-native-web/src/vendor/react-native/VirtualizedSectionList/index.js index 00930728ce..c37c8d3727 100644 --- a/packages/react-native-web/src/vendor/react-native/VirtualizedSectionList/index.js +++ b/packages/react-native-web/src/vendor/react-native/VirtualizedSectionList/index.js @@ -8,15 +8,12 @@ * @format */ -'use strict'; - import type {ViewToken} from '../ViewabilityHelper'; -import {keyExtractor as defaultKeyExtractor} from '../VirtualizeUtils'; import View from '../../../exports/View'; import VirtualizedList from '../VirtualizedList'; -import * as React from 'react'; - +import {keyExtractor as defaultKeyExtractor} from '../VirtualizeUtils'; import invariant from 'fbjs/lib/invariant'; +import * as React from 'react'; type Item = any; @@ -141,9 +138,9 @@ class VirtualizedSectionList< return; } if (params.itemIndex > 0 && this.props.stickySectionHeadersEnabled) { - // $FlowFixMe[prop-missing] Cannot access private property - const frame = this._listRef._getFrameMetricsApprox( + const frame = this._listRef.__getFrameMetricsApprox( index - params.itemIndex, + this._listRef.props, ); viewOffset += frame.length; } @@ -152,6 +149,7 @@ class VirtualizedSectionList< viewOffset, index, }; + // $FlowFixMe[incompatible-use] this._listRef.scrollToIndex(toIndexParams); } @@ -174,7 +172,7 @@ class VirtualizedSectionList< const listHeaderOffset = this.props.ListHeaderComponent ? 1 : 0; const stickyHeaderIndices = this.props.stickySectionHeadersEnabled - ? [] + ? ([]: Array) : undefined; let itemCount = 0; @@ -239,6 +237,7 @@ class VirtualizedSectionList< return null; } + // $FlowFixMe[missing-local-annot] _keyExtractor = (item: Item, index: number) => { const info = this._subExtractor(index); return (info && info.key) || String(index); @@ -342,7 +341,8 @@ class VirtualizedSectionList< }; _renderItem = - (listItemCount: number) => + (listItemCount: number): $FlowFixMe => + // eslint-disable-next-line react/no-unstable-nested-components ({item, index}: {item: Item, index: number, ...}) => { const info = this._subExtractor(index); if (!info) { @@ -394,29 +394,33 @@ class VirtualizedSectionList< } }; - _updatePropsFor = (cellKey, value) => { + _updatePropsFor = (cellKey: string, value: any) => { const updateProps = this._updatePropsMap[cellKey]; if (updateProps != null) { updateProps(value); } }; - _updateHighlightFor = (cellKey, value) => { + _updateHighlightFor = (cellKey: string, value: boolean) => { const updateHighlight = this._updateHighlightMap[cellKey]; if (updateHighlight != null) { updateHighlight(value); } }; - _setUpdateHighlightFor = (cellKey, updateHighlightFn) => { + _setUpdateHighlightFor = ( + cellKey: string, + updateHighlightFn: ?(boolean) => void, + ) => { if (updateHighlightFn != null) { this._updateHighlightMap[cellKey] = updateHighlightFn; } else { + // $FlowFixMe[prop-missing] delete this._updateHighlightFor[cellKey]; } }; - _setUpdatePropsFor = (cellKey, updatePropsFn) => { + _setUpdatePropsFor = (cellKey: string, updatePropsFn: ?(boolean) => void) => { if (updatePropsFn != null) { this._updatePropsMap[cellKey] = updatePropsFn; } else { @@ -448,10 +452,10 @@ class VirtualizedSectionList< return null; } - _updateHighlightMap = {}; - _updatePropsMap = {}; + _updateHighlightMap: {[string]: (boolean) => void} = {}; + _updatePropsMap: {[string]: void | (boolean => void)} = {}; _listRef: ?React.ElementRef; - _captureRef = ref => { + _captureRef = (ref: null | React$ElementRef>) => { this._listRef = ref; }; } @@ -525,6 +529,7 @@ function ItemWithSeparator(props: ItemWithSeparatorProps): React.Node { React.useEffect(() => { setSelfHighlightCallback(cellKey, setSeparatorHighlighted); + // $FlowFixMe[incompatible-call] setSelfUpdatePropsCallback(cellKey, setSeparatorProps); return () => { @@ -598,4 +603,14 @@ function ItemWithSeparator(props: ItemWithSeparatorProps): React.Node { ); } -export default VirtualizedSectionList; \ No newline at end of file +/* $FlowFixMe[class-object-subtyping] added when improving typing for this + * parameters */ +// $FlowFixMe[method-unbinding] +export default (VirtualizedSectionList: React.AbstractComponent< + React.ElementConfig, + $ReadOnly<{ + getListRef: () => ?React.ElementRef, + scrollToLocation: (params: ScrollToLocationParamsType) => void, + ... + }>, +>); diff --git a/packages/react-native-web/src/vendor/react-native/infoLog/index.js b/packages/react-native-web/src/vendor/react-native/infoLog/index.js index 4bf15b1408..7f2a63e0cd 100644 --- a/packages/react-native-web/src/vendor/react-native/infoLog/index.js +++ b/packages/react-native-web/src/vendor/react-native/infoLog/index.js @@ -3,13 +3,17 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict */ + 'use strict'; /** * Intentional info-level logging for clear separation from ad-hoc console debug logging. */ -function infoLog(...args) { +function infoLog(...args: Array): void { return console.log(...args); } From 4a61c1600feb103d023108bc8f761ba4a8c7de1b Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Wed, 5 Apr 2023 10:47:54 -0700 Subject: [PATCH 05/13] 0.19.2 --- package-lock.json | 16 ++++++++-------- .../babel-plugin-react-native-web/package.json | 2 +- packages/react-native-web-docs/package.json | 2 +- packages/react-native-web-examples/package.json | 6 +++--- packages/react-native-web/package.json | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 814c4ed16b..fdc1bf6a72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15420,7 +15420,7 @@ } }, "packages/babel-plugin-react-native-web": { - "version": "0.19.1", + "version": "0.19.2", "license": "MIT", "devDependencies": { "babel-plugin-tester": "^10.1.0" @@ -15476,7 +15476,7 @@ "license": "MIT" }, "packages/react-native-web": { - "version": "0.19.1", + "version": "0.19.2", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.6", @@ -15494,7 +15494,7 @@ } }, "packages/react-native-web-docs": { - "version": "0.19.1", + "version": "0.19.2", "devDependencies": { "@11ty/eleventy": "^1.0.1", "@11ty/eleventy-navigation": "^0.3.3", @@ -15515,14 +15515,14 @@ } }, "packages/react-native-web-examples": { - "version": "0.19.1", + "version": "0.19.2", "license": "MIT", "dependencies": { - "babel-plugin-react-native-web": "0.19.1", + "babel-plugin-react-native-web": "0.19.2", "next": "^12.2.0", "react": "^18.0.0", "react-dom": "^18.0.0", - "react-native-web": "0.19.1" + "react-native-web": "0.19.2" }, "devDependencies": { "@babel/core": "^7.18.6", @@ -23662,11 +23662,11 @@ "requires": { "@babel/core": "^7.18.6", "@babel/preset-flow": "^7.18.6", - "babel-plugin-react-native-web": "0.19.1", + "babel-plugin-react-native-web": "0.19.2", "next": "^12.2.0", "react": "^18.0.0", "react-dom": "^18.0.0", - "react-native-web": "0.19.1" + "react-native-web": "0.19.2" } }, "read-pkg": { diff --git a/packages/babel-plugin-react-native-web/package.json b/packages/babel-plugin-react-native-web/package.json index b868e47745..676e7b9a33 100644 --- a/packages/babel-plugin-react-native-web/package.json +++ b/packages/babel-plugin-react-native-web/package.json @@ -3,7 +3,7 @@ "registry": "https://registry.npmjs.org/" }, "name": "babel-plugin-react-native-web", - "version": "0.19.1", + "version": "0.19.2", "description": "Babel plugin for React Native for Web", "main": "index.js", "devDependencies": { diff --git a/packages/react-native-web-docs/package.json b/packages/react-native-web-docs/package.json index 2f0a5e029c..9b82251967 100644 --- a/packages/react-native-web-docs/package.json +++ b/packages/react-native-web-docs/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "react-native-web-docs", - "version": "0.19.1", + "version": "0.19.2", "description": "Documentation website for React Native for Web", "scripts": { "dev": "eleventy --serve", diff --git a/packages/react-native-web-examples/package.json b/packages/react-native-web-examples/package.json index b5e21299f3..31d2f06015 100644 --- a/packages/react-native-web-examples/package.json +++ b/packages/react-native-web-examples/package.json @@ -1,18 +1,18 @@ { "private": true, "name": "react-native-web-examples", - "version": "0.19.1", + "version": "0.19.2", "scripts": { "build": "next build", "dev": "next", "start": "next start" }, "dependencies": { - "babel-plugin-react-native-web": "0.19.1", + "babel-plugin-react-native-web": "0.19.2", "next": "^12.2.0", "react": "^18.0.0", "react-dom": "^18.0.0", - "react-native-web": "0.19.1" + "react-native-web": "0.19.2" }, "devDependencies": { "@babel/core": "^7.18.6", diff --git a/packages/react-native-web/package.json b/packages/react-native-web/package.json index 23d183fb9a..c955485bae 100644 --- a/packages/react-native-web/package.json +++ b/packages/react-native-web/package.json @@ -3,7 +3,7 @@ "registry": "https://registry.npmjs.org/" }, "name": "react-native-web", - "version": "0.19.1", + "version": "0.19.2", "description": "React Native for Web", "module": "dist/index.js", "main": "dist/cjs/index.js", From 67957380378cfdd52cf0adead603fde5fdca8af3 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Tue, 11 Apr 2023 16:20:59 -0700 Subject: [PATCH 06/13] [fix] Patch more internal sources of deprecation warnings --- .../src/exports/ActivityIndicator/index.js | 6 +++--- .../react-native-web/src/exports/CheckBox/index.js | 4 +++- .../react-native-web/src/exports/Modal/ModalContent.js | 4 ++-- .../src/exports/Modal/ModalFocusTrap.js | 2 +- .../react-native-web/src/exports/TextInput/index.js | 10 +++++----- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/react-native-web/src/exports/ActivityIndicator/index.js b/packages/react-native-web/src/exports/ActivityIndicator/index.js index 6350c34306..151337204e 100644 --- a/packages/react-native-web/src/exports/ActivityIndicator/index.js +++ b/packages/react-native-web/src/exports/ActivityIndicator/index.js @@ -56,10 +56,10 @@ const ActivityIndicator: React.AbstractComponent< return ( void, onValueChange?: ?(e: any) => void, + readOnly?: boolean, value?: boolean }; @@ -35,6 +36,7 @@ const CheckBox: React.AbstractComponent< disabled, onChange, onValueChange, + readOnly, style, value, ...other @@ -64,7 +66,7 @@ const CheckBox: React.AbstractComponent< checked: value, disabled: disabled, onChange: handleChange, - readOnly: ariaReadOnly || other.accessibilityReadOnly, + readOnly: readOnly || ariaReadOnly || other.accessibilityReadOnly, ref: forwardedRef, style: [styles.nativeControl, styles.cursorInherit], type: 'checkbox' diff --git a/packages/react-native-web/src/exports/Modal/ModalContent.js b/packages/react-native-web/src/exports/Modal/ModalContent.js index e81a194ca4..0ce3ee493c 100644 --- a/packages/react-native-web/src/exports/Modal/ModalContent.js +++ b/packages/react-native-web/src/exports/Modal/ModalContent.js @@ -54,9 +54,9 @@ const ModalContent: React.AbstractComponent< return ( {children} diff --git a/packages/react-native-web/src/exports/Modal/ModalFocusTrap.js b/packages/react-native-web/src/exports/Modal/ModalFocusTrap.js index f293a9c8ab..f596bf60d2 100644 --- a/packages/react-native-web/src/exports/Modal/ModalFocusTrap.js +++ b/packages/react-native-web/src/exports/Modal/ModalFocusTrap.js @@ -25,7 +25,7 @@ import canUseDOM from '../../modules/canUseDom'; const FocusBracket = () => { return createElement('div', { - accessibilityRole: 'none', + role: 'none', tabIndex: 0, style: styles.focusBracket }); diff --git a/packages/react-native-web/src/exports/TextInput/index.js b/packages/react-native-web/src/exports/TextInput/index.js index eaebb34460..7f256dc0a0 100644 --- a/packages/react-native-web/src/exports/TextInput/index.js +++ b/packages/react-native-web/src/exports/TextInput/index.js @@ -101,12 +101,12 @@ const TextInput: React.AbstractComponent< blurOnSubmit, clearTextOnFocus, dir, - editable = true, + editable, enterKeyHint, inputMode, - keyboardType = 'default', + keyboardType, multiline = false, - numberOfLines = 1, + numberOfLines, onBlur, onChange, onChangeText, @@ -133,9 +133,9 @@ const TextInput: React.AbstractComponent< onStartShouldSetResponderCapture, onSubmitEditing, placeholderTextColor, - readOnly, + readOnly = false, returnKeyType, - rows, + rows = 1, secureTextEntry = false, selection, selectTextOnFocus, From 338cce194de0ca4a458b82304366b3e6dac94e43 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Tue, 11 Apr 2023 16:21:34 -0700 Subject: [PATCH 07/13] [fix] SSR of Animated doesn't call useLayoutEffect --- .../src/vendor/react-native/Animated/useAnimatedProps.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-native-web/src/vendor/react-native/Animated/useAnimatedProps.js b/packages/react-native-web/src/vendor/react-native/Animated/useAnimatedProps.js index c37a00b622..64b31b03b4 100644 --- a/packages/react-native-web/src/vendor/react-native/Animated/useAnimatedProps.js +++ b/packages/react-native-web/src/vendor/react-native/Animated/useAnimatedProps.js @@ -17,12 +17,13 @@ import NativeAnimatedHelper from './NativeAnimatedHelper'; import { useCallback, useEffect, - useLayoutEffect, useMemo, useReducer, useRef, } from 'react'; +import useLayoutEffect from '../../../modules/useLayoutEffect'; + type ReducedProps = { ...TProps, collapsable: boolean, From 3e74ed0ea754d0862afd2098c2d1b1a365dee92a Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Tue, 11 Apr 2023 16:22:59 -0700 Subject: [PATCH 08/13] Update interactive examples --- .../react-native-web-examples/next.config.js | 6 ++- .../pages/clipboard/index.js | 3 +- .../pages/image/index.js | 9 +++-- .../react-native-web-examples/pages/index.js | 12 ++---- .../pages/linking/index.js | 2 +- .../pages/lists/index.js | 37 +++++++------------ .../pages/localization/index.js | 15 +++++--- .../pages/modal/index.js | 3 +- .../pages/pan-responder/index.js | 2 +- .../pages/pressable/index.js | 12 ++---- .../pages/scroll-view/index.js | 10 +---- .../pages/text-input/index.js | 10 ++--- .../pages/text/index.js | 19 +++++----- .../pages/view/index.js | 30 ++++++++++++--- .../shared/button.js | 23 ++++++++++++ .../shared/example.js | 4 +- 16 files changed, 113 insertions(+), 84 deletions(-) create mode 100644 packages/react-native-web-examples/shared/button.js diff --git a/packages/react-native-web-examples/next.config.js b/packages/react-native-web-examples/next.config.js index 7fcc182978..f2b93d65c6 100644 --- a/packages/react-native-web-examples/next.config.js +++ b/packages/react-native-web-examples/next.config.js @@ -8,5 +8,9 @@ const pages = fs module.exports = { outDir: 'dist', - env: { pages } + env: { pages }, + webpack: (config, options) => { + config.resolve.alias['react-native'] = 'react-native-web'; + return config; + } }; diff --git a/packages/react-native-web-examples/pages/clipboard/index.js b/packages/react-native-web-examples/pages/clipboard/index.js index d2f19a265d..6fefd315f6 100644 --- a/packages/react-native-web-examples/pages/clipboard/index.js +++ b/packages/react-native-web-examples/pages/clipboard/index.js @@ -1,5 +1,6 @@ -import { Button, Clipboard, StyleSheet, TextInput, View } from 'react-native'; +import { Clipboard, StyleSheet, TextInput, View } from 'react-native'; import React from 'react'; +import Button from '../../shared/button'; import Example from '../../shared/example'; export default function ClipboardPage() { diff --git a/packages/react-native-web-examples/pages/image/index.js b/packages/react-native-web-examples/pages/image/index.js index 5cc756bf47..086a21a674 100644 --- a/packages/react-native-web-examples/pages/image/index.js +++ b/packages/react-native-web-examples/pages/image/index.js @@ -47,7 +47,11 @@ export default function ImagePage() { Static image - + Progressive JPEG @@ -143,8 +147,7 @@ const styles = StyleSheet.create({ borderColor: 'black', borderWidth: 0.5, height: 120, - width: 120, - resizeMode: 'cover' + width: 120 }, resizeMode: { borderColor: 'black', diff --git a/packages/react-native-web-examples/pages/index.js b/packages/react-native-web-examples/pages/index.js index 011dda4f33..e1e8ecddb0 100644 --- a/packages/react-native-web-examples/pages/index.js +++ b/packages/react-native-web-examples/pages/index.js @@ -8,11 +8,7 @@ const logoUri = function Link(props) { return ( - + ); } @@ -22,7 +18,7 @@ export default function IndexPage() { - + {process.env.pages.map((name) => ( - + {name} diff --git a/packages/react-native-web-examples/pages/linking/index.js b/packages/react-native-web-examples/pages/linking/index.js index 125133a5b1..3e8471a7f9 100644 --- a/packages/react-native-web-examples/pages/linking/index.js +++ b/packages/react-native-web-examples/pages/linking/index.js @@ -18,11 +18,11 @@ export default class LinkingPage extends PureComponent { Linking.openURL target="_blank" diff --git a/packages/react-native-web-examples/pages/lists/index.js b/packages/react-native-web-examples/pages/lists/index.js index bc4407c632..33bc86fa5f 100644 --- a/packages/react-native-web-examples/pages/lists/index.js +++ b/packages/react-native-web-examples/pages/lists/index.js @@ -15,7 +15,7 @@ import { Image, StyleSheet, Switch, - TouchableHighlight, + Pressable, Text, TextInput, View @@ -92,14 +92,11 @@ class ItemComponent extends React.PureComponent<{ const itemHash = Math.abs(hashCode(item.title)); const imgSource = THUMB_URLS[itemHash % THUMB_URLS.length]; return ( - - + ); } } @@ -172,15 +169,11 @@ class Spindicator extends React.PureComponent<{}> { style={[ styles.spindicator, { - transform: [ - { - rotate: this.props.value.interpolate({ - inputRange: [0, 5000], - outputRange: ['0deg', '360deg'], - extrapolate: 'extend' - }) - } - ] + rotate: this.props.value.interpolate({ + inputRange: [0, 5000], + outputRange: ['0deg', '360deg'], + extrapolate: 'extend' + }) } ]} /> @@ -263,9 +256,7 @@ class SingleColumnExample extends React.PureComponent { }; _onChangeScrollToIndex = (text) => { - this._listRef - .getNode() - .scrollToIndex({ viewPosition: 0.5, index: Number(text) }); + this._listRef.scrollToIndex({ viewPosition: 0.5, index: Number(text) }); }; _scrollPos = new Animated.Value(0); @@ -283,7 +274,7 @@ class SingleColumnExample extends React.PureComponent { ); componentDidUpdate() { - this._listRef.getNode().recordInteraction(); // e.g. flipping logViewable switch + this._listRef.recordInteraction(); // e.g. flipping logViewable switch } render() { @@ -398,7 +389,7 @@ class SingleColumnExample extends React.PureComponent { } }; _pressItem = (key: string) => { - this._listRef.getNode().recordInteraction(); + this._listRef.recordInteraction(); pressItem(this, key); }; _listRef: AnimatedFlatList; @@ -469,7 +460,7 @@ const styles = StyleSheet.create({ smallSwitch: { top: 1, margin: -6, - transform: [{ scale: 0.7 }] + transform: 'scale(0.7)' }, stacked: { alignItems: 'center', diff --git a/packages/react-native-web-examples/pages/localization/index.js b/packages/react-native-web-examples/pages/localization/index.js index 5c279066c3..2cc53d6e63 100644 --- a/packages/react-native-web-examples/pages/localization/index.js +++ b/packages/react-native-web-examples/pages/localization/index.js @@ -12,9 +12,9 @@ import React from 'react'; import { - Button, Image, PixelRatio, + Pressable, ScrollView, StyleSheet, Text, @@ -41,7 +41,9 @@ function ListItem(props) { -