Files
Bao Nguyen a506ed66cc Expose ReactNativeFeatureFlags through react-private-interface (#57940)
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
2026-09-08 07:34:16 -07:00

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);
}