mirror of
https://github.com/react/react-native.git
synced 2026-09-28 13:23:09 +08:00
Summary: Fixes https://github.com/react/react-native/issues/57933. `react-native/virtualized-lists` is published separately and imported `ReactNativeFeatureFlags` through `react-native/src/private/featureflags/ReactNativeFeatureFlags`, which is not listed in `react-native`'s `"exports"`. Metro therefore warned and fell back to file-based resolution whenever an app rendered a virtualized list. Thanks huntie for the patch and the direction — this PR now applies it instead of the original approach: - `ReactNativeFeatureFlags` is exposed on the existing private package boundary, `react-native/react-private-interface` (both the runtime getter and the `.js.flow` re-export); - `VirtualizedList.js` and `VirtualizeUtils.js` import it from there. No new `src/private/*` subpath is exported, and the feature-flag singleton is unchanged. Per your review, the `scripts/monorepo-tests/__tests__/check-packages-test.js` and `scripts/shared/monorepoUtils.js` changes have been dropped — the PR is now just the patch above. Happy to look at enabling `react-native/no-deep-imports` on `virtualized-lists` as a follow-up if that's wanted. `VirtualizeUtils.js` is included alongside `VirtualizedList.js` because it carried the same runtime deep import. The remaining occurrences are out of scope: the four in `react-native/jest-preset` are all `import type` and are erased before resolution, and the one in `VirtualizeUtils-test.js` is not shipped (`virtualized-lists` excludes `**/__tests__/**` from `files`). ## Changelog: [GENERAL] [FIXED] - Fix the Metro package-exports warning caused by `react-native/virtualized-lists` importing an unexported React Native subpath. Pull Request resolved: https://github.com/react/react-native/pull/57940 Test Plan: No new test is added. The existing `virtualized-lists` suites already cover this route, because `VirtualizeUtils`/`VirtualizedList` read the flags at runtime through the new boundary. Counterfactual — dropping only the `ReactNativeFeatureFlags` getter and its `import typeof` from `react-private-interface.js`, keeping the two `virtualized-lists` imports: ```text TypeError: Cannot read properties of undefined (reading 'fixVirtualizeListCollapseWindowSize') 182 | let lastWillAddMore; 183 | > 184 | if (ReactNativeFeatureFlags.fixVirtualizeListCollapseWindowSize()) { | ^ at computeWindowedRenderLimits (packages/virtualized-lists/Lists/VirtualizeUtils.js:184:32) at Object.<anonymous> (packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js:261:47) Test Suites: 2 failed, 6 passed, 8 total Tests: 20 failed, 1 skipped, 151 passed, 172 total ``` Restoring the getter makes it green again. ```text $ yarn jest packages/virtualized-lists scripts/monorepo-tests packages/react-native/Libraries/ReactPrivate --runInBand Test Suites: 9 passed, 9 total Tests: 1 skipped, 176 passed, 177 total Snapshots: 69 passed, 69 total $ yarn flow-check Found 0 errors $ yarn lint $ eslint --max-warnings 0 . Done in 10.23s. ``` Reviewed By: javache Differential Revision: D119164284 Pulled By: cortinico fbshipit-source-id: 1e8ae3b85ae7fb28c55f207f887a97aad85b563c
255 lines
7.6 KiB
JavaScript
255 lines
7.6 KiB
JavaScript
/**
|
|
* 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
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
import type ListMetricsAggregator from './ListMetricsAggregator';
|
|
import type {CellMetricProps} from './ListMetricsAggregator';
|
|
|
|
import {ReactNativeFeatureFlags} from 'react-native/react-private-interface';
|
|
|
|
/**
|
|
* Used to find the indices of the frames that overlap the given offsets. Useful for finding the
|
|
* items that bound different windows of content, such as the visible area or the buffered overscan
|
|
* area.
|
|
*/
|
|
export function elementsThatOverlapOffsets(
|
|
offsets: Array<number>,
|
|
props: CellMetricProps,
|
|
listMetrics: ListMetricsAggregator,
|
|
zoomScale: number = 1,
|
|
): Array<number> {
|
|
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) {
|
|
const mid = left + Math.floor((right - left) / 2);
|
|
const frame = listMetrics.getCellMetricsApprox(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 result;
|
|
}
|
|
|
|
/**
|
|
* Computes the number of elements in the `next` range that are new compared to the `prev` range.
|
|
* Handy for calculating how many new items will be rendered when the render window changes so we
|
|
* can restrict the number of new items render at once so that content can appear on the screen
|
|
* faster.
|
|
*/
|
|
export function newRangeCount(
|
|
prev: {
|
|
first: number,
|
|
last: number,
|
|
...
|
|
},
|
|
next: {
|
|
first: number,
|
|
last: number,
|
|
...
|
|
},
|
|
): number {
|
|
return (
|
|
next.last -
|
|
next.first +
|
|
1 -
|
|
Math.max(
|
|
0,
|
|
1 + Math.min(next.last, prev.last) - Math.max(next.first, prev.first),
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Custom logic for determining which items should be rendered given the current frame and scroll
|
|
* metrics, as well as the previous render state. The algorithm may evolve over time, but generally
|
|
* prioritizes the visible area first, then expands that with overscan regions ahead and behind,
|
|
* biased in the direction of scroll.
|
|
*/
|
|
export function computeWindowedRenderLimits(
|
|
props: CellMetricProps,
|
|
maxToRenderPerBatch: number,
|
|
windowSize: number,
|
|
prev: {
|
|
first: number,
|
|
last: number,
|
|
},
|
|
listMetrics: ListMetricsAggregator,
|
|
scrollMetrics: {
|
|
dt: number,
|
|
offset: number,
|
|
velocity: number,
|
|
visibleLength: number,
|
|
zoomScale: number,
|
|
...
|
|
},
|
|
): {
|
|
first: number,
|
|
last: number,
|
|
} {
|
|
const itemCount = props.getItemCount(props.data);
|
|
if (itemCount === 0) {
|
|
return {first: 0, last: -1};
|
|
}
|
|
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
|
|
// too.
|
|
const visibleBegin = Math.max(0, offset);
|
|
const visibleEnd = visibleBegin + visibleLength;
|
|
const overscanLength = (windowSize - 1) * visibleLength;
|
|
|
|
// Considering velocity seems to introduce more churn than it's worth.
|
|
const leadFactor = 0.5; // Math.max(0, Math.min(1, velocity / 25 + 0.5));
|
|
|
|
const fillPreference =
|
|
velocity > 1 ? 'after' : velocity < -1 ? 'before' : 'none';
|
|
|
|
const overscanBegin = Math.max(
|
|
0,
|
|
visibleBegin - (1 - leadFactor) * overscanLength,
|
|
);
|
|
const overscanEnd = Math.max(0, visibleEnd + leadFactor * overscanLength);
|
|
|
|
const lastItemOffset =
|
|
listMetrics.getCellMetricsApprox(itemCount - 1, props).offset * zoomScale;
|
|
if (lastItemOffset < overscanBegin) {
|
|
// Entire list is before our overscan window
|
|
return {
|
|
first: Math.max(0, itemCount - 1 - maxToRenderPerBatch),
|
|
last: itemCount - 1,
|
|
};
|
|
}
|
|
|
|
// 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],
|
|
props,
|
|
listMetrics,
|
|
zoomScale,
|
|
);
|
|
overscanFirst = overscanFirst == null ? 0 : overscanFirst;
|
|
first = first == null ? Math.max(0, overscanFirst) : first;
|
|
overscanLast = overscanLast == null ? itemCount - 1 : overscanLast;
|
|
last =
|
|
last == null
|
|
? Math.min(overscanLast, first + maxToRenderPerBatch - 1)
|
|
: last;
|
|
const visible = {first, last};
|
|
|
|
// We want to limit the number of new cells we're rendering per batch so that we can fill the
|
|
// content on the screen quickly. If we rendered the entire overscan window at once, the user
|
|
// could be staring at white space for a long time waiting for a bunch of offscreen content to
|
|
// render.
|
|
let newCellCount = newRangeCount(prev, visible);
|
|
|
|
while (true) {
|
|
if (first <= overscanFirst && last >= overscanLast) {
|
|
// If we fill the entire overscan range, we're done.
|
|
break;
|
|
}
|
|
const maxNewCells = newCellCount >= maxToRenderPerBatch;
|
|
|
|
let firstWillAddMore;
|
|
let lastWillAddMore;
|
|
|
|
if (ReactNativeFeatureFlags.fixVirtualizeListCollapseWindowSize()) {
|
|
firstWillAddMore = first <= prev.first;
|
|
lastWillAddMore = last >= prev.last;
|
|
} else {
|
|
firstWillAddMore = first <= prev.first || first > prev.last;
|
|
lastWillAddMore = last >= prev.last || last < prev.first;
|
|
}
|
|
|
|
const firstShouldIncrement =
|
|
first > overscanFirst && (!maxNewCells || !firstWillAddMore);
|
|
const lastShouldIncrement =
|
|
last < overscanLast && (!maxNewCells || !lastWillAddMore);
|
|
if (maxNewCells && !firstShouldIncrement && !lastShouldIncrement) {
|
|
// We only want to stop if we've hit maxNewCells AND we cannot increment first or last
|
|
// without rendering new items. This let's us preserve as many already rendered items as
|
|
// possible, reducing render churn and keeping the rendered overscan range as large as
|
|
// possible.
|
|
break;
|
|
}
|
|
if (
|
|
firstShouldIncrement &&
|
|
!(fillPreference === 'after' && lastShouldIncrement && lastWillAddMore)
|
|
) {
|
|
if (firstWillAddMore) {
|
|
newCellCount++;
|
|
}
|
|
first--;
|
|
}
|
|
if (
|
|
lastShouldIncrement &&
|
|
!(fillPreference === 'before' && firstShouldIncrement && firstWillAddMore)
|
|
) {
|
|
if (lastWillAddMore) {
|
|
newCellCount++;
|
|
}
|
|
last++;
|
|
}
|
|
}
|
|
if (!(
|
|
last >= first &&
|
|
first >= 0 &&
|
|
last < itemCount &&
|
|
first >= overscanFirst &&
|
|
last <= overscanLast &&
|
|
first <= visible.first &&
|
|
last >= visible.last
|
|
)) {
|
|
throw new Error(
|
|
'Bad window calculation ' +
|
|
JSON.stringify({
|
|
first,
|
|
last,
|
|
itemCount,
|
|
overscanFirst,
|
|
overscanLast,
|
|
visible,
|
|
}),
|
|
);
|
|
}
|
|
return {first, last};
|
|
}
|
|
|
|
export function keyExtractor(item: any, index: number): string {
|
|
if (typeof item === 'object' && item?.key != null) {
|
|
return item.key;
|
|
}
|
|
if (typeof item === 'object' && item?.id != null) {
|
|
return item.id;
|
|
}
|
|
return String(index);
|
|
}
|