Skip to content

Commit

Permalink
feat(audits): Add initial perf audits (#10015)
Browse files Browse the repository at this point in the history
* feat(audits): Add initial perf audits

* feat(audits): Setup dev astro-island

* fix(audits): Don't take scroll into account when getting an element's position

* nit: lint

* Fix tests

* chore: changeset

* maybe: Move this.hydrator outside the perf check

* Update packages/astro/e2e/dev-toolbar.test.js

Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>

* address feedback

* address feedback

---------

Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>
  • Loading branch information
Princesseuh and bluwy authored Feb 14, 2024
1 parent f9aebe7 commit 6884b10
Show file tree
Hide file tree
Showing 18 changed files with 309 additions and 41 deletions.
5 changes: 5 additions & 0 deletions .changeset/twenty-plums-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"astro": minor
---

Adds initial support for performance audits to the dev toolbar
19 changes: 8 additions & 11 deletions .github/workflows/test-hosts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Hosted tests

on:
schedule:
- cron: '0 0 * * 0'
- cron: '0 0 * * 0'

env:
ASTRO_TELEMETRY_DISABLED: true
Expand All @@ -28,24 +28,21 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 18
cache: "pnpm"
cache: 'pnpm'

- name: Install dependencies
run: pnpm install

- name: Build Astro
run: pnpm turbo build --filter astro --filter @astrojs/vercel
run: pnpm turbo build --filter astro --filter @astrojs/vercel

- name: Build test project
working-directory: ./packages/integrations/vercel/test/hosted/hosted-astro-project
run:
pnpm run build

run: pnpm run build

- name: Deploy to Vercel
working-directory: ./packages/integrations/vercel/test/hosted/hosted-astro-project
run:
pnpm dlx vercel --prod --prebuilt
run: pnpm dlx vercel --prod --prebuilt

- name: Test
run:
pnpm run test:e2e:hosts
run: pnpm run test:e2e:hosts
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ package-lock.json
*.env

packages/astro/src/**/*.prebuilt.ts
packages/astro/src/**/*.prebuilt-dev.ts
!packages/astro/vendor/vite/dist
packages/integrations/**/.netlify/

Expand Down
4 changes: 4 additions & 0 deletions packages/astro/components/Image.astro
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ const additionalAttributes: HTMLAttributes<'img'> = {};
if (image.srcSet.values.length > 0) {
additionalAttributes.srcset = image.srcSet.attribute;
}
if (import.meta.env.DEV) {
additionalAttributes['data-image-component'] = 'true';
}
---

<img src={image.src} {...additionalAttributes} {...image.attributes} />
4 changes: 4 additions & 0 deletions packages/astro/components/Picture.astro
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ if (props.sizes) {
if (fallbackImage.srcSet.values.length > 0) {
imgAdditionalAttributes.srcset = fallbackImage.srcSet.attribute;
}
if (import.meta.env.DEV) {
imgAdditionalAttributes['data-image-component'] = 'true';
}
---

<picture {...pictureAttributes}>
Expand Down
46 changes: 46 additions & 0 deletions packages/astro/e2e/dev-toolbar-audits.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { expect } from '@playwright/test';
import { testFactory } from './test-utils.js';

const test = testFactory({
root: './fixtures/dev-toolbar/',
});

let devServer;

test.beforeAll(async ({ astro }) => {
devServer = await astro.startDevServer();
});

test.afterAll(async () => {
await devServer.stop();
});

test.describe('Dev Toolbar - Audits', () => {
test('can warn about perf issues zzz', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/audits-perf'));

const toolbar = page.locator('astro-dev-toolbar');
const appButton = toolbar.locator('button[data-app-id="astro:audit"]');
await appButton.click();

const auditCanvas = toolbar.locator('astro-dev-toolbar-app-canvas[data-app-id="astro:audit"]');
const auditHighlights = auditCanvas.locator('astro-dev-toolbar-highlight');

const count = await auditHighlights.count();
expect(count).toEqual(2);

for (const auditHighlight of await auditHighlights.all()) {
await expect(auditHighlight).toBeVisible();

const auditCode = await auditHighlight.getAttribute('data-audit-code');
expect(auditCode.startsWith('perf-')).toBe(true);

await auditHighlight.hover();
const auditHighlightTooltip = auditHighlight.locator('astro-dev-toolbar-tooltip');
await expect(auditHighlightTooltip).toBeVisible();
}

// Toggle app off
await appButton.click();
});
});
28 changes: 21 additions & 7 deletions packages/astro/e2e/dev-toolbar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,18 @@ test.describe('Dev Toolbar', () => {
await appButton.click();

const auditCanvas = toolbar.locator('astro-dev-toolbar-app-canvas[data-app-id="astro:audit"]');
const auditHighlight = auditCanvas.locator('astro-dev-toolbar-highlight');
await expect(auditHighlight).toBeVisible();
const auditHighlights = auditCanvas.locator('astro-dev-toolbar-highlight');

for (const auditHighlight of await auditHighlights.all()) {
await expect(auditHighlight).toBeVisible();

await auditHighlight.hover();
const auditHighlightTooltip = auditHighlight.locator('astro-dev-toolbar-tooltip');
await expect(auditHighlightTooltip).toBeVisible();
await auditHighlight.hover();
const auditHighlightTooltip = auditHighlight.locator('astro-dev-toolbar-tooltip');
await expect(auditHighlightTooltip).toBeVisible();
}

// Toggle app off
await appButton.click();
await expect(auditHighlight).not.toBeVisible();
await expect(auditHighlightTooltip).not.toBeVisible();
});

test('audit shows no issues message when there are no issues', async ({ page, astro }) => {
Expand Down Expand Up @@ -233,4 +234,17 @@ test.describe('Dev Toolbar', () => {
await appButton.click();
await expect(myAppWindow).not.toBeVisible();
});

test('islands include their server and client render time', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));

const island = page.locator('astro-island');
await expect(island).toHaveCount(1);

const serverRenderTime = await island.getAttribute('server-render-time');
const clientRenderTime = await island.getAttribute('client-render-time');

expect(serverRenderTime).not.toBe(null);
expect(clientRenderTime).not.toBe(null);
});
});
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
---

<img src="https://astro.build/assets/press/astro-logo-dark.svg" alt="Astro logo" />
<div>Hey, there's no errors here!</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
import { Image } from "astro:assets";
import walrus from "../light_walrus.avif";
---

<Image src={walrus} loading="lazy" alt="A walrus" />

<div style="height: 9000px;"></div>

<Image src={walrus} loading="eager" alt="A walrus" />
Empty file.
27 changes: 21 additions & 6 deletions packages/astro/src/runtime/client/dev-toolbar/apps/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '../utils/highlight.js';
import { createWindowElement } from '../utils/window.js';
import { a11y } from './a11y.js';
import { perf } from './perf.js';

const icon =
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 1 20 16"><path fill="#fff" d="M.6 2A1.1 1.1 0 0 1 1.7.9h16.6a1.1 1.1 0 1 1 0 2.2H1.6A1.1 1.1 0 0 1 .8 2Zm1.1 7.1h6a1.1 1.1 0 0 0 0-2.2h-6a1.1 1.1 0 0 0 0 2.2ZM9.3 13H1.8a1.1 1.1 0 1 0 0 2.2h7.5a1.1 1.1 0 1 0 0-2.2Zm11.3 1.9a1.1 1.1 0 0 1-1.5 0l-1.7-1.7a4.1 4.1 0 1 1 1.6-1.6l1.6 1.7a1.1 1.1 0 0 1 0 1.6Zm-5.3-3.4a1.9 1.9 0 1 0 0-3.8 1.9 1.9 0 0 0 0 3.8Z"/></svg>';
Expand All @@ -28,10 +29,20 @@ export interface ResolvedAuditRule {

export interface AuditRuleWithSelector extends AuditRule {
selector: string;
match?: (element: Element) => boolean | null | undefined | void;
match?: (
element: Element
) =>
| boolean
| null
| undefined
| void
| Promise<boolean>
| Promise<void>
| Promise<null>
| Promise<undefined>;
}

const rules = [...a11y];
const rules = [...a11y, ...perf];

const dynamicAuditRuleKeys: Array<keyof AuditRule> = ['title', 'message'];
function resolveAuditRule(rule: AuditRule, element: Element): ResolvedAuditRule {
Expand Down Expand Up @@ -93,12 +104,16 @@ export default {
matches = Array.from(elements);
} else {
for (const element of elements) {
if (rule.match(element)) {
if (await rule.match(element)) {
matches.push(element);
}
}
}
for (const element of matches) {
// Don't audit elements that already have an audit on them
// TODO: This is a naive implementation, it'd be good to show all the audits for an element at the same time.
if (audits.some((audit) => audit.auditedElement === element)) continue;

await createAuditProblem(rule, element);
}
}
Expand Down Expand Up @@ -146,10 +161,10 @@ export default {
}
</style>
<header>
<h1><astro-dev-toolbar-icon icon="check-circle"></astro-dev-toolbar-icon>No accessibility issues detected.</h1>
<h1><astro-dev-toolbar-icon icon="check-circle"></astro-dev-toolbar-icon>No accessibility or performance issues detected.</h1>
</header>
<p>
Nice work! This app scans the page and highlights common accessibility issues for you, like a missing "alt" attribute on an image.
Nice work! This app scans the page and highlights common accessibility and performance issues for you, like a missing "alt" attribute on an image, or a image not using performant attributes.
</p>
`
);
Expand Down Expand Up @@ -197,7 +212,7 @@ export default {
}

const rect = originalElement.getBoundingClientRect();
const highlight = createHighlight(rect, 'warning');
const highlight = createHighlight(rect, 'warning', { 'data-audit-code': rule.code });
const tooltip = buildAuditTooltip(rule, originalElement);

// Set the highlight/tooltip as being fixed position the highlighted element
Expand Down
125 changes: 125 additions & 0 deletions packages/astro/src/runtime/client/dev-toolbar/apps/audit/perf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { AuditRuleWithSelector } from './index.js';

// A regular expression to match external URLs
const EXTERNAL_URL_REGEX = /^(?:[a-z+]+:)?\/\//i;

export const perf: AuditRuleWithSelector[] = [
{
code: 'perf-use-image-component',
title: 'Use the Image component',
message: 'This image could be replaced with the Image component to improve performance.',
selector: 'img:not([data-image-component])',
async match(element) {
const src = element.getAttribute('src');
if (!src) return false;

// Don't match data URIs, they're typically used for specific use-cases that the image component doesn't help with
if (src.startsWith('data:')) return false;

// Ignore images that are smaller than 20KB, most of the time the image component won't really help with these, or they're used for specific use-cases (pixel tracking, etc.)
// Ignore this test for remote images for now, fetching them can be very slow and possibly dangerous
if (!EXTERNAL_URL_REGEX.test(src)) {
const imageData = await fetch(src).then((response) => response.blob());
if (imageData.size < 20480) return false;
}

return true;
},
},
{
code: 'perf-use-loading-lazy',
title: 'Use the loading="lazy" attribute',
message: (element) =>
`This ${element.nodeName} tag is below the fold and could be lazy-loaded to improve performance.`,
selector:
'img:not([loading]), img[loading="eager"], iframe:not([loading]), iframe[loading="eager"]',
match(element) {
const htmlElement = element as HTMLImageElement | HTMLIFrameElement;
// Ignore elements that are above the fold, they should be loaded eagerly
if (htmlElement.offsetTop < window.innerHeight) return false;

return true;
},
},
{
code: 'perf-use-loading-eager',
title: 'Use the loading="eager" attribute',
message: (element) =>
`This ${element.nodeName} tag is above the fold and could be eagerly-loaded to improve performance.`,
selector: 'img[loading="lazy"], iframe[loading="lazy"]',
match(element) {
const htmlElement = element as HTMLImageElement | HTMLIFrameElement;

// Ignore elements that are below the fold, they should be loaded lazily
if (htmlElement.offsetTop > window.innerHeight) return false;

return true;
},
},
{
code: 'perf-use-videos',
title: 'Use videos instead of GIFs for large animations',
message:
'This GIF could be replaced with a video to reduce its file size and improve performance.',
selector: 'img[src$=".gif"]',
async match(element) {
const src = element.getAttribute('src');
if (!src) return false;

// Ignore remote URLs
if (EXTERNAL_URL_REGEX.test(src)) return false;

// Ignore GIFs that are smaller than 100KB, those are typically small enough to not be a problem
if (!EXTERNAL_URL_REGEX.test(src)) {
const imageData = await fetch(src).then((response) => response.blob());
if (imageData.size < 102400) return false;
}

return true;
},
},
{
code: 'perf-slow-component-server-render',
title: 'Server-rendered component took a long time to render',
message: (element) =>
`This component took an unusually long time to render on the server (${getCleanRenderingTime(
element.getAttribute('server-render-time')
)}). This might be a sign that it's doing too much work on the server, or something is blocking rendering.`,
selector: 'astro-island[server-render-time]',
match(element) {
const serverRenderTime = element.getAttribute('server-render-time');
if (!serverRenderTime) return false;

const renderingTime = parseFloat(serverRenderTime);
if (Number.isNaN(renderingTime)) return false;

return renderingTime > 500;
},
},
{
code: 'perf-slow-component-client-hydration',
title: 'Client-rendered component took a long time to hydrate',
message: (element) =>
`This component took an unusually long time to render on the server (${getCleanRenderingTime(
element.getAttribute('client-render-time')
)}). This could be a sign that something is blocking the main thread and preventing the component from hydrating quickly.`,
selector: 'astro-island[client-render-time]',
match(element) {
const clientRenderTime = element.getAttribute('client-render-time');
if (!clientRenderTime) return false;

const renderingTime = parseFloat(clientRenderTime);
if (Number.isNaN(renderingTime)) return false;

return renderingTime > 500;
},
},
];

function getCleanRenderingTime(time: string | null) {
if (!time) return 'unknown';
const renderingTime = parseFloat(time);
if (Number.isNaN(renderingTime)) return 'unknown';

return renderingTime.toFixed(2) + 's';
}
Loading

0 comments on commit 6884b10

Please sign in to comment.