Skip to content
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

fix(viewport-ruler): incorrectly caching viewport size #7951

Merged
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
16 changes: 8 additions & 8 deletions src/cdk/overlay/position/connected-position-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ export class ConnectedPositionStrategy implements PositionStrategy {
const originRect = this._origin.getBoundingClientRect();
const overlayRect = element.getBoundingClientRect();

// We use the viewport rect to determine whether a position would go off-screen.
const viewportRect = this._viewportRuler.getViewportRect();
// We use the viewport size to determine whether a position would go off-screen.
const viewportSize = this._viewportRuler.getViewportSize();

// Fallback point if none of the fallbacks fit into the viewport.
let fallbackPoint: OverlayPoint | undefined;
Expand All @@ -134,7 +134,7 @@ export class ConnectedPositionStrategy implements PositionStrategy {
// Get the (x, y) point of connection on the origin, and then use that to get the
// (top, left) coordinate for the overlay at `pos`.
let originPoint = this._getOriginConnectionPoint(originRect, pos);
let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, viewportRect, pos);
let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, viewportSize, pos);

// If the overlay in the calculated position fits on-screen, put it there and we're done.
if (overlayPoint.fitsInViewport) {
Expand Down Expand Up @@ -168,11 +168,11 @@ export class ConnectedPositionStrategy implements PositionStrategy {

const originRect = this._origin.getBoundingClientRect();
const overlayRect = this._pane.getBoundingClientRect();
const viewportRect = this._viewportRuler.getViewportRect();
const viewportSize = this._viewportRuler.getViewportSize();
const lastPosition = this._lastConnectedPosition || this._preferredPositions[0];

let originPoint = this._getOriginConnectionPoint(originRect, lastPosition);
let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, viewportRect, lastPosition);
let overlayPoint = this._getOverlayPoint(originPoint, overlayRect, viewportSize, lastPosition);
this._setElementPosition(this._pane, overlayRect, overlayPoint, lastPosition);
}

Expand Down Expand Up @@ -280,7 +280,7 @@ export class ConnectedPositionStrategy implements PositionStrategy {
private _getOverlayPoint(
originPoint: Point,
overlayRect: ClientRect,
viewportRect: ClientRect,
viewportSize: {width: number; height: number},
pos: ConnectionPositionPair): OverlayPoint {
// Calculate the (overlayStartX, overlayStartY), the start of the potential overlay position
// relative to the origin point.
Expand Down Expand Up @@ -310,9 +310,9 @@ export class ConnectedPositionStrategy implements PositionStrategy {

// How much the overlay would overflow at this position, on each side.
let leftOverflow = 0 - x;
let rightOverflow = (x + overlayRect.width) - viewportRect.width;
let rightOverflow = (x + overlayRect.width) - viewportSize.width;
let topOverflow = 0 - y;
let bottomOverflow = (y + overlayRect.height) - viewportRect.height;
let bottomOverflow = (y + overlayRect.height) - viewportSize.height;

// Visible parts of the element on each axis.
let visibleWidth = this._subtractOverflows(overlayRect.width, leftOverflow, rightOverflow);
Expand Down
25 changes: 7 additions & 18 deletions src/cdk/overlay/scroll/block-scroll-strategy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,28 +39,28 @@ describe('BlockScrollStrategy', () => {
afterEach(inject([OverlayContainer], (container: OverlayContainer) => {
overlayRef.dispose();
document.body.removeChild(forceScrollElement);
setScrollPosition(0, 0);
window.scroll(0, 0);
container.getContainerElement().parentNode!.removeChild(container.getContainerElement());
}));

it('should toggle scroll blocking along the y axis', skipIOS(() => {
setScrollPosition(0, 100);
window.scroll(0, 100);
expect(viewport.getViewportScrollPosition().top)
.toBe(100, 'Expected viewport to be scrollable initially.');

overlayRef.attach(componentPortal);
expect(document.documentElement.style.top)
.toBe('-100px', 'Expected <html> element to be offset by the previous scroll amount.');

setScrollPosition(0, 300);
window.scroll(0, 300);
expect(viewport.getViewportScrollPosition().top)
.toBe(100, 'Expected the viewport not to scroll.');

overlayRef.detach();
expect(viewport.getViewportScrollPosition().top)
.toBe(100, 'Expected old scroll position to have bee restored after disabling.');

setScrollPosition(0, 300);
window.scroll(0, 300);
expect(viewport.getViewportScrollPosition().top)
.toBe(300, 'Expected user to be able to scroll after disabling.');
}));
Expand All @@ -70,23 +70,23 @@ describe('BlockScrollStrategy', () => {
forceScrollElement.style.height = '100px';
forceScrollElement.style.width = '3000px';

setScrollPosition(100, 0);
window.scroll(100, 0);
expect(viewport.getViewportScrollPosition().left)
.toBe(100, 'Expected viewport to be scrollable initially.');

overlayRef.attach(componentPortal);
expect(document.documentElement.style.left)
.toBe('-100px', 'Expected <html> element to be offset by the previous scroll amount.');

setScrollPosition(300, 0);
window.scroll(300, 0);
expect(viewport.getViewportScrollPosition().left)
.toBe(100, 'Expected the viewport not to scroll.');

overlayRef.detach();
expect(viewport.getViewportScrollPosition().left)
.toBe(100, 'Expected old scroll position to have bee restored after disabling.');

setScrollPosition(300, 0);
window.scroll(300, 0);
expect(viewport.getViewportScrollPosition().left)
.toBe(300, 'Expected user to be able to scroll after disabling.');
}));
Expand Down Expand Up @@ -142,7 +142,6 @@ describe('BlockScrollStrategy', () => {
* tests unusable. For example, something as basic as the following won't work:
* ```
* window.scroll(0, 100);
* viewport._cacheViewportGeometry();
* expect(viewport.getViewportScrollPosition().top).toBe(100);
* ```
* @param spec Test to be executed or skipped.
Expand All @@ -155,16 +154,6 @@ describe('BlockScrollStrategy', () => {
};
}

/**
* Scrolls the viewport and clears the cache.
* @param x Amount to scroll along the x axis.
* @param y Amount to scroll along the y axis.
*/
function setScrollPosition(x: number, y: number) {
window.scroll(x, y);
viewport._cacheViewportGeometry();
}

});


Expand Down
2 changes: 1 addition & 1 deletion src/cdk/overlay/scroll/block-scroll-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class BlockScrollStrategy implements ScrollStrategy {
}

const body = document.body;
const viewport = this._viewportRuler.getViewportRect();
const viewport = this._viewportRuler.getViewportSize();
return body.scrollHeight > viewport.height || body.scrollWidth > viewport.width;
}
}
10 changes: 6 additions & 4 deletions src/cdk/scrolling/viewport-ruler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ describe('ViewportRuler', () => {
ruler.ngOnDestroy();
});

it('should get the viewport size', () => {
let size = ruler.getViewportSize();
expect(size.width).toBe(window.innerWidth);
expect(size.height).toBe(window.innerHeight);
});

it('should get the viewport bounds when the page is not scrolled', () => {
let bounds = ruler.getViewportRect();
expect(bounds.top).toBe(0);
Expand All @@ -49,8 +55,6 @@ describe('ViewportRuler', () => {
document.body.appendChild(veryLargeElement);

scrollTo(1500, 2000);
// Force an update of the cached viewport geometries because IE11 emits the scroll event later.
ruler._cacheViewportGeometry();

let bounds = ruler.getViewportRect();

Expand Down Expand Up @@ -87,8 +91,6 @@ describe('ViewportRuler', () => {
document.body.appendChild(veryLargeElement);

scrollTo(1500, 2000);
// Force an update of the cached viewport geometries because IE11 emits the scroll event later.
ruler._cacheViewportGeometry();

// In the iOS simulator (BrowserStack & SauceLabs), adding the content to the
// body causes karma's iframe for the test to stretch to fit that content once we attempt to
Expand Down
52 changes: 23 additions & 29 deletions src/cdk/scrolling/viewport-ruler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@ export const DEFAULT_RESIZE_TIME = 20;
*/
@Injectable()
export class ViewportRuler implements OnDestroy {

/** Cached document client rectangle. */
private _documentRect?: ClientRect;
/** Cached viewport dimensions. */
private _viewportSize: {width: number; height: number};
Copy link
Member

Choose a reason for hiding this comment

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

Does reading the window width / height actually cost anything? Do we need to cache it at all?

Copy link
Member Author

Choose a reason for hiding this comment

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


/** Stream of viewport change events. */
private _change: Observable<Event>;
Expand All @@ -39,21 +38,24 @@ export class ViewportRuler implements OnDestroy {
return merge<Event>(fromEvent(window, 'resize'), fromEvent(window, 'orientationchange'));
}) : observableOf();

this._invalidateCache = this.change().subscribe(() => this._cacheViewportGeometry());
this._invalidateCache = this.change().subscribe(() => this._updateViewportSize());
}

ngOnDestroy() {
this._invalidateCache.unsubscribe();
}

/** Gets a ClientRect for the viewport's bounds. */
getViewportRect(documentRect: ClientRect | undefined = this._documentRect): ClientRect {
// Cache the document bounding rect so that we don't recompute it for multiple calls.
if (!documentRect) {
this._cacheViewportGeometry();
documentRect = this._documentRect;
/** Returns the viewport's width and height. */
getViewportSize(): Readonly<{width: number, height: number}> {
if (!this._viewportSize) {
this._updateViewportSize();
}

return {width: this._viewportSize.width, height: this._viewportSize.height};
}

/** Gets a ClientRect for the viewport's bounds. */
getViewportRect(): ClientRect {
// Use the document element's bounding rect rather than the window scroll properties
// (e.g. pageYOffset, scrollY) due to in issue in Chrome and IE where window scroll
// properties and client coordinates (boundingClientRect, clientX/Y, etc.) are in different
Expand All @@ -63,9 +65,8 @@ export class ViewportRuler implements OnDestroy {
// We use the documentElement instead of the body because, by default (without a css reset)
// browsers typically give the document body an 8px margin, which is not included in
// getBoundingClientRect().
const scrollPosition = this.getViewportScrollPosition(documentRect);
const height = window.innerHeight;
const width = window.innerWidth;
const scrollPosition = this.getViewportScrollPosition();
const {width, height} = this.getViewportSize();

return {
top: scrollPosition.top,
Expand All @@ -77,27 +78,20 @@ export class ViewportRuler implements OnDestroy {
};
}

/**
* Gets the (top, left) scroll position of the viewport.
* @param documentRect
*/
getViewportScrollPosition(documentRect: ClientRect | undefined = this._documentRect) {
// Cache the document bounding rect so that we don't recompute it for multiple calls.
if (!documentRect) {
this._cacheViewportGeometry();
documentRect = this._documentRect;
}

/** Gets the (top, left) scroll position of the viewport. */
getViewportScrollPosition() {
// The top-left-corner of the viewport is determined by the scroll position of the document
// body, normally just (scrollLeft, scrollTop). However, Chrome and Firefox disagree about
// whether `document.body` or `document.documentElement` is the scrolled element, so reading
// `scrollTop` and `scrollLeft` is inconsistent. However, using the bounding rect of
// `document.documentElement` works consistently, where the `top` and `left` values will
// equal negative the scroll position.
const top = -documentRect!.top || document.body.scrollTop || window.scrollY ||
const documentRect = document.documentElement.getBoundingClientRect();
Copy link
Member

Choose a reason for hiding this comment

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

Not for this PR, but thinking about this again, I wonder if there's a better way to get what we're after here than calling getBoundingClientRect here.

Copy link
Member Author

Choose a reason for hiding this comment

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

AFAIK the cross-browser way to do it was something like this: window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0.


const top = -documentRect.top || document.body.scrollTop || window.scrollY ||
document.documentElement.scrollTop || 0;

const left = -documentRect!.left || document.body.scrollLeft || window.scrollX ||
const left = -documentRect.left || document.body.scrollLeft || window.scrollX ||
document.documentElement.scrollLeft || 0;

return {top, left};
Expand All @@ -111,9 +105,9 @@ export class ViewportRuler implements OnDestroy {
return throttleTime > 0 ? auditTime.call(this._change, throttleTime) : this._change;
}

/** Caches the latest client rectangle of the document element. */
_cacheViewportGeometry() {
this._documentRect = document.documentElement.getBoundingClientRect();
/** Updates the cached viewport size. */
private _updateViewportSize() {
this._viewportSize = {width: window.innerWidth, height: window.innerHeight};
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/cdk/testing/fake-viewport-ruler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export class FakeViewportRuler {
};
}

getViewportSize() {
return {width: 1014, height: 686};
}

getViewportScrollPosition() {
return {top: 0, left: 0};
}
Expand Down
8 changes: 4 additions & 4 deletions src/lib/select/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -987,7 +987,7 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
*/
private _calculateOverlayOffsetX(): void {
const overlayRect = this.overlayDir.overlayRef.overlayElement.getBoundingClientRect();
const viewportRect = this._viewportRuler.getViewportRect();
const viewportSize = this._viewportRuler.getViewportSize();
const isRtl = this._isRtl();
const paddingWidth = this.multiple ? SELECT_MULTIPLE_PANEL_PADDING_X + SELECT_PANEL_PADDING_X :
SELECT_PANEL_PADDING_X * 2;
Expand All @@ -1008,7 +1008,7 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,

// Determine how much the select overflows on each side.
const leftOverflow = 0 - (overlayRect.left + offsetX - (isRtl ? paddingWidth : 0));
const rightOverflow = overlayRect.right + offsetX - viewportRect.width
const rightOverflow = overlayRect.right + offsetX - viewportSize.width
+ (isRtl ? 0 : paddingWidth);

// If the element overflows on either side, reduce the offset to allow it to fit.
Expand Down Expand Up @@ -1073,11 +1073,11 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
*/
private _checkOverlayWithinViewport(maxScroll: number): void {
const itemHeight = this._getItemHeight();
const viewportRect = this._viewportRuler.getViewportRect();
const viewportSize = this._viewportRuler.getViewportSize();

const topSpaceAvailable = this._triggerRect.top - SELECT_PANEL_VIEWPORT_PADDING;
const bottomSpaceAvailable =
viewportRect.height - this._triggerRect.bottom - SELECT_PANEL_VIEWPORT_PADDING;
viewportSize.height - this._triggerRect.bottom - SELECT_PANEL_VIEWPORT_PADDING;

const panelHeightTop = Math.abs(this._offsetY);
const totalPanelHeight =
Expand Down