VirtualizedList.RenderItem throws when using function component with hooks (#24832)

Summary:
`<VirtualizedList />` will throw an error if the `renderItem` Prop component uses hooks. Function components without hooks and class components work without issue.

Super contrived Example
```{js}
function FlatListItem({ item }) {
  React.useEffect(() => console.log(item),[])

 return (<Text>{item}</Text>);
}

<FlatList data={[1, 2, 3]} renderItem={FlatListItem} />
```

Example Error:
```
Invariant Violation: Hooks can only be called inside the body of a function component. (https://fb.me/react-invalid-hook-call)

This error is located at:
    in CellRenderer (at VirtualizedList.js:688)
    in RCTScrollContentView (at ScrollView.js:976)
    in RCTScrollView (at ScrollView.js:1115)
    in ScrollView (at VirtualizedList.js:1081)
    in VirtualizedList (at FlatList.js:632)
    in FlatList (at WithoutScrollbars.js:21)
    ...
```

## Changelog

[General] [Added] - VirtualizedList ListItemComponent. An alternative to renderItem that accepts function components with hooks.
[General][Added] - FlatList ListItemComponent. An alternative to renderItem that accepts function components with hooks.
[General][Added] - VirtualizedList and FlatList tests and updated RNTester example
Pull Request resolved: https://github.com/facebook/react-native/pull/24832

Reviewed By: sahrens

Differential Revision: D15334020

Pulled By: cpojer

fbshipit-source-id: 882db722fd6e22f07260b08091b3456d1c66c2c8
This commit is contained in:
Alan Kenyon
2019-05-20 07:46:03 -07:00
committed by Facebook Github Bot
parent 9cd88251a3
commit 57a1e7c000
8 changed files with 602 additions and 73 deletions
+1 -1
View File
@@ -325,7 +325,7 @@ class NetworkOverlay extends React.Component<Props, State> {
WebSocketInterceptor.disableInterception();
}
_renderItem = ({item, index}): ?React.Element<any> => {
_renderItem = ({item, index}): React.Element<any> => {
const tableRowViewStyle = [
styles.tableRow,
index % 2 === 1 ? styles.tableRowOdd : styles.tableRowEven,
+94 -49
View File
@@ -24,15 +24,20 @@ import type {
ViewToken,
ViewabilityConfigCallbackPair,
} from './ViewabilityHelper';
import type {Props as VirtualizedListProps} from './VirtualizedList';
export type SeparatorsObj = {
highlight: () => void,
unhighlight: () => void,
updateProps: (select: 'leading' | 'trailing', newProps: Object) => void,
};
import type {
Props as VirtualizedListProps,
RenderItemType,
RenderItemProps,
} from './VirtualizedList';
type RequiredProps<ItemT> = {
/**
* For simplicity, data is just a plain array. If you want to use something else, like an
* immutable list, use the underlying `VirtualizedList` directly.
*/
data: ?$ReadOnlyArray<ItemT>,
};
type OptionalProps<ItemT> = {
/**
* Takes an item from `data` and renders it into the list. Example usage:
*
@@ -59,18 +64,7 @@ type RequiredProps<ItemT> = {
* `highlight` and `unhighlight` (which set the `highlighted: boolean` prop) are insufficient for
* your use-case.
*/
renderItem: (info: {
item: ItemT,
index: number,
separators: SeparatorsObj,
}) => ?React.Node,
/**
* For simplicity, data is just a plain array. If you want to use something else, like an
* immutable list, use the underlying `VirtualizedList` directly.
*/
data: ?$ReadOnlyArray<ItemT>,
};
type OptionalProps<ItemT> = {
renderItem?: ?RenderItemType<ItemT>,
/**
* 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`
@@ -78,6 +72,33 @@ type OptionalProps<ItemT> = {
* `separators.updateProps`.
*/
ItemSeparatorComponent?: ?React.ComponentType<any>,
/**
* Takes an item from `data` and renders it into the list. Example usage:
*
* <FlatList
* ItemSeparatorComponent={Platform.OS !== 'android' && ({highlighted}) => (
* <View style={[style.separator, highlighted && {marginLeft: 0}]} />
* )}
* data={[{title: 'Title Text', key: 'item1'}]}
* ListItemComponent={({item, separators}) => (
* <TouchableHighlight
* onPress={() => this._onPress(item)}
* onShowUnderlay={separators.highlight}
* onHideUnderlay={separators.unhighlight}>
* <View style={{backgroundColor: 'white'}}>
* <Text>{item.title}</Text>
* </View>
* </TouchableHighlight>
* )}
* />
*
* 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<any>,
/**
* Rendered when the list is empty. Can be a React Component Class, a render function, or
* a rendered element.
@@ -598,47 +619,71 @@ class FlatList<ItemT> extends React.PureComponent<Props<ItemT>, void> {
};
}
_renderItem = (info: Object): ?React.Node => {
const {renderItem, numColumns, columnWrapperStyle} = this.props;
if (numColumns > 1) {
const {item, index} = info;
invariant(
Array.isArray(item),
'Expected array of items with numColumns > 1',
);
return (
<View
style={StyleSheet.compose(
styles.row,
columnWrapperStyle,
)}>
{item.map((it, kk) => {
const element = renderItem({
item: it,
index: index * numColumns + kk,
separators: info.separators,
});
return element != null ? (
<React.Fragment key={kk}>{element}</React.Fragment>
) : null;
})}
</View>
);
} else {
return renderItem(info);
}
_renderer = () => {
const {
ListItemComponent,
renderItem,
numColumns,
columnWrapperStyle,
} = this.props;
let virtualizedListRenderKey = ListItemComponent
? 'ListItemComponent'
: 'renderItem';
const renderer = props => {
if (ListItemComponent) {
return <ListItemComponent {...props} />;
} else if (renderItem) {
return renderItem(props);
} else {
return null;
}
};
return {
[virtualizedListRenderKey]: (info: RenderItemProps<ItemT>) => {
if (numColumns > 1) {
const {item, index} = info;
invariant(
Array.isArray(item),
'Expected array of items with numColumns > 1',
);
return (
<View
style={StyleSheet.compose(
styles.row,
columnWrapperStyle,
)}>
{item.map((it, kk) => {
const element = renderer({
item: it,
index: index * numColumns + kk,
separators: info.separators,
});
return element != null ? (
<React.Fragment key={kk}>{element}</React.Fragment>
) : null;
})}
</View>
);
} else {
return renderer(info);
}
},
};
};
render() {
return (
<VirtualizedList
{...this.props}
renderItem={this._renderItem}
getItem={this._getItem}
getItemCount={this._getItemCount}
keyExtractor={this._keyExtractor}
ref={this._captureRef}
viewabilityConfigCallbackPairs={this._virtualizedListPairs}
{...this._renderer()}
/>
);
}
+61 -11
View File
@@ -17,7 +17,6 @@ const ReactNative = require('../Renderer/shims/ReactNative');
const RefreshControl = require('../Components/RefreshControl/RefreshControl');
const ScrollView = require('../Components/ScrollView/ScrollView');
const StyleSheet = require('../StyleSheet/StyleSheet');
const UIManager = require('../ReactNative/UIManager');
const View = require('../Components/View/View');
const ViewabilityHelper = require('./ViewabilityHelper');
@@ -37,7 +36,21 @@ import type {
type Item = any;
export type renderItemType = (info: any) => ?React.Element<any>;
export type Separators = {
highlight: () => void,
unhighlight: () => void,
updateProps: (select: 'leading' | 'trailing', newProps: Object) => void,
};
export type RenderItemProps<ItemT> = {
item: ItemT,
index: number,
separators: Separators,
};
export type RenderItemType<ItemT> = (
info: RenderItemProps<ItemT>,
) => React.Node;
type ViewabilityHelperCallbackTuple = {
viewabilityHelper: ViewabilityHelper,
@@ -48,9 +61,6 @@ type ViewabilityHelperCallbackTuple = {
};
type RequiredProps = {
// TODO: Conflicts with the optional `renderItem` in
// `VirtualizedSectionList`'s props.
renderItem: $FlowFixMe<renderItemType>,
/**
* 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.
@@ -66,6 +76,9 @@ type RequiredProps = {
getItemCount: (data: any) => number,
};
type OptionalProps = {
// TODO: Conflicts with the optional `renderItem` in
// `VirtualizedSectionList`'s props.
renderItem?: $FlowFixMe<?RenderItemType<Item>>,
/**
* `debug` will turn on extra logging and visual overlays to aid with debugging both usage and
* implementation, but with a significant perf hit.
@@ -111,6 +124,11 @@ type OptionalProps = {
* or a render function. Defaults to using View.
*/
CellRendererComponent?: ?React.ComponentType<any>,
/**
* Each data item is rendered using this element. Can be a React Component Class,
* or a render function.
*/
ListItemComponent?: ?React.ComponentType<any>,
/**
* Rendered when the list is empty. Can be a React Component Class, a render function, or
* a rendered element.
@@ -1664,7 +1682,8 @@ class CellRenderer extends React.Component<
onUpdateSeparators: (cellKeys: Array<?string>, props: Object) => void,
parentProps: {
getItemLayout?: ?Function,
renderItem: renderItemType,
renderItem?: ?RenderItemType<Item>,
ListItemComponent?: ?(React.ComponentType<any> | React.Element<any>),
},
prevCellKey: ?string,
},
@@ -1725,6 +1744,36 @@ class CellRenderer extends React.Component<
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) {
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,
@@ -1736,13 +1785,14 @@ class CellRenderer extends React.Component<
inversionStyle,
parentProps,
} = this.props;
const {renderItem, getItemLayout} = parentProps;
invariant(renderItem, 'no renderItem!');
const element = renderItem({
const {renderItem, getItemLayout, ListItemComponent} = parentProps;
const element = this._renderElement(
renderItem,
ListItemComponent,
item,
index,
separators: this._separators,
});
);
const onLayout =
/* $FlowFixMe(>=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
@@ -25,6 +25,41 @@ describe('FlatList', () => {
);
expect(component).toMatchSnapshot();
});
it('renders simple list (multiple columns)', () => {
const component = ReactTestRenderer.create(
<FlatList
data={[{key: 'i1'}, {key: 'i2'}, {key: 'i3'}]}
renderItem={({item}) => <item value={item.key} />}
numColumns={2}
/>,
);
expect(component).toMatchSnapshot();
});
it('renders simple list using ListItemComponent', () => {
function ListItemComponent({item}) {
return <item value={item.key} />;
}
const component = ReactTestRenderer.create(
<FlatList
data={[{key: 'i1'}, {key: 'i2'}, {key: 'i3'}]}
ListItemComponent={ListItemComponent}
/>,
);
expect(component).toMatchSnapshot();
});
it('renders simple list using ListItemComponent (multiple columns)', () => {
function ListItemComponent({item}) {
return <item value={item.key} />;
}
const component = ReactTestRenderer.create(
<FlatList
data={[{key: 'i1'}, {key: 'i2'}, {key: 'i3'}]}
ListItemComponent={ListItemComponent}
numColumns={2}
/>,
);
expect(component).toMatchSnapshot();
});
it('renders empty list', () => {
const component = ReactTestRenderer.create(
<FlatList data={[]} renderItem={({item}) => <item value={item.key} />} />,
@@ -28,6 +28,61 @@ describe('VirtualizedList', () => {
expect(component).toMatchSnapshot();
});
it('renders simple list using ListItemComponent', () => {
function ListItemComponent({item}) {
return <item value={item.key} />;
}
const component = ReactTestRenderer.create(
<VirtualizedList
data={[{key: 'i1'}, {key: 'i2'}, {key: 'i3'}]}
ListItemComponent={ListItemComponent}
getItem={(data, index) => data[index]}
getItemCount={data => data.length}
/>,
);
expect(component).toMatchSnapshot();
});
it('warns if both renderItem or ListItemComponent are specified. Uses ListItemComponent', () => {
jest.spyOn(global.console, 'warn');
function ListItemComponent({item}) {
return <item value={item.key} testID={`${item.key}-ListItemComponent`} />;
}
const component = ReactTestRenderer.create(
<VirtualizedList
data={[{key: 'i1'}]}
ListItemComponent={ListItemComponent}
renderItem={({item}) => (
<item value={item.key} testID={`${item.key}-renderItem`} />
)}
getItem={(data, index) => data[index]}
getItemCount={data => data.length}
/>,
);
expect(console.warn.mock.calls).toEqual([
[
'VirtualizedList: Both ListItemComponent and renderItem props are present. ListItemComponent will take precedence over renderItem.',
],
]);
expect(component).toMatchSnapshot();
console.warn.mockRestore();
});
it('throws if no renderItem or ListItemComponent', () => {
const componentFactory = () =>
ReactTestRenderer.create(
<VirtualizedList
data={[{key: 'i1'}, {key: 'i2'}, {key: 'i3'}]}
getItem={(data, index) => data[index]}
getItemCount={data => data.length}
/>,
);
expect(componentFactory).toThrow(
'VirtualizedList: Either ListItemComponent or renderItem props are required but none were found.',
);
});
it('renders empty list', () => {
const component = ReactTestRenderer.create(
<VirtualizedList
@@ -185,6 +185,84 @@ exports[`FlatList renders null list 1`] = `
</RCTScrollView>
`;
exports[`FlatList renders simple list (multiple columns) 1`] = `
<RCTScrollView
data={
Array [
Object {
"key": "i1",
},
Object {
"key": "i2",
},
Object {
"key": "i3",
},
]
}
disableVirtualization={false}
getItem={[Function]}
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
numColumns={2}
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
onMomentumScrollEnd={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
removeClippedSubviews={false}
renderItem={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
viewabilityConfigCallbackPairs={Array []}
windowSize={21}
>
<View>
<View
onLayout={[Function]}
style={null}
>
<View
style={
Object {
"flexDirection": "row",
}
}
>
<item
value="i1"
/>
<item
value="i2"
/>
</View>
</View>
<View
onLayout={[Function]}
style={null}
>
<View
style={
Object {
"flexDirection": "row",
}
}
>
<item
value="i3"
/>
</View>
</View>
</View>
</RCTScrollView>
`;
exports[`FlatList renders simple list 1`] = `
<RCTScrollView
data={
@@ -251,3 +329,148 @@ exports[`FlatList renders simple list 1`] = `
</View>
</RCTScrollView>
`;
exports[`FlatList renders simple list using ListItemComponent (multiple columns) 1`] = `
<RCTScrollView
ListItemComponent={[Function]}
data={
Array [
Object {
"key": "i1",
},
Object {
"key": "i2",
},
Object {
"key": "i3",
},
]
}
disableVirtualization={false}
getItem={[Function]}
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
numColumns={2}
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
onMomentumScrollEnd={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
removeClippedSubviews={false}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
viewabilityConfigCallbackPairs={Array []}
windowSize={21}
>
<View>
<View
onLayout={[Function]}
style={null}
>
<View
style={
Object {
"flexDirection": "row",
}
}
>
<item
value="i1"
/>
<item
value="i2"
/>
</View>
</View>
<View
onLayout={[Function]}
style={null}
>
<View
style={
Object {
"flexDirection": "row",
}
}
>
<item
value="i3"
/>
</View>
</View>
</View>
</RCTScrollView>
`;
exports[`FlatList renders simple list using ListItemComponent 1`] = `
<RCTScrollView
ListItemComponent={[Function]}
data={
Array [
Object {
"key": "i1",
},
Object {
"key": "i2",
},
Object {
"key": "i3",
},
]
}
disableVirtualization={false}
getItem={[Function]}
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
numColumns={1}
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
onMomentumScrollEnd={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
removeClippedSubviews={false}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
viewabilityConfigCallbackPairs={Array []}
windowSize={21}
>
<View>
<View
onLayout={[Function]}
style={null}
>
<item
value="i1"
/>
</View>
<View
onLayout={[Function]}
style={null}
>
<item
value="i2"
/>
</View>
<View
onLayout={[Function]}
style={null}
>
<item
value="i3"
/>
</View>
</View>
</RCTScrollView>
`;
@@ -811,6 +811,70 @@ exports[`VirtualizedList renders simple list 1`] = `
</RCTScrollView>
`;
exports[`VirtualizedList renders simple list using ListItemComponent 1`] = `
<RCTScrollView
ListItemComponent={[Function]}
data={
Array [
Object {
"key": "i1",
},
Object {
"key": "i2",
},
Object {
"key": "i3",
},
]
}
disableVirtualization={false}
getItem={[Function]}
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
onMomentumScrollEnd={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
windowSize={21}
>
<View>
<View
onLayout={[Function]}
style={null}
>
<item
value="i1"
/>
</View>
<View
onLayout={[Function]}
style={null}
>
<item
value="i2"
/>
</View>
<View
onLayout={[Function]}
style={null}
>
<item
value="i3"
/>
</View>
</View>
</RCTScrollView>
`;
exports[`VirtualizedList test getItem functionality where data is not an Array 1`] = `
<RCTScrollView
data={
@@ -852,3 +916,47 @@ exports[`VirtualizedList test getItem functionality where data is not an Array 1
</View>
</RCTScrollView>
`;
exports[`VirtualizedList warns if both renderItem or ListItemComponent are specified. Uses ListItemComponent 1`] = `
<RCTScrollView
ListItemComponent={[Function]}
data={
Array [
Object {
"key": "i1",
},
]
}
disableVirtualization={false}
getItem={[Function]}
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
onMomentumScrollEnd={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
renderItem={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
windowSize={21}
>
<View>
<View
onLayout={[Function]}
style={null}
>
<item
testID="i1-ListItemComponent"
value="i1"
/>
</View>
</View>
</RCTScrollView>
`;
+25 -12
View File
@@ -51,6 +51,7 @@ type State = {|
logViewable: boolean,
virtualized: boolean,
empty: boolean,
useFlatListItemComponent: boolean,
|};
class FlatListExample extends React.PureComponent<Props, State> {
@@ -64,6 +65,7 @@ class FlatListExample extends React.PureComponent<Props, State> {
logViewable: false,
virtualized: true,
empty: false,
useFlatListItemComponent: false,
};
_onChangeFilterText = filterText => {
@@ -95,6 +97,7 @@ class FlatListExample extends React.PureComponent<Props, State> {
const filter = item =>
filterRegex.test(item.text) || filterRegex.test(item.title);
const filteredData = this.state.data.filter(filter);
const flatListItemRendererProps = this._renderItemComponent();
return (
<RNTesterPage noSpacer={true} noScroll={true}>
<View style={styles.container}>
@@ -118,6 +121,7 @@ class FlatListExample extends React.PureComponent<Props, State> {
{renderSmallSwitchOption(this, 'inverted')}
{renderSmallSwitchOption(this, 'empty')}
{renderSmallSwitchOption(this, 'debug')}
{renderSmallSwitchOption(this, 'useFlatListItemComponent')}
<Spindicator value={this._scrollPos} />
</View>
</View>
@@ -150,9 +154,9 @@ class FlatListExample extends React.PureComponent<Props, State> {
onViewableItemsChanged={this._onViewableItemsChanged}
ref={this._captureRef}
refreshing={false}
renderItem={this._renderItemComponent}
contentContainerStyle={styles.list}
viewabilityConfig={VIEWABILITY_CONFIG}
{...flatListItemRendererProps}
/>
</View>
</RNTesterPage>
@@ -173,17 +177,26 @@ class FlatListExample extends React.PureComponent<Props, State> {
}));
};
_onRefresh = () => Alert.alert('onRefresh: nothing to refresh :P');
_renderItemComponent = ({item, separators}) => {
return (
<ItemComponent
item={item}
horizontal={this.state.horizontal}
fixedHeight={this.state.fixedHeight}
onPress={this._pressItem}
onShowUnderlay={separators.highlight}
onHideUnderlay={separators.unhighlight}
/>
);
_renderItemComponent = () => {
const flatListPropKey = this.state.useFlatListItemComponent
? 'ListItemComponent'
: 'renderItem';
return {
renderItem: undefined,
[flatListPropKey]: ({item, separators}) => {
return (
<ItemComponent
item={item}
horizontal={this.state.horizontal}
fixedHeight={this.state.fixedHeight}
onPress={this._pressItem}
onShowUnderlay={separators.highlight}
onHideUnderlay={separators.unhighlight}
/>
);
},
};
};
// This is called when items change viewability by scrolling into or out of
// the viewable area.