diff --git a/change/@office-iss-react-native-win32-85ebdd63-c97d-4582-86d4-dfa7b7ab00c3.json b/change/@office-iss-react-native-win32-85ebdd63-c97d-4582-86d4-dfa7b7ab00c3.json new file mode 100644 index 00000000000..0d07cc0f41f --- /dev/null +++ b/change/@office-iss-react-native-win32-85ebdd63-c97d-4582-86d4-dfa7b7ab00c3.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "Add new VirtualizedList Tests from RN Core PR", + "packageName": "@office-iss/react-native-win32", + "email": "ngerlem@microsoft.com", + "dependentChangeType": "none" +} diff --git a/change/react-native-windows-616acf0c-8693-442b-aa4f-edc2553ed716.json b/change/react-native-windows-616acf0c-8693-442b-aa4f-edc2553ed716.json new file mode 100644 index 00000000000..c6f6ea162d3 --- /dev/null +++ b/change/react-native-windows-616acf0c-8693-442b-aa4f-edc2553ed716.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "Add new VirtualizedList Tests from RN Core PR", + "packageName": "react-native-windows", + "email": "ngerlem@microsoft.com", + "dependentChangeType": "none" +} diff --git a/packages/@office-iss/react-native-win32/overrides.json b/packages/@office-iss/react-native-win32/overrides.json index a577223cdf9..f135458b1ca 100644 --- a/packages/@office-iss/react-native-win32/overrides.json +++ b/packages/@office-iss/react-native-win32/overrides.json @@ -315,7 +315,7 @@ "baseHash": "f23a66cfc475ee1e2eda1065e56e05e547b7030e" }, { - "type": "copy", + "type": "derived", "file": "src/Libraries/Lists/__tests__/VirtualizedList-test.js", "baseFile": "Libraries/Lists/__tests__/VirtualizedList-test.js", "baseHash": "fd25fc611f8da21b93e7f8750d8f2fc8273a4a52" diff --git a/packages/@office-iss/react-native-win32/src/Libraries/Lists/__tests__/VirtualizedList-test.js b/packages/@office-iss/react-native-win32/src/Libraries/Lists/__tests__/VirtualizedList-test.js index 260a592fe4f..ae5d9169c1e 100644 --- a/packages/@office-iss/react-native-win32/src/Libraries/Lists/__tests__/VirtualizedList-test.js +++ b/packages/@office-iss/react-native-win32/src/Libraries/Lists/__tests__/VirtualizedList-test.js @@ -104,6 +104,28 @@ describe('VirtualizedList', () => { expect(component).toMatchSnapshot(); }); + it('renders empty list after batch', () => { + const component = ReactTestRenderer.create( + } + getItem={(data, index) => data[index]} + getItemCount={data => data.length} + />, + ); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + + performAllBatches(); + }); + + expect(component).toMatchSnapshot(); + }); + it('renders null list', () => { const component = ReactTestRenderer.create( { // onLayout, which can cause https://github.com/facebook/react-native/issues/16067 instance._onContentSizeChange(300, initialContentHeight); instance._onContentSizeChange(300, data.length * ITEM_HEIGHT); - jest.runAllTimers(); + performAllBatches(); expect(onEndReached).not.toHaveBeenCalled(); @@ -384,7 +406,7 @@ describe('VirtualizedList', () => { contentInset: {right: 0, top: 0, left: 0, bottom: 0}, }, }); - jest.runAllTimers(); + performAllBatches(); expect(onEndReached).toHaveBeenCalled(); }); @@ -506,161 +528,1006 @@ describe('VirtualizedList', () => { }); it('forwards correct stickyHeaderIndices when all in initial render window', () => { - const items = Array(10) - .fill() - .map((_, i) => (i % 3 === 0 ? {key: i, sticky: true} : {key: i})); - const stickyIndices = items - .filter(item => item.sticky) - .map(item => item.key); + const items = generateItemsStickyEveryN(10, 3); + const ITEM_HEIGHT = 10; + const component = ReactTestRenderer.create( + , + ); + + // The initial render is specified to be the length of items provided. + // Expect that all sticky items (1 every 3) are passed to the underlying + // scrollview. + expect(component).toMatchSnapshot(); + }); + + it('forwards correct stickyHeaderIndices when ListHeaderComponent present', () => { + const items = generateItemsStickyEveryN(10, 3); const ITEM_HEIGHT = 10; const component = ReactTestRenderer.create( React.createElement('Header')} initialNumToRender={10} - renderItem={({item}) => } - getItem={(data, index) => data[index]} - getItemCount={data => data.length} - getItemLayout={(_, index) => ({ - length: ITEM_HEIGHT, - offset: ITEM_HEIGHT * index, - index, - })} + {...baseItemProps(items)} + {...fixedHeightItemLayoutProps(ITEM_HEIGHT)} />, ); + // The initial render is specified to be the length of items provided. + // Expect that all sticky items (1 every 3) are passed to the underlying + // scrollview, indices offset by 1 to account for the the header component. expect(component).toMatchSnapshot(); }); it('forwards correct stickyHeaderIndices when partially in initial render window', () => { - const items = Array(10) - .fill() - .map((_, i) => (i % 3 === 0 ? {key: i, sticky: true} : {key: i})); - const stickyIndices = items - .filter(item => item.sticky) - .map(item => item.key); + const items = generateItemsStickyEveryN(10, 3); const ITEM_HEIGHT = 10; const component = ReactTestRenderer.create( } - getItem={(data, index) => data[index]} - getItemCount={data => data.length} - getItemLayout={(_, index) => ({ - length: ITEM_HEIGHT, - offset: ITEM_HEIGHT * index, - index, - })} + {...baseItemProps(items)} + {...fixedHeightItemLayoutProps(ITEM_HEIGHT)} />, ); + // The initial render is specified to be half the length of items provided. + // Expect that all sticky items of index < 5 are passed to the underlying + // scrollview. expect(component).toMatchSnapshot(); }); - it('realizes sticky headers in viewport on batched render', () => { - const items = Array(10) - .fill() - .map((_, i) => (i % 3 === 0 ? {key: i, sticky: true} : {key: i})); - const stickyIndices = items - .filter(item => item.sticky) - .map(item => item.key); - + it('renders sticky headers in viewport on batched render', () => { + const items = generateItemsStickyEveryN(10, 3); const ITEM_HEIGHT = 10; - const virtualizedListProps = { - data: items, - stickyHeaderIndices: stickyIndices, - initialNumToRender: 1, - windowSize: 1, - renderItem: ({item}) => , - getItem: (data, index) => data[index], - getItemCount: data => data.length, - getItemLayout: (_, index) => ({ - length: ITEM_HEIGHT, - offset: ITEM_HEIGHT * index, - index, - }), - }; - let component; - ReactTestRenderer.act(() => { component = ReactTestRenderer.create( - , + , ); }); ReactTestRenderer.act(() => { - component - .getInstance() - ._onLayout({nativeEvent: {layout: {width: 10, height: 50}}}); - component.getInstance()._onContentSizeChange(10, 100); - jest.runAllTimers(); + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 100}, + }); + performAllBatches(); }); + // A windowSize of 1 means we will render just the viewport height (50dip). + // Expect 5 10dip items to eventually be rendered, with sticky headers in + // the first 5 propagated. expect(component).toMatchSnapshot(); }); - it('keeps sticky headers realized after scrolled out of viewport', () => { - const items = Array(20) - .fill() - .map((_, i) => - i % 3 === 0 ? {key: i, sticky: true} : {key: i, sticky: false}, - ); - const stickyIndices = items - .filter(item => item.sticky) - .map(item => item.key); - + it('keeps sticky headers above viewport visualized', () => { + const items = generateItemsStickyEveryN(20, 3); const ITEM_HEIGHT = 10; - const virtualizedListProps = { - data: items, - stickyHeaderIndices: stickyIndices, - initialNumToRender: 1, - windowSize: 1, - renderItem: ({item}) => , - getItem: (data, index) => data[index], - getItemCount: data => data.length, - getItemLayout: (_, index) => ({ - length: ITEM_HEIGHT, - offset: ITEM_HEIGHT * index, - index, - }), - }; - let component; - ReactTestRenderer.act(() => { component = ReactTestRenderer.create( - , + , ); }); ReactTestRenderer.act(() => { - component - .getInstance() - ._onLayout({nativeEvent: {layout: {width: 10, height: 50}}}); - component.getInstance()._onContentSizeChange(10, 200); - jest.runAllTimers(); + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + performAllBatches(); }); ReactTestRenderer.act(() => { - component.getInstance()._onScroll({ - nativeEvent: { - contentOffset: {x: 0, y: 150}, - contentSize: {width: 10, height: 200}, - layoutMeasurement: {width: 10, height: 50}, - }, - }); - jest.runAllTimers(); + simulateScroll(component, {x: 0, y: 150}); + performAllBatches(); }); + // Scroll to the bottom 50 dip (last five items) of the content. Expect the + // last five items to be rendered, along with every sticky header above, + // even though they are out of the viewport window in layout coordinates. + // This is because they will remain rendered even once scrolled-past in + // layout space. expect(component).toMatchSnapshot(); }); }); + +it('unmounts sticky headers moved below viewport', () => { + const items = generateItemsStickyEveryN(20, 3); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 150}); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 0}); + performAllBatches(); + }); + + // Scroll to the bottom 50 dip (last five items) of the content, then back up + // to the first 5. Ensure that sticky items are unmounted once they are below + // the render area. + expect(component).toMatchSnapshot(); +}); + +it('renders offset cells in initial render when initialScrollIndex set', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + const component = ReactTestRenderer.create( + , + ); + + // Check that the first render respects initialScrollIndex + expect(component).toMatchSnapshot(); +}); + +it('does not over-render when there is less than initialNumToRender cells', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + const component = ReactTestRenderer.create( + , + ); + + // Check that the first render clamps to the last item when intialNumToRender + // goes over it. + expect(component).toMatchSnapshot(); +}); + +it('retains intitial render if initialScrollIndex == 0', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 150}); + performAllBatches(); + }); + + // If initialScrollIndex is 0 (the default), we should never unmount the top + // initialNumToRender as part of the "scroll to top optimization", even after + // scrolling to the bottom five items. + expect(component).toMatchSnapshot(); +}); + +it('discards intitial render if initialScrollIndex != 0', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 150}); + performAllBatches(); + }); + + // If initialScrollIndex is not 0, we do not enable retaining initial render + // as part of "scroll to top" optimization. + expect(component).toMatchSnapshot(); +}); + +it('expands render area by maxToRenderPerBatch on tick', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; + + const props = { + initialNumToRender: 5, + maxToRenderPerBatch: 2, + }; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + performNextBatch(); + }); + + // We start by rendering 5 items in the initial render, but have default + // windowSize, enabling eventual rendering up to 20 viewports worth of + // content. We limit this to rendering 2 items per-batch via + // maxToRenderPerBatch, so we should only have 7 items rendered after the + // initial timer tick. + expect(component).toMatchSnapshot(); +}); + +it('does not adjust render area until content area layed out', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; + + let component; + + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateViewportLayout(component, {width: 10, height: 50}); + performAllBatches(); + }); + + // We should not start layout-based logic to expand rendered area until + // content is layed out. Expect only the 5 initial items to be rendered after + // processing all batch work, even though the windowSize allows for more. + expect(component).toMatchSnapshot(); +}); + +it('does not adjust render area with non-zero initialScrollIndex until scrolled', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + performAllBatches(); + }); + + // Layout information from before the time we scroll to initial index may not + // correspond to the area "initialScrollIndex" points to. Expect only the 5 + // initial items (starting at initialScrollIndex) to be rendered after + // processing all batch work, even though the windowSize allows for more. + expect(component).toMatchSnapshot(); +}); + +it('adjusts render area with non-zero initialScrollIndex after scrolled', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + + simulateScroll(component, {x: 0, y: 10}); + performAllBatches(); + }); + + // We should expand the render area after receiving a message indcating we + // arrived at initialScrollIndex. + expect(component).toMatchSnapshot(); +}); + +it('renders initialNumToRender cells when virtualization disabled', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + const component = ReactTestRenderer.create( + , + ); + + // We should render initialNumToRender items with no spacers on initial render + // when virtualization is disabled + expect(component).toMatchSnapshot(); +}); + +it('renders no spacers up to initialScrollIndex on first render when virtualization disabled', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + // There should be no spacers present in an offset initial render with + // virtualiztion disabled. Only initialNumToRender items starting at + // initialScrollIndex. + expect(component).toMatchSnapshot(); +}); + +it('expands first in viewport to render up to maxToRenderPerBatch on initial render', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + // When virtualization is disabled we may render items before initialItemIndex + // if initialItemIndex + initialNumToRender < maToRenderPerBatch. Expect cells + // 0-3 to be rendered in this example, even though initialScrollIndex is 4. + expect(component).toMatchSnapshot(); +}); + +it('renders items before initialScrollIndex on first batch tick when virtualization disabled', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 100}, + }); + performNextBatch(); + }); + + // When virtualization is disabled, we render "maxToRenderPerBatch" items + // sequentially per batch tick. Any items not yet rendered before + // initialScrollIndex are currently rendered at this time. Expect the first + // tick to render all items before initialScrollIndex, along with + // maxToRenderPerBatch after. + expect(component).toMatchSnapshot(); +}); + +it('eventually renders all items when virtualization disabled', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 100}, + }); + performAllBatches(); + }); + + // After all batch ticks, all items should eventually be rendered when\ + // virtualization is disabled. + expect(component).toMatchSnapshot(); +}); + +it('retains initial render region when an item is appended', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + component.update( + , + ); + }); + + // Adding an item to the list before batch render should keep the existing + // rendered items rendered. Expect the first 3 items rendered, and a spacer + // for 8 items (including the 11th, added item). + expect(component).toMatchSnapshot(); +}); + +it('retains batch render region when an item is appended', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 100}, + }); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + component.update( + , + ); + }); + + // Adding an item to the list after batch render should keep the existing + // rendered items rendered. We batch render 10 items, then add an 11th. Expect + // the first ten items to be present, with a spacer for the 11th until the + // next batch render. + expect(component).toMatchSnapshot(); +}); + +it('constrains batch render region when an item is removed', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 100}, + }); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + component.update( + , + ); + }); + + // If the number of items is reduced, we should remove the corresponding + // already rendered items. Expect there to be 5 items present. New items in a + // previously occupied index may also be immediately rendered. + expect(component).toMatchSnapshot(); +}); + +it('renders a zero-height tail spacer on initial render if getItemLayout not defined', () => { + const items = generateItems(10); + + const component = ReactTestRenderer.create( + , + ); + + // Do not add space for out-of-viewport content on initial render when we do + // not yet know how large it should be (no getItemLayout and cell onLayout not + // yet called). Expect the tail spacer not to occupy space. + expect(component).toMatchSnapshot(); +}); + +it('renders zero-height tail spacer on batch render if cells not yet measured and getItemLayout not defined', () => { + const items = generateItems(10); + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + performNextBatch(); + }); + + // Do not add space for out-of-viewport content unless the cell has previously + // been layed out and measurements cached. Expect the tail spacer not to + // occupy space. + expect(component).toMatchSnapshot(); +}); + +it('renders tail spacer up to last measured index if getItemLayout not defined', () => { + const items = generateItems(10); + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + const LAST_MEASURED_CELL = 6; + for (let i = 0; i <= LAST_MEASURED_CELL; ++i) { + simulateCellLayout(component, items, i, { + width: 10, + height: 10, + x: 0, + y: 10 * i, + }); + } + + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 30}, + }); + performNextBatch(); + }); + + // If cells in the out-of-viewport area have been measured, their space can be + // incorporated into the tail spacer, without space for the cells we can not + // measure until layout. Expect there to be a tail spacer occupying the space + // for measured, but not yet rendered items (up to and including item 6). + expect(component).toMatchSnapshot(); +}); + +it('renders tail spacer up to last measured with irregular layout when getItemLayout undefined', () => { + const items = generateItems(10); + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + const LAST_MEASURED_CELL = 6; + + let currentY = 0; + for (let i = 0; i <= LAST_MEASURED_CELL; ++i) { + simulateCellLayout(component, items, i, { + width: 10, + height: i, + x: 0, + y: currentY + i, + }); + currentY += i; + } + + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 30}, + }); + performNextBatch(); + }); + + // If cells in the out-of-viewport area have been measured, their space can be + // incorporated into the tail spacer, without space for the cells we can not + // measure until layout. Expect there to be a tail spacer occupying the space + // for measured, but not yet rendered items (up to and including item 6). + expect(component).toMatchSnapshot(); +}); + +it('renders full tail spacer if all cells measured', () => { + const items = generateItems(10); + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + const LAST_MEASURED_CELL = 9; + for (let i = 0; i <= LAST_MEASURED_CELL; ++i) { + simulateCellLayout(component, items, i, { + width: 10, + height: 10, + x: 0, + y: 10 * i, + }); + } + + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 30}, + }); + performNextBatch(); + }); + + // The tail-spacer should occupy the space of all non-rendered items if all + // items have been measured. + expect(component).toMatchSnapshot(); +}); + +it('renders windowSize derived region at top', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 20}, + content: {width: 10, height: 100}, + }); + performAllBatches(); + }); + + // A windowSize of 3 means that we should render a viewport's worth of content + // above and below the current. A 20 dip viewport at the top of the list means + // we should render the top 4 10-dip items (for the current viewport, and + // 20dip below). + expect(component).toMatchSnapshot(); +}); + +it('renders windowSize derived region in middle', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 20}, + content: {width: 10, height: 100}, + }); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 50}); + performAllBatches(); + }); + + // A windowSize of 3 means that we should render a viewport's worth of content + // above and below the current. A 20 dip viewport in the top of the list means + // we should render the 6 10-dip items (for the current viewport, 20 dip above + // and below), along with retaining the top initialNumToRenderItems. We seem + // to actually render 7 in the middle due to rounding at the moment. + expect(component).toMatchSnapshot(); +}); + +it('renders windowSize derived region at bottom', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 20}, + content: {width: 10, height: 100}, + }); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 80}); + performAllBatches(); + }); + + // A windowSize of 3 means that we should render a viewport's worth of content + // above and below the current. A 20 dip viewport at the bottom of the list + // means we should render the bottom 4 10-dip items (for the current viewport, + // and 20dip above), along with retaining the top initialNumToRenderItems. We + // seem to actually render 4 at the bottom due to rounding at the moment. + expect(component).toMatchSnapshot(); +}); + +function generateItems(count) { + return Array(count) + .fill() + .map((_, i) => ({key: i})); +} + +function generateItemsStickyEveryN(count, n) { + return Array(count) + .fill() + .map((_, i) => (i % n === 0 ? {key: i, sticky: true} : {key: i})); +} + +function baseItemProps(items) { + return { + data: items, + renderItem: ({item}) => + React.createElement('MockCellItem', {value: item.key, ...item}), + getItem: (data, index) => data[index], + getItemCount: data => data.length, + stickyHeaderIndices: stickyHeaderIndices(items), + }; +} + +function stickyHeaderIndices(items) { + return items.filter(item => item.sticky).map(item => item.key); +} + +function fixedHeightItemLayoutProps(height) { + return { + getItemLayout: (_, index) => ({ + length: height, + offset: height * index, + index, + }), + }; +} + +let lastViewportLayout; +let lastContentLayout; + +function simulateLayout(component, args) { + simulateViewportLayout(component, args.viewport); + simulateContentLayout(component, args.content); +} + +function simulateViewportLayout(component, dimensions) { + lastViewportLayout = dimensions; + component.getInstance()._onLayout({nativeEvent: {layout: dimensions}}); +} + +function simulateContentLayout(component, dimensions) { + lastContentLayout = dimensions; + component + .getInstance() + ._onContentSizeChange(dimensions.width, dimensions.height); +} + +function simulateCellLayout(component, items, itemIndex, dimensions) { + const instance = component.getInstance(); + const cellKey = instance._keyExtractor(items[itemIndex], itemIndex); + instance._onCellLayout( + {nativeEvent: {layout: dimensions}}, + cellKey, + itemIndex, + ); +} + +function simulateScroll(component, position) { + component.getInstance()._onScroll({ + nativeEvent: { + contentOffset: position, + contentSize: lastContentLayout, + layoutMeasurement: lastViewportLayout, + }, + }); +} + +function performAllBatches() { + jest.runAllTimers(); +} + +function performNextBatch() { + jest.runOnlyPendingTimers(); +} diff --git a/packages/@office-iss/react-native-win32/src/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap b/packages/@office-iss/react-native-win32/src/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap index 6bd99586b38..ad93703101b 100644 --- a/packages/@office-iss/react-native-win32/src/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap +++ b/packages/@office-iss/react-native-win32/src/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap @@ -1,5 +1,152 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`VirtualizedList forwards correct stickyHeaderIndices when ListHeaderComponent present 1`] = ` + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + exports[`VirtualizedList forwards correct stickyHeaderIndices when all in initial render window 1`] = ` - @@ -74,21 +221,21 @@ exports[`VirtualizedList forwards correct stickyHeaderIndices when all in initia - - - @@ -96,21 +243,21 @@ exports[`VirtualizedList forwards correct stickyHeaderIndices when all in initia - - - @@ -118,21 +265,21 @@ exports[`VirtualizedList forwards correct stickyHeaderIndices when all in initia - - - @@ -205,7 +352,7 @@ exports[`VirtualizedList forwards correct stickyHeaderIndices when partially in - @@ -213,21 +360,21 @@ exports[`VirtualizedList forwards correct stickyHeaderIndices when partially in - - - @@ -235,7 +382,7 @@ exports[`VirtualizedList forwards correct stickyHeaderIndices when partially in - @@ -613,7 +760,7 @@ exports[`VirtualizedList handles separators correctly 3`] = ` `; -exports[`VirtualizedList keeps sticky headers realized after scrolled out of viewport 1`] = ` +exports[`VirtualizedList keeps sticky headers above viewport visualized 1`] = ` - @@ -743,7 +877,7 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie - @@ -758,7 +892,7 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie - @@ -766,23 +900,21 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie - - - @@ -790,23 +922,21 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie - - - @@ -814,23 +944,21 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie - - - @@ -838,8 +966,7 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie - @@ -847,209 +974,99 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie `; -exports[`VirtualizedList realizes sticky headers in viewport on batched render 1`] = ` +exports[`VirtualizedList renders all the bells and whistles 1`] = ` + } + refreshing={false} renderItem={[Function]} scrollEventThrottle={50} - stickyHeaderIndices={ + stickyHeaderIndices={Array []} + style={ Array [ - 0, - 3, + Object { + "transform": Array [ + Object { + "scaleY": -1, + }, + ], + }, + undefined, ] } - windowSize={1} > + - +
- - - - - - - - - - - - - - -`; - -exports[`VirtualizedList renders all the bells and whistles 1`] = ` - - } - refreshing={false} - renderItem={[Function]} - scrollEventThrottle={50} - stickyHeaderIndices={Array []} - style={ - Array [ - Object { - "transform": Array [ - Object { - "scaleY": -1, - }, - ], - }, - undefined, - ] - } -> - - - -
- - `; +exports[`VirtualizedList renders empty list after batch 1`] = ` + + + +`; + exports[`VirtualizedList renders empty list with empty component 1`] = ` `; +exports[`VirtualizedList renders sticky headers in viewport on batched render 1`] = ` + + + + + + + + + + + + + + + + + + + + +`; + exports[`VirtualizedList test getItem functionality where data is not an Array 1`] = ` `; + +exports[`adjusts render area with non-zero initialScrollIndex after scrolled 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`constrains batch render region when an item is removed 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`discards intitial render if initialScrollIndex != 0 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`does not adjust render area until content area layed out 1`] = ` + + + + + + + + + + + + + + + + + + + + +`; + +exports[`does not adjust render area with non-zero initialScrollIndex until scrolled 1`] = ` + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`does not over-render when there is less than initialNumToRender cells 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`eventually renders all items when virtualization disabled 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`expands first in viewport to render up to maxToRenderPerBatch on initial render 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`expands render area by maxToRenderPerBatch on tick 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders a zero-height tail spacer on initial render if getItemLayout not defined 1`] = ` + + + + + + + + + + + + + + +`; + +exports[`renders full tail spacer if all cells measured 1`] = ` + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders initialNumToRender cells when virtualization disabled 1`] = ` + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders items before initialScrollIndex on first batch tick when virtualization disabled 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders no spacers up to initialScrollIndex on first render when virtualization disabled 1`] = ` + + + + + + + + + + +`; + +exports[`renders offset cells in initial render when initialScrollIndex set 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders tail spacer up to last measured index if getItemLayout not defined 1`] = ` + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders tail spacer up to last measured with irregular layout when getItemLayout undefined 1`] = ` + + + + + + + + + + + + + + + + + +`; + +exports[`renders windowSize derived region at bottom 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders windowSize derived region at top 1`] = ` + + + + + + + + + + + + + + + + + +`; + +exports[`renders windowSize derived region in middle 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders zero-height tail spacer on batch render if cells not yet measured and getItemLayout not defined 1`] = ` + + + + + + + + + + + + + + +`; + +exports[`retains batch render region when an item is appended 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`retains initial render region when an item is appended 1`] = ` + + + + + + + + + + + + + + +`; + +exports[`retains intitial render if initialScrollIndex == 0 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`unmounts sticky headers moved below viewport 1`] = ` + + + + + + + + + + + + + + + + + + + + +`; diff --git a/vnext/overrides.json b/vnext/overrides.json index 713467ae402..45dd707dec7 100644 --- a/vnext/overrides.json +++ b/vnext/overrides.json @@ -378,7 +378,7 @@ "baseHash": "f23a66cfc475ee1e2eda1065e56e05e547b7030e" }, { - "type": "copy", + "type": "derived", "file": "src/Libraries/Lists/__tests__/VirtualizedList-test.js", "baseFile": "Libraries/Lists/__tests__/VirtualizedList-test.js", "baseHash": "fd25fc611f8da21b93e7f8750d8f2fc8273a4a52" diff --git a/vnext/src/Libraries/Lists/__tests__/VirtualizedList-test.js b/vnext/src/Libraries/Lists/__tests__/VirtualizedList-test.js index 260a592fe4f..ae5d9169c1e 100644 --- a/vnext/src/Libraries/Lists/__tests__/VirtualizedList-test.js +++ b/vnext/src/Libraries/Lists/__tests__/VirtualizedList-test.js @@ -104,6 +104,28 @@ describe('VirtualizedList', () => { expect(component).toMatchSnapshot(); }); + it('renders empty list after batch', () => { + const component = ReactTestRenderer.create( + } + getItem={(data, index) => data[index]} + getItemCount={data => data.length} + />, + ); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + + performAllBatches(); + }); + + expect(component).toMatchSnapshot(); + }); + it('renders null list', () => { const component = ReactTestRenderer.create( { // onLayout, which can cause https://github.com/facebook/react-native/issues/16067 instance._onContentSizeChange(300, initialContentHeight); instance._onContentSizeChange(300, data.length * ITEM_HEIGHT); - jest.runAllTimers(); + performAllBatches(); expect(onEndReached).not.toHaveBeenCalled(); @@ -384,7 +406,7 @@ describe('VirtualizedList', () => { contentInset: {right: 0, top: 0, left: 0, bottom: 0}, }, }); - jest.runAllTimers(); + performAllBatches(); expect(onEndReached).toHaveBeenCalled(); }); @@ -506,161 +528,1006 @@ describe('VirtualizedList', () => { }); it('forwards correct stickyHeaderIndices when all in initial render window', () => { - const items = Array(10) - .fill() - .map((_, i) => (i % 3 === 0 ? {key: i, sticky: true} : {key: i})); - const stickyIndices = items - .filter(item => item.sticky) - .map(item => item.key); + const items = generateItemsStickyEveryN(10, 3); + const ITEM_HEIGHT = 10; + const component = ReactTestRenderer.create( + , + ); + + // The initial render is specified to be the length of items provided. + // Expect that all sticky items (1 every 3) are passed to the underlying + // scrollview. + expect(component).toMatchSnapshot(); + }); + + it('forwards correct stickyHeaderIndices when ListHeaderComponent present', () => { + const items = generateItemsStickyEveryN(10, 3); const ITEM_HEIGHT = 10; const component = ReactTestRenderer.create( React.createElement('Header')} initialNumToRender={10} - renderItem={({item}) => } - getItem={(data, index) => data[index]} - getItemCount={data => data.length} - getItemLayout={(_, index) => ({ - length: ITEM_HEIGHT, - offset: ITEM_HEIGHT * index, - index, - })} + {...baseItemProps(items)} + {...fixedHeightItemLayoutProps(ITEM_HEIGHT)} />, ); + // The initial render is specified to be the length of items provided. + // Expect that all sticky items (1 every 3) are passed to the underlying + // scrollview, indices offset by 1 to account for the the header component. expect(component).toMatchSnapshot(); }); it('forwards correct stickyHeaderIndices when partially in initial render window', () => { - const items = Array(10) - .fill() - .map((_, i) => (i % 3 === 0 ? {key: i, sticky: true} : {key: i})); - const stickyIndices = items - .filter(item => item.sticky) - .map(item => item.key); + const items = generateItemsStickyEveryN(10, 3); const ITEM_HEIGHT = 10; const component = ReactTestRenderer.create( } - getItem={(data, index) => data[index]} - getItemCount={data => data.length} - getItemLayout={(_, index) => ({ - length: ITEM_HEIGHT, - offset: ITEM_HEIGHT * index, - index, - })} + {...baseItemProps(items)} + {...fixedHeightItemLayoutProps(ITEM_HEIGHT)} />, ); + // The initial render is specified to be half the length of items provided. + // Expect that all sticky items of index < 5 are passed to the underlying + // scrollview. expect(component).toMatchSnapshot(); }); - it('realizes sticky headers in viewport on batched render', () => { - const items = Array(10) - .fill() - .map((_, i) => (i % 3 === 0 ? {key: i, sticky: true} : {key: i})); - const stickyIndices = items - .filter(item => item.sticky) - .map(item => item.key); - + it('renders sticky headers in viewport on batched render', () => { + const items = generateItemsStickyEveryN(10, 3); const ITEM_HEIGHT = 10; - const virtualizedListProps = { - data: items, - stickyHeaderIndices: stickyIndices, - initialNumToRender: 1, - windowSize: 1, - renderItem: ({item}) => , - getItem: (data, index) => data[index], - getItemCount: data => data.length, - getItemLayout: (_, index) => ({ - length: ITEM_HEIGHT, - offset: ITEM_HEIGHT * index, - index, - }), - }; - let component; - ReactTestRenderer.act(() => { component = ReactTestRenderer.create( - , + , ); }); ReactTestRenderer.act(() => { - component - .getInstance() - ._onLayout({nativeEvent: {layout: {width: 10, height: 50}}}); - component.getInstance()._onContentSizeChange(10, 100); - jest.runAllTimers(); + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 100}, + }); + performAllBatches(); }); + // A windowSize of 1 means we will render just the viewport height (50dip). + // Expect 5 10dip items to eventually be rendered, with sticky headers in + // the first 5 propagated. expect(component).toMatchSnapshot(); }); - it('keeps sticky headers realized after scrolled out of viewport', () => { - const items = Array(20) - .fill() - .map((_, i) => - i % 3 === 0 ? {key: i, sticky: true} : {key: i, sticky: false}, - ); - const stickyIndices = items - .filter(item => item.sticky) - .map(item => item.key); - + it('keeps sticky headers above viewport visualized', () => { + const items = generateItemsStickyEveryN(20, 3); const ITEM_HEIGHT = 10; - const virtualizedListProps = { - data: items, - stickyHeaderIndices: stickyIndices, - initialNumToRender: 1, - windowSize: 1, - renderItem: ({item}) => , - getItem: (data, index) => data[index], - getItemCount: data => data.length, - getItemLayout: (_, index) => ({ - length: ITEM_HEIGHT, - offset: ITEM_HEIGHT * index, - index, - }), - }; - let component; - ReactTestRenderer.act(() => { component = ReactTestRenderer.create( - , + , ); }); ReactTestRenderer.act(() => { - component - .getInstance() - ._onLayout({nativeEvent: {layout: {width: 10, height: 50}}}); - component.getInstance()._onContentSizeChange(10, 200); - jest.runAllTimers(); + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + performAllBatches(); }); ReactTestRenderer.act(() => { - component.getInstance()._onScroll({ - nativeEvent: { - contentOffset: {x: 0, y: 150}, - contentSize: {width: 10, height: 200}, - layoutMeasurement: {width: 10, height: 50}, - }, - }); - jest.runAllTimers(); + simulateScroll(component, {x: 0, y: 150}); + performAllBatches(); }); + // Scroll to the bottom 50 dip (last five items) of the content. Expect the + // last five items to be rendered, along with every sticky header above, + // even though they are out of the viewport window in layout coordinates. + // This is because they will remain rendered even once scrolled-past in + // layout space. expect(component).toMatchSnapshot(); }); }); + +it('unmounts sticky headers moved below viewport', () => { + const items = generateItemsStickyEveryN(20, 3); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 150}); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 0}); + performAllBatches(); + }); + + // Scroll to the bottom 50 dip (last five items) of the content, then back up + // to the first 5. Ensure that sticky items are unmounted once they are below + // the render area. + expect(component).toMatchSnapshot(); +}); + +it('renders offset cells in initial render when initialScrollIndex set', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + const component = ReactTestRenderer.create( + , + ); + + // Check that the first render respects initialScrollIndex + expect(component).toMatchSnapshot(); +}); + +it('does not over-render when there is less than initialNumToRender cells', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + const component = ReactTestRenderer.create( + , + ); + + // Check that the first render clamps to the last item when intialNumToRender + // goes over it. + expect(component).toMatchSnapshot(); +}); + +it('retains intitial render if initialScrollIndex == 0', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 150}); + performAllBatches(); + }); + + // If initialScrollIndex is 0 (the default), we should never unmount the top + // initialNumToRender as part of the "scroll to top optimization", even after + // scrolling to the bottom five items. + expect(component).toMatchSnapshot(); +}); + +it('discards intitial render if initialScrollIndex != 0', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 150}); + performAllBatches(); + }); + + // If initialScrollIndex is not 0, we do not enable retaining initial render + // as part of "scroll to top" optimization. + expect(component).toMatchSnapshot(); +}); + +it('expands render area by maxToRenderPerBatch on tick', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; + + const props = { + initialNumToRender: 5, + maxToRenderPerBatch: 2, + }; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + performNextBatch(); + }); + + // We start by rendering 5 items in the initial render, but have default + // windowSize, enabling eventual rendering up to 20 viewports worth of + // content. We limit this to rendering 2 items per-batch via + // maxToRenderPerBatch, so we should only have 7 items rendered after the + // initial timer tick. + expect(component).toMatchSnapshot(); +}); + +it('does not adjust render area until content area layed out', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; + + let component; + + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateViewportLayout(component, {width: 10, height: 50}); + performAllBatches(); + }); + + // We should not start layout-based logic to expand rendered area until + // content is layed out. Expect only the 5 initial items to be rendered after + // processing all batch work, even though the windowSize allows for more. + expect(component).toMatchSnapshot(); +}); + +it('does not adjust render area with non-zero initialScrollIndex until scrolled', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + performAllBatches(); + }); + + // Layout information from before the time we scroll to initial index may not + // correspond to the area "initialScrollIndex" points to. Expect only the 5 + // initial items (starting at initialScrollIndex) to be rendered after + // processing all batch work, even though the windowSize allows for more. + expect(component).toMatchSnapshot(); +}); + +it('adjusts render area with non-zero initialScrollIndex after scrolled', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + + simulateScroll(component, {x: 0, y: 10}); + performAllBatches(); + }); + + // We should expand the render area after receiving a message indcating we + // arrived at initialScrollIndex. + expect(component).toMatchSnapshot(); +}); + +it('renders initialNumToRender cells when virtualization disabled', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + const component = ReactTestRenderer.create( + , + ); + + // We should render initialNumToRender items with no spacers on initial render + // when virtualization is disabled + expect(component).toMatchSnapshot(); +}); + +it('renders no spacers up to initialScrollIndex on first render when virtualization disabled', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + // There should be no spacers present in an offset initial render with + // virtualiztion disabled. Only initialNumToRender items starting at + // initialScrollIndex. + expect(component).toMatchSnapshot(); +}); + +it('expands first in viewport to render up to maxToRenderPerBatch on initial render', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + // When virtualization is disabled we may render items before initialItemIndex + // if initialItemIndex + initialNumToRender < maToRenderPerBatch. Expect cells + // 0-3 to be rendered in this example, even though initialScrollIndex is 4. + expect(component).toMatchSnapshot(); +}); + +it('renders items before initialScrollIndex on first batch tick when virtualization disabled', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 100}, + }); + performNextBatch(); + }); + + // When virtualization is disabled, we render "maxToRenderPerBatch" items + // sequentially per batch tick. Any items not yet rendered before + // initialScrollIndex are currently rendered at this time. Expect the first + // tick to render all items before initialScrollIndex, along with + // maxToRenderPerBatch after. + expect(component).toMatchSnapshot(); +}); + +it('eventually renders all items when virtualization disabled', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 100}, + }); + performAllBatches(); + }); + + // After all batch ticks, all items should eventually be rendered when\ + // virtualization is disabled. + expect(component).toMatchSnapshot(); +}); + +it('retains initial render region when an item is appended', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + component.update( + , + ); + }); + + // Adding an item to the list before batch render should keep the existing + // rendered items rendered. Expect the first 3 items rendered, and a spacer + // for 8 items (including the 11th, added item). + expect(component).toMatchSnapshot(); +}); + +it('retains batch render region when an item is appended', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 100}, + }); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + component.update( + , + ); + }); + + // Adding an item to the list after batch render should keep the existing + // rendered items rendered. We batch render 10 items, then add an 11th. Expect + // the first ten items to be present, with a spacer for the 11th until the + // next batch render. + expect(component).toMatchSnapshot(); +}); + +it('constrains batch render region when an item is removed', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 100}, + }); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + component.update( + , + ); + }); + + // If the number of items is reduced, we should remove the corresponding + // already rendered items. Expect there to be 5 items present. New items in a + // previously occupied index may also be immediately rendered. + expect(component).toMatchSnapshot(); +}); + +it('renders a zero-height tail spacer on initial render if getItemLayout not defined', () => { + const items = generateItems(10); + + const component = ReactTestRenderer.create( + , + ); + + // Do not add space for out-of-viewport content on initial render when we do + // not yet know how large it should be (no getItemLayout and cell onLayout not + // yet called). Expect the tail spacer not to occupy space. + expect(component).toMatchSnapshot(); +}); + +it('renders zero-height tail spacer on batch render if cells not yet measured and getItemLayout not defined', () => { + const items = generateItems(10); + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); + performNextBatch(); + }); + + // Do not add space for out-of-viewport content unless the cell has previously + // been layed out and measurements cached. Expect the tail spacer not to + // occupy space. + expect(component).toMatchSnapshot(); +}); + +it('renders tail spacer up to last measured index if getItemLayout not defined', () => { + const items = generateItems(10); + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + const LAST_MEASURED_CELL = 6; + for (let i = 0; i <= LAST_MEASURED_CELL; ++i) { + simulateCellLayout(component, items, i, { + width: 10, + height: 10, + x: 0, + y: 10 * i, + }); + } + + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 30}, + }); + performNextBatch(); + }); + + // If cells in the out-of-viewport area have been measured, their space can be + // incorporated into the tail spacer, without space for the cells we can not + // measure until layout. Expect there to be a tail spacer occupying the space + // for measured, but not yet rendered items (up to and including item 6). + expect(component).toMatchSnapshot(); +}); + +it('renders tail spacer up to last measured with irregular layout when getItemLayout undefined', () => { + const items = generateItems(10); + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + const LAST_MEASURED_CELL = 6; + + let currentY = 0; + for (let i = 0; i <= LAST_MEASURED_CELL; ++i) { + simulateCellLayout(component, items, i, { + width: 10, + height: i, + x: 0, + y: currentY + i, + }); + currentY += i; + } + + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 30}, + }); + performNextBatch(); + }); + + // If cells in the out-of-viewport area have been measured, their space can be + // incorporated into the tail spacer, without space for the cells we can not + // measure until layout. Expect there to be a tail spacer occupying the space + // for measured, but not yet rendered items (up to and including item 6). + expect(component).toMatchSnapshot(); +}); + +it('renders full tail spacer if all cells measured', () => { + const items = generateItems(10); + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + const LAST_MEASURED_CELL = 9; + for (let i = 0; i <= LAST_MEASURED_CELL; ++i) { + simulateCellLayout(component, items, i, { + width: 10, + height: 10, + x: 0, + y: 10 * i, + }); + } + + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 30}, + }); + performNextBatch(); + }); + + // The tail-spacer should occupy the space of all non-rendered items if all + // items have been measured. + expect(component).toMatchSnapshot(); +}); + +it('renders windowSize derived region at top', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 20}, + content: {width: 10, height: 100}, + }); + performAllBatches(); + }); + + // A windowSize of 3 means that we should render a viewport's worth of content + // above and below the current. A 20 dip viewport at the top of the list means + // we should render the top 4 10-dip items (for the current viewport, and + // 20dip below). + expect(component).toMatchSnapshot(); +}); + +it('renders windowSize derived region in middle', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 20}, + content: {width: 10, height: 100}, + }); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 50}); + performAllBatches(); + }); + + // A windowSize of 3 means that we should render a viewport's worth of content + // above and below the current. A 20 dip viewport in the top of the list means + // we should render the 6 10-dip items (for the current viewport, 20 dip above + // and below), along with retaining the top initialNumToRenderItems. We seem + // to actually render 7 in the middle due to rounding at the moment. + expect(component).toMatchSnapshot(); +}); + +it('renders windowSize derived region at bottom', () => { + const items = generateItems(10); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 20}, + content: {width: 10, height: 100}, + }); + performAllBatches(); + }); + + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 80}); + performAllBatches(); + }); + + // A windowSize of 3 means that we should render a viewport's worth of content + // above and below the current. A 20 dip viewport at the bottom of the list + // means we should render the bottom 4 10-dip items (for the current viewport, + // and 20dip above), along with retaining the top initialNumToRenderItems. We + // seem to actually render 4 at the bottom due to rounding at the moment. + expect(component).toMatchSnapshot(); +}); + +function generateItems(count) { + return Array(count) + .fill() + .map((_, i) => ({key: i})); +} + +function generateItemsStickyEveryN(count, n) { + return Array(count) + .fill() + .map((_, i) => (i % n === 0 ? {key: i, sticky: true} : {key: i})); +} + +function baseItemProps(items) { + return { + data: items, + renderItem: ({item}) => + React.createElement('MockCellItem', {value: item.key, ...item}), + getItem: (data, index) => data[index], + getItemCount: data => data.length, + stickyHeaderIndices: stickyHeaderIndices(items), + }; +} + +function stickyHeaderIndices(items) { + return items.filter(item => item.sticky).map(item => item.key); +} + +function fixedHeightItemLayoutProps(height) { + return { + getItemLayout: (_, index) => ({ + length: height, + offset: height * index, + index, + }), + }; +} + +let lastViewportLayout; +let lastContentLayout; + +function simulateLayout(component, args) { + simulateViewportLayout(component, args.viewport); + simulateContentLayout(component, args.content); +} + +function simulateViewportLayout(component, dimensions) { + lastViewportLayout = dimensions; + component.getInstance()._onLayout({nativeEvent: {layout: dimensions}}); +} + +function simulateContentLayout(component, dimensions) { + lastContentLayout = dimensions; + component + .getInstance() + ._onContentSizeChange(dimensions.width, dimensions.height); +} + +function simulateCellLayout(component, items, itemIndex, dimensions) { + const instance = component.getInstance(); + const cellKey = instance._keyExtractor(items[itemIndex], itemIndex); + instance._onCellLayout( + {nativeEvent: {layout: dimensions}}, + cellKey, + itemIndex, + ); +} + +function simulateScroll(component, position) { + component.getInstance()._onScroll({ + nativeEvent: { + contentOffset: position, + contentSize: lastContentLayout, + layoutMeasurement: lastViewportLayout, + }, + }); +} + +function performAllBatches() { + jest.runAllTimers(); +} + +function performNextBatch() { + jest.runOnlyPendingTimers(); +} diff --git a/vnext/src/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap b/vnext/src/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap index 6bd99586b38..ad93703101b 100644 --- a/vnext/src/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap +++ b/vnext/src/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap @@ -1,5 +1,152 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`VirtualizedList forwards correct stickyHeaderIndices when ListHeaderComponent present 1`] = ` + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + exports[`VirtualizedList forwards correct stickyHeaderIndices when all in initial render window 1`] = ` - @@ -74,21 +221,21 @@ exports[`VirtualizedList forwards correct stickyHeaderIndices when all in initia - - - @@ -96,21 +243,21 @@ exports[`VirtualizedList forwards correct stickyHeaderIndices when all in initia - - - @@ -118,21 +265,21 @@ exports[`VirtualizedList forwards correct stickyHeaderIndices when all in initia - - - @@ -205,7 +352,7 @@ exports[`VirtualizedList forwards correct stickyHeaderIndices when partially in - @@ -213,21 +360,21 @@ exports[`VirtualizedList forwards correct stickyHeaderIndices when partially in - - - @@ -235,7 +382,7 @@ exports[`VirtualizedList forwards correct stickyHeaderIndices when partially in - @@ -613,7 +760,7 @@ exports[`VirtualizedList handles separators correctly 3`] = ` `; -exports[`VirtualizedList keeps sticky headers realized after scrolled out of viewport 1`] = ` +exports[`VirtualizedList keeps sticky headers above viewport visualized 1`] = ` - @@ -743,7 +877,7 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie - @@ -758,7 +892,7 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie - @@ -766,23 +900,21 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie - - - @@ -790,23 +922,21 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie - - - @@ -814,23 +944,21 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie - - - @@ -838,8 +966,7 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie - @@ -847,209 +974,99 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie `; -exports[`VirtualizedList realizes sticky headers in viewport on batched render 1`] = ` +exports[`VirtualizedList renders all the bells and whistles 1`] = ` + } + refreshing={false} renderItem={[Function]} scrollEventThrottle={50} - stickyHeaderIndices={ + stickyHeaderIndices={Array []} + style={ Array [ - 0, - 3, + Object { + "transform": Array [ + Object { + "scaleY": -1, + }, + ], + }, + undefined, ] } - windowSize={1} > + - +
- - - - - - - - - - - - - - -`; - -exports[`VirtualizedList renders all the bells and whistles 1`] = ` - - } - refreshing={false} - renderItem={[Function]} - scrollEventThrottle={50} - stickyHeaderIndices={Array []} - style={ - Array [ - Object { - "transform": Array [ - Object { - "scaleY": -1, - }, - ], - }, - undefined, - ] - } -> - - - -
- - `; +exports[`VirtualizedList renders empty list after batch 1`] = ` + + + +`; + exports[`VirtualizedList renders empty list with empty component 1`] = ` `; +exports[`VirtualizedList renders sticky headers in viewport on batched render 1`] = ` + + + + + + + + + + + + + + + + + + + + +`; + exports[`VirtualizedList test getItem functionality where data is not an Array 1`] = ` `; + +exports[`adjusts render area with non-zero initialScrollIndex after scrolled 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`constrains batch render region when an item is removed 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`discards intitial render if initialScrollIndex != 0 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`does not adjust render area until content area layed out 1`] = ` + + + + + + + + + + + + + + + + + + + + +`; + +exports[`does not adjust render area with non-zero initialScrollIndex until scrolled 1`] = ` + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`does not over-render when there is less than initialNumToRender cells 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`eventually renders all items when virtualization disabled 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`expands first in viewport to render up to maxToRenderPerBatch on initial render 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`expands render area by maxToRenderPerBatch on tick 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders a zero-height tail spacer on initial render if getItemLayout not defined 1`] = ` + + + + + + + + + + + + + + +`; + +exports[`renders full tail spacer if all cells measured 1`] = ` + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders initialNumToRender cells when virtualization disabled 1`] = ` + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders items before initialScrollIndex on first batch tick when virtualization disabled 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders no spacers up to initialScrollIndex on first render when virtualization disabled 1`] = ` + + + + + + + + + + +`; + +exports[`renders offset cells in initial render when initialScrollIndex set 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders tail spacer up to last measured index if getItemLayout not defined 1`] = ` + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders tail spacer up to last measured with irregular layout when getItemLayout undefined 1`] = ` + + + + + + + + + + + + + + + + + +`; + +exports[`renders windowSize derived region at bottom 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders windowSize derived region at top 1`] = ` + + + + + + + + + + + + + + + + + +`; + +exports[`renders windowSize derived region in middle 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders zero-height tail spacer on batch render if cells not yet measured and getItemLayout not defined 1`] = ` + + + + + + + + + + + + + + +`; + +exports[`retains batch render region when an item is appended 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`retains initial render region when an item is appended 1`] = ` + + + + + + + + + + + + + + +`; + +exports[`retains intitial render if initialScrollIndex == 0 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`unmounts sticky headers moved below viewport 1`] = ` + + + + + + + + + + + + + + + + + + + + +`;