Skip to content

Commit

Permalink
Added performance tab to the Preflight tool (#2773) (#3056)
Browse files Browse the repository at this point in the history
* Added performance tab to the Preflight tool (#2773)

added performance tab

* Fixes upon testing

* Address Rares PR Feedback

* Address Narcis PR feedback

* Address Roberts PR feedback

---------

Co-authored-by: Robert Bogos <[email protected]>
  • Loading branch information
mokimo and robert-bogos authored Oct 29, 2024
1 parent 5f07d64 commit fded71e
Show file tree
Hide file tree
Showing 9 changed files with 636 additions and 15 deletions.
265 changes: 265 additions & 0 deletions libs/blocks/preflight/panels/performance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import { html, signal, useEffect } from '../../../deps/htm-preact.js';
import { getMetadata } from '../../../utils/utils.js';

const icons = {
pass: 'green',
fail: 'red',
empty: 'empty',
};
// TODO MEP/Personalization
// TODO mobile / tablet?
// TODO create ticket for PSI API
// TODO link to documentation directly within the sections
const text = {
lcpEl: {
key: 'lcpEl',
title: 'Valid LCP',
passed: { description: 'Valid LCP in the first section detected.' },
failed: { description: 'No LCP image or video in the first section detected.' },
},
singleBlock: {
key: 'singleBlock',
title: 'Single Block',
passed: { description: 'First section has exactly one block.' },
failed: { description: 'First section has more than one block.' },
},
imageSize: {
key: 'imageSize',
title: 'Images size',
empty: { description: 'No image as LCP element.' },
passed: { description: 'LCP image is less than 100KB.' },
failed: { description: 'LCP image is over 100KB.' },
},
videoPoster: {
key: 'videoPoster',
title: 'Videos',
empty: { description: 'No video as LCP element.' },
passed: { description: 'LCP video has a poster attribute' },
failed: { description: 'LCP video has no poster attribute.' },
},
fragments: {
key: 'fragments',
title: 'Fragments',
passed: { description: 'No fragments used within the LCP section.' },
failed: { description: 'Fragments used within the LCP section.' },
},
personalization: {
key: 'personalization',
title: 'Personalization',
passed: { description: 'Personalization is currently not enabled.' },
failed: { description: 'MEP or Target enabled.' },
},
placeholders: {
key: 'placeholders',
title: 'Placeholders',
passed: { description: 'No placeholders found within the LCP section.' },
failed: { description: 'Placeholders found within the LCP section.' },
},
icons: {
key: 'icons',
title: 'Icons',
passed: { description: 'No icons found within the LCP section.' },
failed: { description: 'Icons found within the LCP section.' },
},
};

export const config = {
items: signal([
...Object.values(text).map(({ key, title }) => ({
key,
title,
icon: icons.empty,
description: 'Loading...',
})),
]),
lcp: null,
cls: 0,
};

export const findItem = (key) => config.items.value.find((item) => item.key === key);
export const updateItem = ({ key, ...updates }) => {
const { items } = config;
items.value = items.value.map((item) => (item.key === key ? { ...item, ...updates } : item));
};

export function conditionalItemUpdate({ emptyWhen, failsWhen, key }) {
const icon = (emptyWhen && icons.empty) || (failsWhen && icons.fail) || icons.pass;
const descriptionKey = (emptyWhen && 'empty') || (failsWhen && 'failed') || 'passed';
updateItem({
key,
icon,
description: text[key][descriptionKey].description,
});
}

// TODO do we also want to check the content-length header?
// https://www.w3.org/TR/largest-contentful-paint/#largest-contentful-paint-candidate-element
// candidate’s element is a text node, or candidate’s request's response's content length
// in bytes is >= candidate’s element's effective visual size * 0.004
export async function checkImageSize() {
const { lcp } = config;
let hasValidImage = lcp?.url && !lcp.url.match('media_.*.mp4');
let blob;
let isSizeValid;
if (hasValidImage) {
try {
blob = await fetch(lcp.url).then((res) => res.blob());
isSizeValid = blob.size / 1024 <= 100;
} catch (error) {
hasValidImage = false;
}

Check warning on line 110 in libs/blocks/preflight/panels/performance.js

View check run for this annotation

Codecov / codecov/patch

libs/blocks/preflight/panels/performance.js#L109-L110

Added lines #L109 - L110 were not covered by tests
}

conditionalItemUpdate({
failsWhen: !isSizeValid,
emptyWhen: !hasValidImage,
key: text.imageSize.key,
});
}

export function checkLCP() {
const { lcp } = config;
const firstSection = document.querySelector('main > div.section');
const validLcp = lcp?.element && lcp?.url && firstSection?.contains(lcp.element);
conditionalItemUpdate({
failsWhen: !validLcp,
key: text.lcpEl.key,
description: text.lcpEl.passed.description,
});
return validLcp;
}

export const checkFragments = () => conditionalItemUpdate({
failsWhen: config.lcp.element.closest('.fragment') || config.lcp.element.closest('.section')?.querySelector('[data-path*="fragment"]'),
key: text.fragments.key,
});

export const checkPlaceholders = () => conditionalItemUpdate({
failsWhen: config.lcp.element.closest('.section').dataset.hasPlaceholders === 'true',
key: text.placeholders.key,
});

export const checkForPersonalization = () => conditionalItemUpdate({
failsWhen: getMetadata('personalization') || getMetadata('target') === 'on',
key: text.personalization.key,
});

export const checkVideosWithoutPosterAttribute = () => conditionalItemUpdate({
failsWhen: !config.lcp.element.poster,
emptyWhen: !config.lcp.url.match('media_.*.mp4'),
key: text.videoPoster.key,
});

export const checkIcons = () => conditionalItemUpdate({
failsWhen: config.lcp.element.closest('.section').querySelector('.icon-milo'),
key: text.icons.key,

Check warning on line 155 in libs/blocks/preflight/panels/performance.js

View check run for this annotation

Codecov / codecov/patch

libs/blocks/preflight/panels/performance.js#L154-L155

Added lines #L154 - L155 were not covered by tests
});

export function PerformanceItem({ icon, title, description }) {
return html` <div class="preflight-item">
<div class="result-icon ${icon}"></div>
<div class="preflight-item-text">
<p class="preflight-item-title">${title}</p>
<p class="preflight-item-description">${description}</p>
</div>
</div>`;
}

export const checkForSingleBlock = () => conditionalItemUpdate({
failsWhen: document.querySelector('main > div.section').childElementCount > 1,
key: text.singleBlock.key,
});

export const createPerformanceItem = ({
icon,
title,
description,
} = {}) => html`<${PerformanceItem}
icon=${icon}
title=${title}
description=${description}
/>`;

let clonedLcpSection;
function highlightElement(event) {
if (!config.lcp) return;
const lcpSection = config.lcp?.element.closest('.section');
const tooltip = document.querySelector('.lcp-tooltip-modal');
const { offsetHeight, offsetWidth } = lcpSection;
const scaleFactor = Math.min(500 / offsetWidth, 500 / offsetHeight);
if (!clonedLcpSection) {
clonedLcpSection = lcpSection.cloneNode(true);
clonedLcpSection.classList.add('lcp-clone');
}
Object.assign(clonedLcpSection.style, {
width: `${lcpSection.offsetWidth}px`,
height: `${lcpSection.offsetHeight}px`,
transform: `scale(${scaleFactor})`,
transformOrigin: 'top left',
});
if (!tooltip.children.length) tooltip.appendChild(clonedLcpSection);
const { top, left } = event.currentTarget.getBoundingClientRect();
Object.assign(tooltip.style, {
width: `${offsetWidth * scaleFactor}px`,
height: `${offsetHeight * scaleFactor}px`,
top: `${top + window.scrollY - offsetHeight * scaleFactor - 10}px`,
left: `${left + window.scrollX}px`,
});
document.querySelector('.lcp-tooltip-modal').classList.add('show');
}

Check warning on line 209 in libs/blocks/preflight/panels/performance.js

View check run for this annotation

Codecov / codecov/patch

libs/blocks/preflight/panels/performance.js#L184-L209

Added lines #L184 - L209 were not covered by tests

const removeHighlight = () => { document.querySelector('.lcp-tooltip-modal').classList.remove('show'); };

function observePerfMetrics() {
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
if (lastEntry) config.lcp = lastEntry;
if (!checkLCP()) {
Object.values(text).forEach(({ key }) => {
if (key === 'lcpEl') return;
updateItem({ key, description: 'No LCP element found.' });
});
return;
}
checkFragments();
checkForPersonalization();
checkVideosWithoutPosterAttribute();
checkIcons();
checkForSingleBlock();
checkPlaceholders();
Promise.all([checkImageSize()]);

Check warning on line 231 in libs/blocks/preflight/panels/performance.js

View check run for this annotation

Codecov / codecov/patch

libs/blocks/preflight/panels/performance.js#L215-L231

Added lines #L215 - L231 were not covered by tests
}).observe({ type: 'largest-contentful-paint', buffered: true });

new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
entries.forEach((entry) => {
if (!entry.hadRecentInput) {
config.cls += entry.value;
}
});
if (config.cls > 0) {
// TODO - Lana log? We should not have any CLS.
}

Check warning on line 243 in libs/blocks/preflight/panels/performance.js

View check run for this annotation

Codecov / codecov/patch

libs/blocks/preflight/panels/performance.js#L235-L243

Added lines #L235 - L243 were not covered by tests
}).observe({ type: 'layout-shift', buffered: true });
}

export function Panel() {
useEffect(() => observePerfMetrics(), []);

return html`
<div class="preflight-columns">
<div class="preflight-column">${config.items.value.slice(0, 4).map((item) => createPerformanceItem(item))}</div>
<div class="preflight-column">${config.items.value.slice(4, 8).map((item) => createPerformanceItem(item))}</div>
<div>Unsure on how to get this page fully into the green? Check out the <a class="performance-guidelines" href="https://milo.adobe.com/docs/authoring/performance/" target="_blank">Milo Performance Guidelines</a>.</div>
<div>
<span class="performance-element-preview" onMouseEnter=${highlightElement} onMouseLeave=${removeHighlight}>
Highlight the found LCP section
</span>
</div>
<div class="lcp-tooltip-modal"></div>
</div>
`;
}

export default Panel;
14 changes: 7 additions & 7 deletions libs/blocks/preflight/panels/seo.js
Original file line number Diff line number Diff line change
Expand Up @@ -292,11 +292,11 @@ export async function sendResults() {

function SeoItem({ icon, title, description }) {
return html`
<div class=seo-item>
<div class=preflight-item>
<div class="result-icon ${icon}"></div>
<div class=seo-item-text>
<p class=seo-item-title>${title}</p>
<p class=seo-item-description>${description}</p>
<div class=preflight-item-text>
<p class=preflight-item-title>${title}</p>
<p class=preflight-item-description>${description}</p>
</div>
</div>`;
}
Expand Down Expand Up @@ -327,14 +327,14 @@ async function getResults() {
export default function Panel() {
useEffect(() => { getResults(); }, []);
return html`
<div class=seo-columns>
<div class=seo-column>
<div class=preflight-columns>
<div class=preflight-column>
<${SeoItem} icon=${titleResult.value.icon} title=${titleResult.value.title} description=${titleResult.value.description} />
<${SeoItem} icon=${h1Result.value.icon} title=${h1Result.value.title} description=${h1Result.value.description} />
<${SeoItem} icon=${canonResult.value.icon} title=${canonResult.value.title} description=${canonResult.value.description} />
<${SeoItem} icon=${linksResult.value.icon} title=${linksResult.value.title} description=${linksResult.value.description} />
</div>
<div class=seo-column>
<div class=preflight-column>
<${SeoItem} icon=${bodyResult.value.icon} title=${bodyResult.value.title} description=${bodyResult.value.description} />
<${SeoItem} icon=${loremResult.value.icon} title=${loremResult.value.title} description=${loremResult.value.description} />
<${SeoItem} icon=${descResult.value.icon} title=${descResult.value.title} description=${descResult.value.description} />
Expand Down
51 changes: 45 additions & 6 deletions libs/blocks/preflight/preflight.css
Original file line number Diff line number Diff line change
Expand Up @@ -312,21 +312,21 @@ span.preflight-time {
}

/* SEO */
.seo-columns {
.preflight-columns {
margin: 24px 48px 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 48px;
}

.seo-item {
.preflight-item {
margin-bottom: 48px;
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
}

.seo-item:last-child {
.preflight-item:last-child {
margin-bottom: 0;
}

Expand All @@ -341,6 +341,11 @@ span.preflight-time {
animation: spin 2s linear infinite;
}

.result-icon.empty {
background: url('./img/empty.svg');
background-size: 60px;
}

@keyframes spin {
100% {
-webkit-transform: rotate(360deg);
Expand All @@ -367,14 +372,14 @@ span.preflight-time {
background-size: 60px;
}

.seo-item-title {
.preflight-item-title {
margin: 0;
font-weight: 700;
font-size: 32px;
line-height: 1;
}

.seo-item-description {
.preflight-item-description {
margin: 0;
}

Expand Down Expand Up @@ -625,4 +630,38 @@ img[data-alt-check]::after {

.dialog-modal#preflight table td h3 {
font-size: 16px;
}
}

.performance-guidelines {
color: #fff;
text-decoration: underline;
}

.performance-element-preview {
position: relative;
display: inline-block;
color: #fff;
text-decoration: underline;
}

.performance-element-preview:hover {
text-decoration: none;
}

/* styles.css */
.lcp-tooltip-modal {
width: 0;
height: 0;
position: fixed;
border: 2px solid red;
background-color: white;
z-index: 103;
overflow: hidden;
pointer-events: none;
display: block;
visibility: hidden;
}

.lcp-tooltip-modal.show {
visibility: visible;
}
Loading

0 comments on commit fded71e

Please sign in to comment.