Files
react-native/Libraries/Utilities/ReactNativeTestTools.js
T
Nick Gerleman 971599317b Ship VirtualizedList_EXPERIMENTAL
Summary:
`VirtualizedList_EXPERIMENTAL` is a fork of VirtualizedList. It was originally created for the purpose of keyboard/a11y fixes, which required the list to allow rendering discontiguous regions of cells. So, the large part of the refactor is in the data structure used to represent state (now a sparse bitmask), and building out the render function to operate off the bitmask.

This structure allows pre-rendering areas where we know focus must be able to be synchronously be moved to. This is exercised on platforms like Windows and macOS which fire view level focus events. The new implementation otherwise aims to preserve near-exact list behavior as previously.

Apart from the structure change, the refactor has made the code (subjectively) a lot easier to reason about, and there are now stronger internal invariants than before. The bitmask structure also enables new approaches for nested list stability, and implementing OffScreen.

**Why ship it now?**

Having two implementations has multiple times prevented other changes to VirtualizedList, so it is incurring a constant engineer cost.

At this point, we have a lot of reason to be confident in the maturity of the new list implementation (see the test plan below). So, reconciling the implementations not only unlocks the enhancements of the new list, but unblocks future changes to VirtualizedList.

## Test Plan

1. **Unit tests:** A few dozen unit tests were added to VirtualizedList, validating the regions it renders in response to injected input, across its API surface. Both VirtualizedList implementations must pass the same tests, around expected behavior.
2. **RNTester:** Scenarios in RNTester were manually validated, and all worked as before
3. **New invariants:** I added a lot of internal logic checks and bounds checks as invariant violations, to try to produce signal if any part of the refactor went unexpected in the wild.
4. **MSFT Rollout:** I rolled out the changes as a fork of VirtualizedList at MSFT to a couple of high-usage surfaces using Android, iOS, and Windows on Paper. Some invariant violations were surfaced and fixed, but telemetry showed solid system and scenario health with the change.
5. **Meta import:** Fixed all land-blocking test failures when using the new list. These were confined to updating snapshots, and adding additional checks for invalid input.
6. **Panel apps:** Manually validated top-level surfaces of panel apps in debug/release modes. Fixed a couple of invariant violations and novel usages. Profiled new JS structures against an expensive list with Hermes profiler.
7. **Facebook App Rollout:** After some manual validation of top-level surfaces, the change was rolled out to Facebook for Android and iOS as an experiment. New invariant violations were fixed, and the change has sat in production for several weeks at this point, while measuring impact to metrics like error rate, responsiveness, and impressions.

This is the first introduction to all of OSS, and some Meta internal apps. This means there may still be novel usages of VirtualizedList that were not picked up during testing, but I think there has been enough diligence to roll out what has been tested, and forward fix anything unexpected that might still come up.

Changelog:
[General][Changed] - Ship VirtualizedList_EXPERIMENTAL

Reviewed By: javache

Differential Revision: D40259791

fbshipit-source-id: 63eee9381d197a1e38ae663b2158436ff135c0e1
2022-10-13 05:04:40 -07:00

239 lines
7.7 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
*/
/* eslint-env jest */
import type {ReactTestRenderer as ReactTestRendererType} from 'react-test-renderer';
const Switch = require('../Components/Switch/Switch').default;
const TextInput = require('../Components/TextInput/TextInput');
const View = require('../Components/View/View');
const VirtualizedList = require('../Lists/VirtualizedList').default;
const Text = require('../Text/Text');
const React = require('react');
const ShallowRenderer = require('react-shallow-renderer');
const ReactTestRenderer = require('react-test-renderer');
/* $FlowFixMe[not-a-function] (>=0.125.1 site=react_native_fb) This comment
* suppresses an error found when Flow v0.125.1 was deployed. To see the error,
* delete this comment and run Flow. */
// $FlowFixMe[invalid-constructor]
const shallowRenderer = new ShallowRenderer();
export type ReactTestInstance = $PropertyType<ReactTestRendererType, 'root'>;
export type Predicate = (node: ReactTestInstance) => boolean;
type $ReturnType<Fn> = $Call<<Ret, A>((...A) => Ret) => Ret, Fn>;
/* $FlowFixMe[value-as-type] (>=0.125.1 site=react_native_fb) This comment
* suppresses an error found when Flow v0.125.1 was deployed. To see the error,
* delete this comment and run Flow. */
export type ReactTestRendererJSON =
/* $FlowFixMe[prop-missing] (>=0.125.1 site=react_native_fb) This comment
* suppresses an error found when Flow v0.125.1 was deployed. To see the error,
* delete this comment and run Flow. */
$ReturnType<ReactTestRenderer.create.toJSON>;
function byClickable(): Predicate {
return withMessage(
node =>
// note: <Text /> lazy-mounts press handlers after the first press,
// so this is a workaround for targeting text nodes.
(node.type === Text &&
node.props &&
typeof node.props.onPress === 'function') ||
// note: Special casing <Switch /> since it doesn't use touchable
(node.type === Switch && node.props && node.props.disabled !== true) ||
(node.type === View &&
node?.props?.onStartShouldSetResponder?.testOnly_pressabilityConfig) ||
// HACK: Find components that use `Pressability`.
node.instance?.state?.pressability != null ||
// TODO: Remove this after deleting `Touchable`.
(node.instance != null &&
// $FlowFixMe[prop-missing]
typeof node.instance.touchableHandlePress === 'function'),
'is clickable',
);
}
function byTestID(testID: string): Predicate {
return withMessage(
node => node.props && node.props.testID === testID,
`testID prop equals ${testID}`,
);
}
function byTextMatching(regex: RegExp): Predicate {
return withMessage(
node => node.props != null && regex.exec(node.props.children) !== null,
`text content matches ${regex.toString()}`,
);
}
function enter(instance: ReactTestInstance, text: string) {
const input = instance.findByType(TextInput);
input.props.onChange && input.props.onChange({nativeEvent: {text}});
input.props.onChangeText && input.props.onChangeText(text);
}
// Returns null if there is no error, otherwise returns an error message string.
function maximumDepthError(
tree: ReactTestRendererType,
maxDepthLimit: number,
): ?string {
const maxDepth = maximumDepthOfJSON(tree.toJSON());
if (maxDepth > maxDepthLimit) {
return (
`maximumDepth of ${maxDepth} exceeded limit of ${maxDepthLimit} - this is a proxy ` +
'metric to protect against stack overflow errors:\n\n' +
'https://fburl.com/rn-view-stack-overflow.\n\n' +
'To fix, you need to remove native layers from your hierarchy, such as unnecessary View ' +
'wrappers.'
);
} else {
return null;
}
}
function expectNoConsoleWarn() {
(jest: $FlowFixMe).spyOn(console, 'warn').mockImplementation((...args) => {
expect(args).toBeFalsy();
});
}
function expectNoConsoleError() {
let hasNotFailed = true;
(jest: $FlowFixMe).spyOn(console, 'error').mockImplementation((...args) => {
if (hasNotFailed) {
hasNotFailed = false; // set false to prevent infinite recursion
expect(args).toBeFalsy();
}
});
}
function expectRendersMatchingSnapshot(
name: string,
ComponentProvider: () => React.Element<any>,
unmockComponent: () => mixed,
) {
let instance;
jest.resetAllMocks();
instance = ReactTestRenderer.create(<ComponentProvider />);
expect(instance).toMatchSnapshot(
'should deep render when mocked (please verify output manually)',
);
jest.resetAllMocks();
unmockComponent();
instance = shallowRenderer.render(<ComponentProvider />);
expect(instance).toMatchSnapshot(
`should shallow render as <${name} /> when not mocked`,
);
jest.resetAllMocks();
instance = shallowRenderer.render(<ComponentProvider />);
expect(instance).toMatchSnapshot(
`should shallow render as <${name} /> when mocked`,
);
jest.resetAllMocks();
unmockComponent();
instance = ReactTestRenderer.create(<ComponentProvider />);
expect(instance).toMatchSnapshot(
'should deep render when not mocked (please verify output manually)',
);
}
// Takes a node from toJSON()
function maximumDepthOfJSON(node: ?ReactTestRendererJSON): number {
if (node == null) {
return 0;
} else if (typeof node === 'string' || node.children == null) {
return 1;
} else {
let maxDepth = 0;
node.children.forEach(child => {
maxDepth = Math.max(maximumDepthOfJSON(child) + 1, maxDepth);
});
return maxDepth;
}
}
function renderAndEnforceStrictMode(element: React.Node): any {
expectNoConsoleError();
return renderWithStrictMode(element);
}
function renderWithStrictMode(element: React.Node): ReactTestRendererType {
const WorkAroundBugWithStrictModeInTestRenderer = (prps: {
children: React.Node,
}) => prps.children;
const StrictMode = (React: $FlowFixMe).StrictMode;
return ReactTestRenderer.create(
<WorkAroundBugWithStrictModeInTestRenderer>
<StrictMode>{element}</StrictMode>
</WorkAroundBugWithStrictModeInTestRenderer>,
);
}
function tap(instance: ReactTestInstance) {
const touchable = instance.find(byClickable());
if (touchable.type === Text && touchable.props && touchable.props.onPress) {
touchable.props.onPress();
} else if (touchable.type === Switch && touchable.props) {
const value = !touchable.props.value;
const {onChange, onValueChange} = touchable.props;
onChange && onChange({nativeEvent: {value}});
onValueChange && onValueChange(value);
} else if (
touchable?.props?.onStartShouldSetResponder?.testOnly_pressabilityConfig
) {
const {onPress, disabled} =
touchable.props.onStartShouldSetResponder.testOnly_pressabilityConfig();
if (!disabled) {
onPress({nativeEvent: {}});
}
} else {
// Only tap when props.disabled isn't set (or there aren't any props)
if (!touchable.props || !touchable.props.disabled) {
touchable.props.onPress({nativeEvent: {}});
}
}
}
function scrollToBottom(instance: ReactTestInstance) {
const list = instance.findByType(VirtualizedList);
list.props && list.props.onEndReached();
}
// To make error messages a little bit better, we attach a custom toString
// implementation to a predicate
function withMessage(fn: Predicate, message: string): Predicate {
(fn: any).toString = () => message;
return fn;
}
export {byClickable};
export {byTestID};
export {byTextMatching};
export {enter};
export {expectNoConsoleWarn};
export {expectNoConsoleError};
export {expectRendersMatchingSnapshot};
export {maximumDepthError};
export {maximumDepthOfJSON};
export {renderAndEnforceStrictMode};
export {renderWithStrictMode};
export {scrollToBottom};
export {tap};
export {withMessage};