From 89c80b72921c935008352de5e3e7e84d60b9fca2 Mon Sep 17 00:00:00 2001 From: Gray Norton Date: Tue, 22 Aug 2023 15:25:55 -0700 Subject: [PATCH 1/5] Add regression tests for #3904 and #4125 --- .../scenarios/fixed-position-ancestor.test.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 packages/labs/virtualizer/src/test/scenarios/fixed-position-ancestor.test.ts diff --git a/packages/labs/virtualizer/src/test/scenarios/fixed-position-ancestor.test.ts b/packages/labs/virtualizer/src/test/scenarios/fixed-position-ancestor.test.ts new file mode 100644 index 0000000000..0a0507ac33 --- /dev/null +++ b/packages/labs/virtualizer/src/test/scenarios/fixed-position-ancestor.test.ts @@ -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` +
+
+ html`
${name}
`} + >
+
+
+ `); + + 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'); + }); + + // 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` +
+
+ html`

${name}

`} + >
+
+
+ `); + + 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'); + }); +}); From e353f1afd0ed1e6fecaefc1d2b5c72127618c0c6 Mon Sep 17 00:00:00 2001 From: Gray Norton Date: Tue, 22 Aug 2023 15:27:24 -0700 Subject: [PATCH 2/5] Fix #4125; don't ignore position: fixed elements, only their ancestors --- packages/labs/virtualizer/src/Virtualizer.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/labs/virtualizer/src/Virtualizer.ts b/packages/labs/virtualizer/src/Virtualizer.ts index 304d51bed7..f7fb07c87c 100644 --- a/packages/labs/virtualizer/src/Virtualizer.ts +++ b/packages/labs/virtualizer/src/Virtualizer.ts @@ -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'; }); } From 008135b1a416ef9ae5605d2c84d1cdba95d9cbce Mon Sep 17 00:00:00 2001 From: Gray Norton Date: Tue, 22 Aug 2023 15:42:37 -0700 Subject: [PATCH 3/5] Add changeset --- .changeset/giant-spiders-warn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/giant-spiders-warn.md diff --git a/.changeset/giant-spiders-warn.md b/.changeset/giant-spiders-warn.md new file mode 100644 index 0000000000..28568c02b1 --- /dev/null +++ b/.changeset/giant-spiders-warn.md @@ -0,0 +1,5 @@ +--- +'@lit-labs/virtualizer': patch +--- + +Fix bug affecting position: fixed scrollers (#4125) From 9056f4f0e34d70f5903af5ee208bd4caa8cf84aa Mon Sep 17 00:00:00 2001 From: Gray Norton Date: Wed, 23 Aug 2023 16:31:32 -0700 Subject: [PATCH 4/5] Normalize layoutComplete timing, adjust tests --- packages/labs/virtualizer/src/Virtualizer.ts | 20 +++++++++++++++++++ .../scenarios/fixed-position-ancestor.test.ts | 13 ++++++------ .../test/scenarios/masonry-gotchas.test.ts | 2 ++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/labs/virtualizer/src/Virtualizer.ts b/packages/labs/virtualizer/src/Virtualizer.ts index f7fb07c87c..7b7517af7b 100644 --- a/packages/labs/virtualizer/src/Virtualizer.ts +++ b/packages/labs/virtualizer/src/Virtualizer.ts @@ -435,6 +435,18 @@ export class Virtualizer { // If we don't have a constructor yet, load the default DefaultLayoutConstructor = Ctor = (await import('./layouts/flow.js')) .FlowLayout as unknown as LayoutConstructor; + } else { + // If we don't have to dynamically import the default layout, + // we wait an animation frame to make the timing consistent in + // both cases. This is for ease of testing; it means that a + // layoutComplete promise requested immediately after a virtualizer + // is rendered will always resolve after the initial layout pass. + // Without this delay, the first layout pass can complete before + // a layoutComplete promise can be requested. It feels slightly + // bad to insert this delay strictly for testing purposes, but + // the difference in real performance should be negligible and + // the tradeoff seems worthwhile. + await new Promise((resolve) => requestAnimationFrame(resolve)); } this._layout = new Ctor( @@ -799,6 +811,14 @@ export class Virtualizer { * lastVisible. */ private _notifyRange() { + // If we're here, it means our range has changed. If it has changed + // such that no children will be rendered, we should go ahead and + // schedule resolution of the layoutComplete promise now, since the + // ResizeObserver callback we use for this purpose in the case where + // children *are* rendered won't execute. + if (this._first === -1 || this._last === -1) { + this._scheduleLayoutComplete(); + } this._hostElement!.dispatchEvent( new RangeChangedEvent({first: this._first, last: this._last}) ); diff --git a/packages/labs/virtualizer/src/test/scenarios/fixed-position-ancestor.test.ts b/packages/labs/virtualizer/src/test/scenarios/fixed-position-ancestor.test.ts index 0a0507ac33..98ffb09ff8 100644 --- a/packages/labs/virtualizer/src/test/scenarios/fixed-position-ancestor.test.ts +++ b/packages/labs/virtualizer/src/test/scenarios/fixed-position-ancestor.test.ts @@ -63,9 +63,6 @@ describe('Virtualizer behaves properly when it has a position: fixed ancestor', // 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'); }); @@ -109,11 +106,13 @@ describe('Virtualizer behaves properly when it has a position: fixed ancestor', // 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; + // We race layoutComplete against a short timeout here because in the case + // where the virtualizer doesn't re-render, layoutComplete won't resolve. + await Promise.race([ + virtualizer.layoutComplete, + new Promise((resolve) => setTimeout(resolve, 250)), + ]); expect(virtualizer.textContent).to.contain('Item 99'); }); }); diff --git a/packages/labs/virtualizer/src/test/scenarios/masonry-gotchas.test.ts b/packages/labs/virtualizer/src/test/scenarios/masonry-gotchas.test.ts index bff61048dd..c88f7f1767 100644 --- a/packages/labs/virtualizer/src/test/scenarios/masonry-gotchas.test.ts +++ b/packages/labs/virtualizer/src/test/scenarios/masonry-gotchas.test.ts @@ -57,6 +57,7 @@ describe("Size virtualizer properly even if last item placed doesn't extend the ` ); const virtualizer = el.shadowRoot!.querySelector('lit-virtualizer')!; + await virtualizer.layoutComplete; const virtualizerHeight = virtualizer.getBoundingClientRect().height; const firstChildHeight = virtualizer.children[0].getBoundingClientRect().height; @@ -75,6 +76,7 @@ describe("Calculate range properly even if last item placed doesn't extend the f ` ); const virtualizer = el.shadowRoot!.querySelector('lit-virtualizer')!; + await virtualizer.layoutComplete; expect(virtualizer.children.length).to.equal(2); }); }); From 6a7dc7fccc520d5e28fe23bcbbc94a7ce6c67a86 Mon Sep 17 00:00:00 2001 From: Gray Norton Date: Wed, 23 Aug 2023 17:38:25 -0700 Subject: [PATCH 5/5] Revert "Normalize layoutComplete timing, adjust tests" This reverts commit 9056f4f0e34d70f5903af5ee208bd4caa8cf84aa. --- packages/labs/virtualizer/src/Virtualizer.ts | 20 ------------------- .../scenarios/fixed-position-ancestor.test.ts | 13 ++++++------ .../test/scenarios/masonry-gotchas.test.ts | 2 -- 3 files changed, 7 insertions(+), 28 deletions(-) diff --git a/packages/labs/virtualizer/src/Virtualizer.ts b/packages/labs/virtualizer/src/Virtualizer.ts index 7b7517af7b..f7fb07c87c 100644 --- a/packages/labs/virtualizer/src/Virtualizer.ts +++ b/packages/labs/virtualizer/src/Virtualizer.ts @@ -435,18 +435,6 @@ export class Virtualizer { // If we don't have a constructor yet, load the default DefaultLayoutConstructor = Ctor = (await import('./layouts/flow.js')) .FlowLayout as unknown as LayoutConstructor; - } else { - // If we don't have to dynamically import the default layout, - // we wait an animation frame to make the timing consistent in - // both cases. This is for ease of testing; it means that a - // layoutComplete promise requested immediately after a virtualizer - // is rendered will always resolve after the initial layout pass. - // Without this delay, the first layout pass can complete before - // a layoutComplete promise can be requested. It feels slightly - // bad to insert this delay strictly for testing purposes, but - // the difference in real performance should be negligible and - // the tradeoff seems worthwhile. - await new Promise((resolve) => requestAnimationFrame(resolve)); } this._layout = new Ctor( @@ -811,14 +799,6 @@ export class Virtualizer { * lastVisible. */ private _notifyRange() { - // If we're here, it means our range has changed. If it has changed - // such that no children will be rendered, we should go ahead and - // schedule resolution of the layoutComplete promise now, since the - // ResizeObserver callback we use for this purpose in the case where - // children *are* rendered won't execute. - if (this._first === -1 || this._last === -1) { - this._scheduleLayoutComplete(); - } this._hostElement!.dispatchEvent( new RangeChangedEvent({first: this._first, last: this._last}) ); diff --git a/packages/labs/virtualizer/src/test/scenarios/fixed-position-ancestor.test.ts b/packages/labs/virtualizer/src/test/scenarios/fixed-position-ancestor.test.ts index 98ffb09ff8..0a0507ac33 100644 --- a/packages/labs/virtualizer/src/test/scenarios/fixed-position-ancestor.test.ts +++ b/packages/labs/virtualizer/src/test/scenarios/fixed-position-ancestor.test.ts @@ -63,6 +63,9 @@ describe('Virtualizer behaves properly when it has a position: fixed ancestor', // 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'); }); @@ -106,13 +109,11 @@ describe('Virtualizer behaves properly when it has a position: fixed ancestor', // 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); - // We race layoutComplete against a short timeout here because in the case - // where the virtualizer doesn't re-render, layoutComplete won't resolve. - await Promise.race([ - virtualizer.layoutComplete, - new Promise((resolve) => setTimeout(resolve, 250)), - ]); + await virtualizer.layoutComplete; expect(virtualizer.textContent).to.contain('Item 99'); }); }); diff --git a/packages/labs/virtualizer/src/test/scenarios/masonry-gotchas.test.ts b/packages/labs/virtualizer/src/test/scenarios/masonry-gotchas.test.ts index c88f7f1767..bff61048dd 100644 --- a/packages/labs/virtualizer/src/test/scenarios/masonry-gotchas.test.ts +++ b/packages/labs/virtualizer/src/test/scenarios/masonry-gotchas.test.ts @@ -57,7 +57,6 @@ describe("Size virtualizer properly even if last item placed doesn't extend the ` ); const virtualizer = el.shadowRoot!.querySelector('lit-virtualizer')!; - await virtualizer.layoutComplete; const virtualizerHeight = virtualizer.getBoundingClientRect().height; const firstChildHeight = virtualizer.children[0].getBoundingClientRect().height; @@ -76,7 +75,6 @@ describe("Calculate range properly even if last item placed doesn't extend the f ` ); const virtualizer = el.shadowRoot!.querySelector('lit-virtualizer')!; - await virtualizer.layoutComplete; expect(virtualizer.children.length).to.equal(2); }); });