Skip to content

Commit

Permalink
Added performance tab to the Preflight tool (#2773)
Browse files Browse the repository at this point in the history
added performance tab
  • Loading branch information
robert-bogos authored and mokimo committed Oct 16, 2024
1 parent ec48f3c commit 74fcdc4
Show file tree
Hide file tree
Showing 8 changed files with 631 additions and 4 deletions.
271 changes: 271 additions & 0 deletions libs/blocks/preflight/panels/performance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
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));
};

function observePerfMetrics() {
// Observe LCP
const lcpObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
if (!lastEntry || lastEntry.url.includes('preflight')) return;
config.lcp = lastEntry;
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

// Observe CLS
const clsObserver = 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.
// console.log('CLS:', cls);
}
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
}

// Dont call the method to retain the empty state of an item
export function conditionalItemUpdate({ emptyWhen, failsWhen, key }) {
if (emptyWhen) {
updateItem({
key,
icon: icons.empty,
description: text[key].empty.description,
});
return;
}
if (failsWhen) {
updateItem({
key,
icon: icons.fail,
description: text[key].failed.description,
});
return;
}
updateItem({
key,
icon: icons.pass,
description: text[key].passed.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;
const hasValidImage = lcp?.url && !lcp.url.match('media_.*.mp4');
let blob;
let isSizeValid;
if (hasValidImage) {
blob = await fetch(lcp.url).then((res) => res.blob());
isSizeValid = blob.size / 1000 <= 100;
}

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

export async function checkLCP() {
const { lcp } = config;
const mainSection = document.querySelector('main > div.section');
conditionalItemUpdate({
failsWhen: !lcp || !lcp.element || !lcp.url || !mainSection?.contains(lcp.element),
key: text.lcpEl.key,
icon: icons.pass,
description: text.lcpEl.passed.description,
});
}

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 = async () => conditionalItemUpdate({
failsWhen: config.lcp.element.closest('.section').dataset.placeholdersDecorated === 'true',
key: text.placeholders.key,
});

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

export const checkVideosWithoutPosterAttribute = async () => 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,
});

export function PerformanceItem({ icon, title, description }) {
return html` <div class="seo-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>
</div>`;
}

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

async function getResults() {
observePerfMetrics();
checkLCP();
checkFragments();
checkForPersonalization();
checkVideosWithoutPosterAttribute();
checkIcons();
checkForSingleBlock();
await Promise.all([checkImageSize(), checkPlaceholders()]);
}

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

let clonedLcpSection;
function highlightElement(event) {
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');
clonedLcpSection.style.width = `${lcpSection.offsetWidth}px`;
clonedLcpSection.style.height = `${lcpSection.offsetHeight}px`;
clonedLcpSection.style.transform = `scale(${scaleFactor})`;
clonedLcpSection.style.transformOrigin = 'top left';
}
if (!tooltip.children.length) tooltip.appendChild(clonedLcpSection);
const { top, left } = event.currentTarget.getBoundingClientRect();
tooltip.style.width = `${offsetWidth * scaleFactor}px`;
tooltip.style.height = `${offsetHeight * scaleFactor}px`;
tooltip.style.top = `${top + window.scrollY - offsetHeight * scaleFactor - 10}px`;
tooltip.style.left = `${left + window.scrollX}px`;
document.querySelector('.lcp-tooltip-modal').classList.add('show');
}

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

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

return html`
<div class="seo-columns">
<div class="seo-column">${config.items.value.slice(0, 4).map((item) => createPerformanceItem(item))}</div>
<div class="seo-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>
<div class="lcp-tooltip-modal"></div>
`;
}

export default Panel;
41 changes: 40 additions & 1 deletion libs/blocks/preflight/preflight.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 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;
}
6 changes: 5 additions & 1 deletion libs/blocks/preflight/preflight.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import General from './panels/general.js';
import SEO from './panels/seo.js';
import Accessibility from './panels/accessibility.js';
import Martech from './panels/martech.js';
import Performance from './panels/performance.js';

const HEADING = 'Milo Preflight';
const IMG_PATH = '/blocks/preflight/img';

const tabs = signal([
{ title: 'General', selected: true },
{ title: 'General' },
{ title: 'SEO' },
{ title: 'Martech' },
{ title: 'Accessibility' },
{ title: 'Performance' },
]);

function setTab(active) {
Expand All @@ -32,6 +34,8 @@ function setPanel(title) {
return html`<${Martech} />`;
case 'Accessibility':
return html`<${Accessibility} />`;
case 'Performance':
return html`<${Performance} />`;
default:
return html`<p>No matching panel.</p>`;
}
Expand Down
1 change: 1 addition & 0 deletions libs/utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,7 @@ async function decoratePlaceholders(area, config) {
if (!area) return;
const nodes = findReplaceableNodes(area);
if (!nodes.length) return;
area.dataset.placeholdersDecorated = 'true';
const placeholderPath = `${config.locale?.contentRoot}/placeholders.json`;
placeholderRequest = placeholderRequest
|| customFetch({ resource: placeholderPath, withCacheRules: true })
Expand Down
30 changes: 30 additions & 0 deletions test/blocks/preflight/panels/performance/mocks/marquee.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<main>
<div>
<div class="marquee dark">
<div>
<div>
<br>
<picture>
<source type="image/webp" srcset="./media_1e3592d2ec5d63ce4a4d8a920d87da94b5b2cee5c.png?width=2000&#x26;format=webply&#x26;optimize=medium" media="(min-width: 600px)">
<source type="image/webp" srcset="./media_1e3592d2ec5d63ce4a4d8a920d87da94b5b2cee5c.png?width=750&#x26;format=webply&#x26;optimize=medium">
<source type="image/png" srcset="./media_1e3592d2ec5d63ce4a4d8a920d87da94b5b2cee5c.png?width=2000&#x26;format=png&#x26;optimize=medium" media="(min-width: 600px)">
<img loading="lazy" alt="" src="./media_1e3592d2ec5d63ce4a4d8a920d87da94b5b2cee5c.png?width=750&#x26;format=png&#x26;optimize=medium" width="1024" height="1024">
</picture>
</div>
</div>
<div>
<div>
<h1 id="using-data-to-transform-attitudes-and-evolve-experiences">Using data to transform attitudes and evolve experiences.</h1>
<p>How BMW Group transformed the online automotive experience for customers, dealers, and employees</p>
<p>
<strong>
<em>
<a href="/fragments/customer-success-stories/modals/bmw#watch-now">Watch Now</a>
</em>
</strong>
</p>
</div>
</div>
</div>
</div>
</main>
Loading

0 comments on commit 74fcdc4

Please sign in to comment.