-
Notifications
You must be signed in to change notification settings - Fork 24.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add unit tests for VirtualizedList render quirks #31401
Conversation
This change adds a series of snapshot tests to validate the render output of VirtualizedList in mixed scenarios. Jest timer mocks are used to measure rendering at different ticks. These test cases mostly center around realization logic, to help prevent regressions when chaning internal state representation.
Base commit: ebe939b |
Base commit: ebe939b |
Depends on: facebook#31401 facebook#31420 VirtualizedList currently keeps a [first, last] range as state, tracking the region of cells to render. The render functions uses this as an input, along with a few special cases to render more (sticky headers, initial render region.) This change moves to instead keep state which describes discontiguous render regions. This mask is continually updated as the viewport changes, batch renders expand the region, etc. Special cases are baked into the render mask, with a relatively simple tranformation from the mask to render function. This representation makes it much easier to support keyboarding scenarios, which require keeping distinct regions (e.g. for last focused) realized while out of viewport. MSFT employees can see more here: https://msit.microsoftstream.com/video/fe01a1ff-0400-94b1-d4f1-f1eb924b1809
@rozele has imported this pull request. If you are a Facebook employee, you can view this diff on Phabricator. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is amazing! Thank you for all the test cases here! It's been educational for me to review. I've left some comments/questions mostly about expectations of the test cases and ask if we could better clarify the expectations from the snapshots. For lowest effort, I think even a comment of expectation would be really helpful of what indices we expect to be rendered.
@@ -104,6 +104,28 @@ describe('VirtualizedList', () => { | |||
expect(component).toMatchSnapshot(); | |||
}); | |||
|
|||
it('renders empty list after batch', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see we have a test above for rendering empty list, should that just be replaced by this one? Or should all our tests be calling onLayout?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The difference here is initial render vs later render batches. The first virtualizedlist render is special cased to not need layout information, compared to later renders which use layout information and excersise some different code paths.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gotcha, small nit then, maybe we should rename that test case to initial render renders empty list
.
jest.runAllTimers(); | ||
simulateLayout(component, { | ||
viewport: {width: 10, height: 50}, | ||
content: {width: 10, height: 200}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why was the content height changed to 200 from 100?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It should be 100 here. Some of the tests are 10 items vs 20, which is why there is a size difference. I tried to do smaller tests where possible to make the snapshots more decipherable, but extra cells made some cases clearer.
.filter(item => item.sticky) | ||
.map(item => item.key); | ||
|
||
it('keeps sticky headers above viewport visualized', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Am I understanding correctly that sticky headers should stay rendered once they've been realized and even if they are no longer in the viewport?
Edit: Ah interesting the test case below unmounts sticky headers moved below viewport
clarifies more. Hmm, do we have documentation about the virtualization behavior of this? If this is the only thing that addresses it, should we add more comments to explain the behavior?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The prop (exposed to ScrollView) is documented, but I'm not sure virtualization behavior is. I discovered the logic in-code, but its requirement makes sense.
If you have a sticky header, it will stay at top of viewport, even if its original layout position is scrolled out of viewport. That means layout-based logic would not be aware it is on screen, and would consider it eligible to be virtualized away (while on screen). Exempting sticky headers (even just the closest) above the viewport from virtualization prevents them from disappearing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh yes that does make sense! Yea would be great to add this documentation to this test.
}); | ||
jest.runAllTimers(); | ||
simulateScroll(component, {x: 0, y: 150}); | ||
performAllBatches(); | ||
}); | ||
|
||
expect(component).toMatchSnapshot(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we add more assertions here to be clear what we're looking for in these snapshots? I'm not exactly sure what I'm looking for 😅. Either a comment or a snapshot / inspection assertion would be really helpful! I realize we haven't done so in the past but I think it'll really help clarify expectations.
simulateScroll(component, {x: 0, y: 150}); | ||
performAllBatches(); | ||
}); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we verify here that the sticky indices in question are rendered?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The beginning part of this should be I think the equivalent of the above keeps sticky headers above viewport visualized
performAllBatches(); | ||
}); | ||
|
||
expect(component).toMatchSnapshot(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When I look at the snapshot of this, I see all 20 MockCellItems rendered. Is that expected?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch. A past iteration of this IIRC had windowSize={1}
to force that we virtualize part of the list. It looks like this was dropped.
I will do a look through of all of the snapshots when adding comments describing expectations, to catch if there are other cases like this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like windowSize
was replaced with window
in a few places. Need to figure out what happened there.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed the snapshot is now correct, with the correct prop being passed from the test.
expect(component).toMatchSnapshot(); | ||
}); | ||
|
||
it('renders offset cells in initial render when initialScrollIndex set', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you clarify what the offset cells are here? And provide more assertions on the test?
expect(component).toMatchSnapshot(); | ||
}); | ||
|
||
it('does not over-render when there is less than initialNumToRender cells', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does over-render mean?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Need to think about the language for this.
The intent is to test the case where we start at a non-zero initial scrollIndex, and need to render less than initialNumToRender.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, so we want to ensure we don't render anything before the initialScrollIndex? Gotcha, again just a comment here might be helpful enough to get that context across.
Will add some comments explaining expected behavior to everything |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code analysis results:
eslint
found some issues. Runyarn lint --fix
to automatically fix problems.
…o add-virt-test-coverage
This merges in the changes from facebook/react-native#31401 into our repo, since they have not been merged upstream yet. These files can be set to be immutable copies of upstream code again once the changes are merged upstream.
This merges in the changes from facebook/react-native#31401 into our repo, since they have not been merged upstream yet. These files can be set to be immutable copies of upstream code again once the changes are merged upstream.
@@ -728,7 +862,7 @@ exports[`VirtualizedList keeps sticky headers realized after scrolled out of vie | |||
<View | |||
style={null} | |||
> | |||
<item | |||
<MockCellItem |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This snapshot was described as:
// 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.
I see more than the last 5 items rendered here -- am I misunderstanding?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right. The description here isn't quite accurate. I can update it.
We always keep the initially rendered items present rendered, for the scroll to top optimization. Since initialNumToRender is set to 1, we keep the top cell rendered. windowSize
is set to 1, instead of the default 21, which should theoretically mean "just keep the stuff in viewport rendered". This would mean five items, and I've observed that is what we do end up seeing when viewport is at the top, or some other positions. I've noticed we sometimes render more than that though, which I think may be related to either rounding, or directional render logic, that I have not explored. That is I think why we see more than 5 items at the end, even though the contract would be that windowSize
of 1 would mean just the items in viewport.
Now that I am looking closer at this, we have a 50-height spacer where some sticky headers should be. We do specifically keep MockCellItem 6 rendered, even though there are two spacers after, but that implies we are only keeping the most recent sticky header above viewport realized, instead of all of them.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated the description to better match the current behavior
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool thanks @NickGerleman for digging deep into this! I think @rozele may need to re-import this
@rozele has imported this pull request. If you are a Facebook employee, you can view this diff on Phabricator. |
Builds upon the `CellRenderMask` data structure added with facebook#31420, and VirtualizedList coverage added with facebook#31401. VirtualizedList currently keeps a [first, last] range as state, tracking the region of cells to render. The render functions uses this as an input, along with a few special cases to render more (sticky headers, initial render region.) This change moves to instead keep state which describes discontiguous render regions. This mask is continually updated as the viewport changes, batch renders expand the region, etc. Special cases are baked into the render mask, with a relatively simple tranformation from the mask to render function. This representation makes it much easier to support keyboarding scenarios, which require keeping distinct regions (e.g. for last focused) realized while out of viewport. MS/FB folks have a video discussion about VirtualizedList here: https://msit.microsoftstream.com/video/fe01a1ff-0400-94b1-d4f1-f1eb924b1809 facebook#31401 added quite a few snapshot tests, centering around the logic this change is touching. I manually validated RNTester FlatList examples (and their should be some upstream UI testing for them).
Builds upon the `CellRenderMask` data structure added with facebook#31420, and VirtualizedList coverage added with facebook#31401. VirtualizedList currently keeps a [first, last] range as state, tracking the region of cells to render. The render functions uses this as an input, along with a few special cases to render more (sticky headers, initial render region.) This change moves to instead keep state which describes discontiguous render regions. This mask is continually updated as the viewport changes, batch renders expand the region, etc. Special cases are baked into the render mask, with a relatively simple tranformation from the mask to render function. This representation makes it much easier to support keyboarding scenarios, which require keeping distinct regions (e.g. for last focused) realized while out of viewport. MS/FB folks have a video discussion about VirtualizedList here: https://msit.microsoftstream.com/video/fe01a1ff-0400-94b1-d4f1-f1eb924b1809 facebook#31401 added quite a few snapshot tests, centering around the logic this change is touching. I manually validated RNTester FlatList examples (and their should be some upstream UI testing for them).
Summary
This change adds a series of snapshot tests to validate the render output of VirtualizedList in mixed scenarios. Jest timer mocks are used to measure rendering at different ticks. These test cases mostly center around realization logic, to help prevent regressions when chaning internal state representation.
Changelog
[Internal] [Added] - Add unit tests for VirtualizedList render quirks
Test Plan
Ran the added UTs locally.