Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/giant-spiders-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lit-labs/virtualizer': patch
---

Fix bug affecting position: fixed scrollers (#4125)
7 changes: 5 additions & 2 deletions packages/labs/virtualizer/src/Virtualizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -942,8 +942,11 @@ function getElementAncestors(el: HTMLElement, includeSelf = false) {
function getClippingAncestors(el: HTMLElement, includeSelf = false) {
let foundFixed = false;
return getElementAncestors(el, includeSelf).filter((a) => {
if (foundFixed) {
return false;
}
const style = getComputedStyle(a);
foundFixed = foundFixed || style.position === 'fixed';
return !foundFixed && style.overflow !== 'visible';
foundFixed = style.position === 'fixed';
return style.overflow !== 'visible';
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/

import {array, ignoreBenignErrors} from '../helpers.js';
import {LitVirtualizer} from '../../lit-virtualizer.js';
import {grid} from '../../layouts/grid.js';
import {styleMap} from 'lit/directives/style-map.js';
import {expect, html, fixture} from '@open-wc/testing';

interface NamedItem {
name: string;
}

const _100Items = array(100).map((_, i) => ({name: `Item ${i}`}));

describe('Virtualizer behaves properly when it has a position: fixed ancestor', () => {
ignoreBenignErrors(beforeEach, afterEach);

// Regression test for https://github.com/lit/lit/issues/3904
it('should calculate its viewport correctly', async () => {
// Our outer container is overflow: hidden, so would normally be
// a "clipping ancestor" of our virtualizer. Since it has a
// width of zero, it would cause the virtualizer to be invisible.
const containerStyles = {
overflow: 'hidden',
width: '0',
};

// But since this scrolling element is position: fixed, it will
// be positioned relative to the document, essentially "popping out"
// of its zero-width parent. This means the virtualizer will be
// visible and have some space to work with, after all.
const scrollerStyles = {
position: 'fixed',
width: '200px',
height: '200px',
};

// We use the grid layout here because it conveniently will not
// render any items if it calculates that it doesn't have sufficient
// space, making our testing job easier.
const container = await fixture(html`
<div id="container" style=${styleMap(containerStyles)}>
<div id="scroller" style=${styleMap(scrollerStyles)}>
<lit-virtualizer
.layout=${grid({
itemSize: '100px',
})}
.items=${_100Items}
.renderItem=${({name}: NamedItem) => html`<div>${name}</div>`}
></lit-virtualizer>
</div>
</div>
`);

const scroller = container.querySelector('#scroller')!;
expect(scroller).to.be.instanceOf(HTMLDivElement);
const virtualizer = container.querySelector('lit-virtualizer')!;
expect(virtualizer).to.be.instanceOf(LitVirtualizer);

// If virtualizer has properly ignored the zero-width ancestor of our
// fixed-position scroller, some children will be rendered; otherwise, not.
//
// In practice, we'll time out if we fail here because the `layoutComplete`
// promise will never be fulfilled.
await virtualizer.layoutComplete;
expect(virtualizer.textContent).to.contain('Item 0');
Comment on lines +67 to +70
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering a failure's a failure so I'm okay either way, but what are your thoughts on using pass here since the failure message would be more explicit?

Copy link
Contributor Author

@graynorton graynorton Aug 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer to avoid using pass except in cases where we really can't avoid it; instead, I'd prefer to make sure layoutComplete is as reliable as possible and use that for cases where we want to check the state of things after a virtualizer renders.

That said, I was also bothered by the fact that this test failed with a timeout rather than something more akin to the failure you'd observe in manual testing, so I did a bit of digging. It turns out that there were two quirks in the layoutComplete implementation that resulted in the timeout in this case:

  • When you provided a layout explicitly, rather than relying on the default layout to be dynamically loaded, the initial layout pass would complete before you had a chance to request the layoutComplete promise.

  • If a layout pass resulted in no children being rendered (as in the failure case for this test), layoutComplete would never resolve.

I have made tweaks to address both of these quirks and pushed a new commit to this branch. As a result, this test now fails not with a timeout but with a bad render, as you'd expect.

However, there is a decent chance that releasing this change will require users to update some existing tests; specifically, if they are rendering a virtualizer and immediately checking the state of its initial render (without any delay or retry logic), they will likely need to insert an await virtualizer.layoutComplete. (I had to do this to two of our existing tests.) So we need to decide whether we want to go ahead (I'm inclined to) and, if so, whether we call this a breaking change for the Virtualizer release (I'm open to discussion).

Edit: If we do go ahead, we'll want to edit the change log entry for this release to separately call out the change to layoutComplete.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm torn. I think the change to layoutComplete is worth discussing but probably better to be split from this PR just so we can land the position: fixed scroller fix first.

Like the comment you added, it feels bad to introduce a delay purely for the sake of a promise whose main use is for testing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that makes sense. I'll roll back that commit here and make the layoutComplete changes into a separate PR.

});

// Regression test for https://github.com/lit/lit/issues/4125
it('should respond to scroll events from a fixed-position scroller', async () => {
const containerStyles = {
overflow: 'hidden',
};

// Our scroller is position: fixed. While its ancestors (if any) should
// therefore be ignored for the purpose of calculating virtualizer's
// viewport, the scroller itself should be considered a clipping ancestor,
// and virtualizer should listen to any `scroll` events it emits.
const scrollerStyles = {
position: 'fixed',
width: '200px',
height: '200px',
overflow: 'auto',
};

const container = await fixture(html`
<div id="container" style=${styleMap(containerStyles)}>
<div id="scroller" style=${styleMap(scrollerStyles)}>
<lit-virtualizer
.items=${_100Items}
.renderItem=${({name}: NamedItem) => html`<p>${name}</p>`}
></lit-virtualizer>
</div>
</div>
`);

const scroller = container.querySelector('#scroller')!;
expect(scroller).to.be.instanceOf(HTMLDivElement);
const virtualizer = container.querySelector('lit-virtualizer')!;
expect(virtualizer).to.be.instanceOf(LitVirtualizer);

await virtualizer.layoutComplete;
expect(virtualizer.textContent).to.contain('Item 0');

// If the position: fixed scroller has properly been recognized as
// a clipping ancestor, then virtualizer will re-render as scrolling
// occurs; otherwise, not.
//
// In practice, we'll time out if we fail here because the `layoutComplete`
// promise will never be fulfilled.
scroller.scrollTo(0, scroller.scrollHeight);
await virtualizer.layoutComplete;
expect(virtualizer.textContent).to.contain('Item 99');
Comment on lines +106 to +117
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interestingly replacing both of the await virtualizer.layoutComplete here with pass() makes this fail. There must be something about starting a scroller.scrollTo() specifically after awaiting layoutComplete that looking for 'Item 0' alone isn't enough.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it is possible in a testing context to initiate a scroll while virtualizer is still in the middle of a layout pass, and this can cause unpredictable results. Waiting for layoutComplete avoids this situation, which is another reason why I'd like to lean on it more heavily in our tests.

In my updated version of this test, I added some ad hoc logic to race the layoutComplete promise against a short timeout so that we don't fail with a timeout; instead, we fail because the rendered state of the virtualizer is not correct (which aligns nicely with how you'd observe this failure in manual testing).

});
});