Fix card list UI layout shifts (jumps) on load

This commit fixes layout shifts that occur on card list part of the page
when the page is initially loaded.

- Resolve issue where card list starts with minimal width, leading
  to jumps in UI until correct width is calculated on medium and
  big screens.
- Dispose of existing `ResizeObserver` properly before creating a new
  one. This prevents leaks and incorrect width calculations if
  `containerElement` changes.
- Throttle resize events to minimize width/height calculation changes,
  enhancing performance and reducing the chances for layout shifts.

Supporting CI/CD improvements:

- Enable artifact upload in CI/CD even if E2E tests fail.
- Distinguish uploaded artifacts by operating system for clarity.
This commit is contained in:
undergroundwires
2023-11-16 16:06:33 +01:00
parent 3864f04218
commit bf3426f91b
12 changed files with 425 additions and 63 deletions

View File

@@ -0,0 +1,231 @@
// eslint-disable-next-line max-classes-per-file
import { waitForHeaderBrandTitle } from './shared/ApplicationLoad';
interface Stoppable {
stop(): void;
}
describe('card list layout stability', () => {
describe('during initial page load', () => {
const testScenarios: ReadonlyArray<{
readonly name: string;
readonly width: number;
readonly height: number;
}> = [
{ name: 'iPhone SE', width: 375, height: 667 },
{ name: '13-inch Laptop', width: 1280, height: 800 },
{ name: '4K Ultra HD Desktop', width: 3840, height: 2160 },
];
const testCleanup = new Array<Stoppable>();
afterEach(() => {
testCleanup.forEach((c) => c.stop());
testCleanup.length = 0;
});
testScenarios.forEach(({ name, width, height }) => {
it(`ensures layout stability on ${name}`, () => {
// arrange
const dimensions = new DimensionsStorage();
cy.viewport(width, height);
// act
cy.window().then((win) => {
findElementFast(win, '.cards', (cardList) => {
testCleanup.push(
new SizeMonitor().start(cardList, () => dimensions.add(captureDimensions(cardList))),
);
});
testCleanup.push(
new ContinuousRunner()
.start(() => {
/*
As Cypress does not inherently support CPU throttling, this workaround is used to
intentionally slow down Cypress's execution. It allows capturing sudden layout
issues, such as brief flashes or shifts.
*/
cy.window().then(() => {
cy.log('Throttling');
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(50, { log: false });
});
}, 100),
);
});
cy.visit('/');
for (const assertNextCheckpoint of Object.values(checkpoints)) {
assertNextCheckpoint();
}
// assert
const widthToleranceInPx = 0;
const widthsInPx = dimensions.getUniqueWidths();
expect(isWithinTolerance(widthsInPx, widthToleranceInPx)).to.equal(true, [
`Unique width values over time: ${[...widthsInPx].join(', ')}`,
`Height changes are more than ${widthToleranceInPx}px tolerance`,
`Captured metrics: ${dimensions.toString()}`,
].join('\n\n'));
const heightToleranceInPx = 100; // Set in relation to card sizes.
// Tolerance allows for minor layout shifts without (e.g. for icon or font loading)
// false test failures. The number `100` accounts for shifts when the number of
// cards per row changes, avoiding failures for shifts less than the smallest card
// size (~175px).
const heightsInPx = dimensions.getUniqueHeights();
expect(isWithinTolerance(heightsInPx, heightToleranceInPx)).to.equal(true, [
`Unique height values over time: ${[...heightsInPx].join(', ')}`,
`Height changes are more than ${heightToleranceInPx}px tolerance`,
`Captured metrics: ${dimensions.toString()}`,
].join('\n\n'));
});
});
});
});
/*
It finds a DOM element as quickly as possible.
It's crucial for detecting early layout shifts during page load,
which may be missed by standard Cypress commands such as `cy.get`, `cy.document`.
*/
function findElementFast(
win: Cypress.AUTWindow,
query: string,
handler: (element: Element) => void,
timeoutInMs = 5000,
): void {
const endTime = Date.now() + timeoutInMs;
const finder = new ContinuousRunner();
finder.start(() => {
const element = win.document.querySelector(query);
if (element) {
handler(element);
finder.stop();
return;
}
if (Date.now() >= endTime) {
finder.stop();
throw new Error(`Timed out. Failed to find element. Query: ${query}. Timeout: ${timeoutInMs}ms`);
}
}, 1 /* As aggressive as possible */);
}
class DimensionsStorage {
private readonly dimensions = new Array<SizeDimensions>();
public add(newDimension: SizeDimensions): void {
if (this.dimensions.length > 0) {
const lastDimension = this.dimensions[this.dimensions.length - 1];
if (lastDimension.width === newDimension.width
&& lastDimension.height === newDimension.height) {
return;
}
}
cy.window().then(() => {
cy.log(`Captured: ${JSON.stringify(newDimension)}`);
});
this.dimensions.push(newDimension);
}
public getUniqueWidths(): readonly number[] {
return [...new Set(this.dimensions.map((d) => d.width))];
}
public getUniqueHeights(): readonly number[] {
return [...new Set(this.dimensions.map((d) => d.height))];
}
public toString(): string {
return JSON.stringify(this.dimensions);
}
}
function isWithinTolerance(
numbers: readonly number[],
tolerance: number,
) {
let changeWithinTolerance = true;
const [firstValue, ...otherValues] = numbers;
let previousValue = firstValue;
otherValues.forEach((value) => {
const difference = Math.abs(value - previousValue);
if (difference > tolerance) {
changeWithinTolerance = false;
}
previousValue = value;
});
return changeWithinTolerance;
}
interface SizeDimensions {
readonly width: number;
readonly height: number;
}
function captureDimensions(element: Element): SizeDimensions {
const dimensions = element.getBoundingClientRect(); // more reliable than body.scroll...
return {
width: Math.round(dimensions.width),
height: Math.round(dimensions.height),
};
}
enum ApplicationLoadStep {
IndexHtmlLoaded = 0,
AppVueLoaded = 1,
HeaderBrandTitleLoaded = 2,
}
const checkpoints: Record<ApplicationLoadStep, () => void> = {
[ApplicationLoadStep.IndexHtmlLoaded]: () => cy.get('#app').should('be.visible'),
[ApplicationLoadStep.AppVueLoaded]: () => cy.get('.app__wrapper').should('be.visible'),
[ApplicationLoadStep.HeaderBrandTitleLoaded]: () => waitForHeaderBrandTitle(),
};
class ContinuousRunner implements Stoppable {
private timer: ReturnType<typeof setTimeout> | undefined;
public start(callback: () => void, intervalInMs: number): this {
this.stop();
this.timer = setInterval(() => {
if (this.isStopped) {
return;
}
callback();
}, intervalInMs);
return this;
}
public stop() {
if (this.timer === undefined) {
return;
}
clearInterval(this.timer);
this.timer = undefined;
}
private get isStopped() {
return this.timer === undefined;
}
}
class SizeMonitor implements Stoppable {
private observer: ResizeObserver | undefined;
public start(element: Element, sizeChangedCallback: () => void): this {
this.stop();
this.observer = new ResizeObserver(() => {
if (this.isStopped) {
return;
}
sizeChangedCallback();
});
this.observer.observe(element);
return this;
}
public stop() {
this.observer?.disconnect();
this.observer = undefined;
}
private get isStopped() {
return this.observer === undefined;
}
}

View File

@@ -1,9 +1,11 @@
import { waitForHeaderBrandTitle } from './shared/ApplicationLoad';
describe('application is initialized as expected', () => {
it('loads title as expected', () => {
// act
cy.visit('/');
// assert
cy.contains('h1', 'privacy.sexy');
waitForHeaderBrandTitle();
});
it('there are no console.error output', () => {
// act

View File

@@ -0,0 +1,3 @@
export function waitForHeaderBrandTitle() {
cy.contains('h1', 'privacy.sexy');
}

View File

@@ -0,0 +1,63 @@
import { shallowMount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import { Ref, nextTick, ref } from 'vue';
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { createSizeObserverStub } from '@tests/unit/shared/Stubs/SizeObserverStub';
const DOM_SELECTOR_CARDS = '.cards';
describe('CardList.vue', () => {
describe('rendering cards based on width', () => {
it('renders cards when a valid width is provided', async () => {
// arrange
const expectedCardsExistence = true;
const width = ref(0);
// act
const wrapper = mountComponent({
widthRef: width,
});
width.value = 800;
await nextTick();
// assert
const actual = wrapper.find(DOM_SELECTOR_CARDS).exists();
expect(actual).to.equal(expectedCardsExistence, wrapper.html());
});
it('does not render cards when width is not set', async () => {
// arrange
const expectedCardsExistence = false;
const width = ref(0);
const wrapper = mountComponent({
widthRef: width,
});
// act
await nextTick();
// assert
const actual = wrapper.find(DOM_SELECTOR_CARDS).exists();
expect(actual).to.equal(expectedCardsExistence, wrapper.html());
});
});
});
function mountComponent(options?: {
readonly useCollectionState?: ReturnType<typeof useCollectionState>,
readonly widthRef?: Readonly<Ref<number>>,
}) {
const {
name: sizeObserverName,
component: sizeObserverStub,
} = createSizeObserverStub(options?.widthRef);
return shallowMount(CardList, {
global: {
provide: {
[InjectionKeys.useCollectionState.key]:
() => options?.useCollectionState ?? new UseCollectionStateStub().get(),
},
stubs: {
[sizeObserverName]: sizeObserverStub,
},
},
});
}

View File

@@ -22,7 +22,7 @@ export class CategoryCollectionStateStub implements ICategoryCollectionState {
return this.collection.os;
}
public collection: ICategoryCollection = new CategoryCollectionStub();
public collection: ICategoryCollection = new CategoryCollectionStub().withSomeActions();
public selection: IUserSelection = new UserSelectionStub([]);

View File

@@ -21,6 +21,13 @@ export class CategoryCollectionStub implements ICategoryCollection {
public readonly actions = new Array<ICategory>();
public withSomeActions(): this {
this.withAction(new CategoryStub(1));
this.withAction(new CategoryStub(2));
this.withAction(new CategoryStub(3));
return this;
}
public withAction(category: ICategory): this {
this.actions.push(category);
return this;

View File

@@ -0,0 +1,27 @@
import { defineComponent, ref, watch } from 'vue';
import type { Ref } from 'vue';
const COMPONENT_SIZE_OBSERVER_NAME = 'SizeObserver';
export function createSizeObserverStub(
widthRef: Readonly<Ref<number>> = ref(500),
) {
const component = defineComponent({
name: COMPONENT_SIZE_OBSERVER_NAME,
template: `<div id="${COMPONENT_SIZE_OBSERVER_NAME}-stub"><slot /></div>`,
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
widthChanged: (newWidth: number) => true,
/* eslint-enable @typescript-eslint/no-unused-vars */
},
setup: (_, { emit }) => {
watch(widthRef, (newValue) => {
emit('widthChanged', newValue);
});
},
});
return {
name: COMPONENT_SIZE_OBSERVER_NAME,
component,
};
}