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,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,
};
}