Skip to content

Commit

Permalink
Fix jumpy card list UI on medium and large screens
Browse files Browse the repository at this point in the history
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:

- Fix CI/CD cannot upload artifacts when E2E tests fail.
- Fix uploded artifacts being merged for different operating systems by
  adding the name of the used operating system in uploaded artifact
  file.
  • Loading branch information
undergroundwires committed Nov 16, 2023
1 parent 3864f04 commit 22af57e
Show file tree
Hide file tree
Showing 7 changed files with 234 additions and 63 deletions.
9 changes: 5 additions & 4 deletions .github/workflows/tests.e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
-
name: Output artifact directories
id: artifacts
if: always() # Run even if previous steps fail because test run video is always captured
shell: bash
run: |-
declare -r dirs_json_file='cypress-dirs.json'
Expand All @@ -49,15 +50,15 @@ jobs:
echo "VIDEOS_DIR=${VIDEOS_DIR}" >> "${GITHUB_OUTPUT}"
-
name: Upload screenshots
if: failure() # Run only if previous step fails because screenshots will be generated only if E2E test failed
if: failure() # Run only if previous steps fail because screenshots will be generated only if E2E test failed
uses: actions/upload-artifact@v3
with:
name: e2e-screenshots
name: e2e-screenshots-${{ matrix.os }}
path: ${{ steps.artifacts.outputs.SCREENSHOTS_DIR }}
-
name: Upload videos
if: always() # Run even if previous step fails because test run video is always captured
if: always() # Run even if previous steps fail because test run video is always captured
uses: actions/upload-artifact@v3
with:
name: e2e-videos
name: e2e-videos-${{ matrix.os }}
path: ${{ steps.artifacts.outputs.VIDEOS_DIR }}
67 changes: 41 additions & 26 deletions src/presentation/assets/styles/_mixins.scss
Original file line number Diff line number Diff line change
@@ -1,52 +1,67 @@
@mixin hover-or-touch($selector-suffix: '', $selector-prefix: '&') {
@media (hover: hover) {
/* We only do this if hover is truly supported; otherwise the emulator in mobile
@media (hover: hover) {

/* We only do this if hover is truly supported; otherwise the emulator in mobile
keeps hovered style in-place even after touching, making it sticky. */
#{$selector-prefix}:hover #{$selector-suffix} {
@content;
}
#{$selector-prefix}:hover #{$selector-suffix} {
@content;
}
@media (hover: none) {
/* We only do this if hover is not supported,otherwise the desktop behavior is not
}

@media (hover: none) {

/* We only do this if hover is not supported,otherwise the desktop behavior is not
as desired; it does not get activated on hover but only during click/touch. */
#{$selector-prefix}:active #{$selector-suffix} {
@content;
}
#{$selector-prefix}:active #{$selector-suffix} {
@content;
}
}
}

@mixin clickable($cursor: 'pointer') {
cursor: #{$cursor};
user-select: none;
/*
cursor: #{$cursor};
user-select: none;
/*
It removes (blue) background during touch as seen in mobile webkit browsers (Chrome, Safari, Edge).
The default behavior is that any element (or containing element) that has cursor:pointer
explicitly set and is clicked will flash blue momentarily.
Removing it could have accessibility issue since that hides an interactive cue. But as we still provide
response to user actions through :active by `hover-or-touch` mixin.
*/
-webkit-tap-highlight-color: transparent;
-webkit-tap-highlight-color: transparent;
}

@mixin fade-transition($name) {
.#{$name}-enter-active,
.#{$name}-leave-active {
transition: opacity 0.3s ease;
}

.#{$name}-enter-from,
.#{$name}-leave-to {
opacity: 0;
}
}

@mixin fade-slide-transition($name, $duration, $offset-upward: null) {
.#{$name}-enter-active,
.#{$name}-leave-active {
transition: all $duration;
}

.#{$name}-leave-active,
.#{$name}-enter-from
{
opacity: 0;
.#{$name}-enter-active,
.#{$name}-leave-active {
transition: all $duration;
}

@if $offset-upward {
transform: translateY($offset-upward);
}
.#{$name}-leave-active,
.#{$name}-enter-from {
opacity: 0;

@if $offset-upward {
transform: translateY($offset-upward);
}
}
}

@mixin reset-ul {
margin: 0;
padding: 0;
list-style: none;
}
}
65 changes: 35 additions & 30 deletions src/presentation/components/Scripts/View/Cards/CardList.vue
Original file line number Diff line number Diff line change
@@ -1,34 +1,36 @@
<template>
<SizeObserver v-on:widthChanged="width = $event">
<!--
<div id="responsivity-debug">
Width: {{ width || 'undefined' }}
Size:
<span v-if="width <= 500">small</span>
<span v-if="width > 500 && width < 750">medium</span>
<span v-if="width >= 750">big</span>
<SizeObserver v-on:widthChanged="width = $event" ref="containerElement">
<transition name="fade-transition">
<div v-if="width">
<!-- <div id="responsivity-debug">
Width: {{ width || 'undefined' }}
Size:
<span v-if="width <= 500">small</span>
<span v-if="width > 500 && width < 750">medium</span>
<span v-if="width >= 750">big</span>
</div> -->
<div
v-if="categoryIds.length > 0"
class="cards"
>
<CardListItem
class="card"
v-bind:class="{
'small-screen': width <= 500,
'medium-screen': width > 500 && width < 750,
'big-screen': width >= 750,
}"
v-for="categoryId of categoryIds"
:data-category="categoryId"
v-bind:key="categoryId"
:categoryId="categoryId"
:activeCategoryId="activeCategoryId"
v-on:cardExpansionChanged="onSelected(categoryId, $event)"
/>
</div>
<div v-else class="error">Something went bad 😢</div>
</div>
-->
<div
v-if="categoryIds.length > 0"
class="cards"
>
<CardListItem
class="card"
v-bind:class="{
'small-screen': width <= 500,
'medium-screen': width > 500 && width < 750,
'big-screen': width >= 750,
}"
v-for="categoryId of categoryIds"
:data-category="categoryId"
v-bind:key="categoryId"
:categoryId="categoryId"
:activeCategoryId="activeCategoryId"
v-on:cardExpansionChanged="onSelected(categoryId, $event)"
/>
</div>
<div v-else class="error">Something went bad 😢</div>
</transition>
</SizeObserver>
</template>

Expand All @@ -49,7 +51,8 @@ export default defineComponent({
setup() {
const { currentState, onStateChange } = injectKey((keys) => keys.useCollectionState);
const width = ref<number>(0);
const width = ref<number | undefined>();
const categoryIds = computed<readonly number[]>(
() => currentState.value.collection.actions.map((category) => category.id),
);
Expand Down Expand Up @@ -138,4 +141,6 @@ function isClickable(element: Element) {
font-size: 3.5em;
font-family: $font-normal;
}
@include fade-transition('fade-transition');
</style>
6 changes: 4 additions & 2 deletions src/presentation/components/Shared/SizeObserver.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
defineComponent, shallowRef, onMounted, onBeforeUnmount, watch,
} from 'vue';
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill';
import { throttle } from '@/presentation/components/Shared/Throttle';
export default defineComponent({
emits: {
Expand All @@ -34,10 +35,11 @@ export default defineComponent({
return;
}
resizeObserverReady.then(() => {
observer = new ResizeObserver(updateSize);
disposeObserver();
observer = new ResizeObserver(throttle(updateSize, 200));
observer.observe(element);
});
updateSize();
updateSize(); // Do not throttle, immediately inform new width
}, { immediate: true });
});
Expand Down
143 changes: 143 additions & 0 deletions tests/e2e/card-list-layout-stability-on-load.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { waitForHeaderBrandTitle } from './shared/ApplicationLoad';

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 }, // regression bug
];
testScenarios.forEach(({ name, width, height }) => {
it(`ensures layout stability on ${name}`, () => {
// arrange
cy.viewport(width, height);
const dimensions = new Array<SizeDimensions>();
const addDimension = (newDimension: SizeDimensions) => {
if (dimensions.length > 0) {
const lastDimension = dimensions[dimensions.length - 1];
if (lastDimension.width === newDimension.width
&& lastDimension.height === newDimension.height) {
return;
}
}
dimensions.push(newDimension);
};
const capturer = new ContinuousSizeMonitor();

// act
cy.window().then((win) => {
// We need to start capturing size changes before visiting the page (loading) as this
// layout shift/jump happens rapidly and will not be visible once the page is loaded.
capturer.startCapturing(win, (metric) => {
addDimension(metric);
});
});
cy.visit('/');
capturer.stopCapturing();
const captureMetrics = () => cy.window().then(
(win) => {
const cardList = findCardList(win);
if (cardList) {
addDimension(captureDimensions(cardList));
}
},
);
captureMetrics();
for (const waitUntilNextCheckpoint of Object.values(checkpoints)) {
waitUntilNextCheckpoint();
captureMetrics();
}

// assert
cy.document().then(() => {
const widthTolerance = 0;
const widths = new Set(dimensions.map((d) => d.width));
expect(isWithinTolerance(widths, widthTolerance)).to.equal(true, [
`Unique width values over time: ${[...widths].join(', ')}`,
`Height changes are more than ${widthTolerance}px tolerance`,
`Captured metrics: ${JSON.stringify(dimensions)}`,
].join('\n\n'));

const heightTolerance = 100;
const heights = new Set(dimensions.map((d) => d.height));
expect(isWithinTolerance(heights, heightTolerance)).to.equal(true, [
`Unique height values over time: ${[...heights].join(', ')}`,
`Height changes are more than ${heightTolerance}px tolerance`,
`Captured metrics: ${JSON.stringify(dimensions)}`,
].join('\n\n'));
});
});
});
});
});

function findCardList(win: Cypress.AUTWindow): Element | undefined {
return win.document.querySelector('.cards') || undefined;
}

class ContinuousSizeMonitor {
private capturer: ReturnType<typeof setTimeout> | undefined;

public startCapturing(win: Cypress.AUTWindow, callback: (metric: SizeDimensions) => void) {
this.capturer = setInterval(() => {
const cardList = findCardList(win);
if (cardList) {
const metrics = captureDimensions(cardList);
callback(metrics);
}
}, 5);
}

public stopCapturing() {
clearInterval(this.capturer);
}
}

function isWithinTolerance(
numbers: Iterable<number>,
tolerance: number,
) {
let changeWithinTolerance = true;
const values = [...numbers];
const [firstValue, ...otherValues] = values;
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,
CardListLoaded = 3,
}

const checkpoints: Record<ApplicationLoadStep, () => void> = {
[ApplicationLoadStep.IndexHtmlLoaded]: () => cy.get('#app'),
[ApplicationLoadStep.AppVueLoaded]: () => cy.get('.app__wrapper').should('be.visible'),
[ApplicationLoadStep.HeaderBrandTitleLoaded]: () => waitForHeaderBrandTitle(),
[ApplicationLoadStep.CardListLoaded]: () => cy.get('.cards').should('be.visible'),
};
4 changes: 3 additions & 1 deletion tests/e2e/initialization.cy.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions tests/e2e/shared/ApplicationLoad.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function waitForHeaderBrandTitle() {
cy.contains('h1', 'privacy.sexy');
}

0 comments on commit 22af57e

Please sign in to comment.