diff --git a/.changeset/two-ads-bathe.md b/.changeset/two-ads-bathe.md new file mode 100644 index 0000000000..beb3301460 --- /dev/null +++ b/.changeset/two-ads-bathe.md @@ -0,0 +1,5 @@ +--- +"astro": minor +--- + +Adds support for page mutations to the audits in the dev toolbar. Astro will now rerun the audits whenever elements are added or deleted from the page. diff --git a/packages/astro/e2e/dev-toolbar-audits.test.js b/packages/astro/e2e/dev-toolbar-audits.test.js index 2195aa9f95..cbb89ab728 100644 --- a/packages/astro/e2e/dev-toolbar-audits.test.js +++ b/packages/astro/e2e/dev-toolbar-audits.test.js @@ -44,6 +44,115 @@ test.describe('Dev Toolbar - Audits', () => { await appButton.click(); }); + test('can handle mutations', async ({ page, astro }) => { + await page.goto(astro.resolveUrl('/audits-mutations')); + + 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'); + await expect(auditHighlights).toHaveCount(1); + + await page.click('body'); + + const badButton = page.locator('#bad-button'); + + let consolePromise = page.waitForEvent('console'); + await badButton.click(); + await consolePromise; + + await appButton.click(); + await expect(auditHighlights).toHaveCount(2); + }); + + test('multiple changes only result in one audit update', async ({ page, astro }) => { + await page.goto(astro.resolveUrl('/')); + + await page.evaluate(() => { + localStorage.setItem( + 'astro:dev-toolbar:settings', + JSON.stringify({ + verbose: true, + }) + ); + }); + + await page.goto(astro.resolveUrl('/audits-mutations')); + + let logs = []; + page.on('console', (msg) => { + logs.push(msg.text()); + }); + + const badButton = page.locator('#bad-button'); + + let consolePromise = page.waitForEvent('console', (msg) => + msg.text().includes('Rerunning audit lints') + ); + await badButton.click({ clickCount: 5 }); + await consolePromise; + + await page.click('body'); + + expect( + logs.filter((log) => log.includes('Rerunning audit lints because the DOM has been updated')) + .length === 1 + ).toBe(true); + }); + + test('handle mutations properly during view transitions', async ({ page, astro }) => { + await page.goto(astro.resolveUrl('/')); + + await page.evaluate(() => { + localStorage.setItem( + 'astro:dev-toolbar:settings', + JSON.stringify({ + verbose: true, + }) + ); + }); + + await page.goto(astro.resolveUrl('/audits-mutations')); + + let logs = []; + page.on('console', (msg) => { + logs.push(msg.text()); + }); + + const linkToOtherPage = page.locator('#link-to-2'); + let consolePromise = page.waitForEvent('console'); + await linkToOtherPage.click(); + await consolePromise; + + 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'); + await expect(auditHighlights).toHaveCount(1); + + await page.click('body'); + + const badButton = page.locator('#bad-button-2'); + + consolePromise = page.waitForEvent('console'); + await badButton.click(); + await consolePromise; + + await appButton.click(); + await expect(auditHighlights).toHaveCount(2); + + // Make sure we only reran audits once + expect( + logs.filter((log) => log.includes('Rerunning audit lints because the DOM has been updated')) + .length === 1 + ).toBe(true); + }); + test('does not warn for non-interactive element', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/a11y-exceptions')); diff --git a/packages/astro/e2e/dev-toolbar.test.js b/packages/astro/e2e/dev-toolbar.test.js index b2e6242b47..49472fbb3b 100644 --- a/packages/astro/e2e/dev-toolbar.test.js +++ b/packages/astro/e2e/dev-toolbar.test.js @@ -272,7 +272,6 @@ test.describe('Dev Toolbar', () => { await appButton.click(); const myAppCanvas = toolbar.locator('astro-dev-toolbar-app-canvas[data-app-id="my-plugin"]'); - console.log(await myAppCanvas.innerHTML()); const myAppWindow = myAppCanvas.locator('astro-dev-toolbar-window'); await expect(myAppWindow).toHaveCount(1); await expect(myAppWindow).toBeVisible(); diff --git a/packages/astro/e2e/fixtures/dev-toolbar/src/pages/audits-mutations-2.astro b/packages/astro/e2e/fixtures/dev-toolbar/src/pages/audits-mutations-2.astro new file mode 100644 index 0000000000..e1c95e3e4d --- /dev/null +++ b/packages/astro/e2e/fixtures/dev-toolbar/src/pages/audits-mutations-2.astro @@ -0,0 +1,29 @@ +--- +import Layout from "../layout/Layout.astro"; +--- + + + +Go to Mutations 1 + +


+ + + +
diff --git a/packages/astro/e2e/fixtures/dev-toolbar/src/pages/audits-mutations.astro b/packages/astro/e2e/fixtures/dev-toolbar/src/pages/audits-mutations.astro new file mode 100644 index 0000000000..1c4950a2ff --- /dev/null +++ b/packages/astro/e2e/fixtures/dev-toolbar/src/pages/audits-mutations.astro @@ -0,0 +1,28 @@ +--- +import Layout from "../layout/Layout.astro"; +--- + + + +Go to Mutations 2 + + + + + diff --git a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/index.ts b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/index.ts index e07e6c6ac8..1e7e3009c2 100644 --- a/packages/astro/src/runtime/client/dev-toolbar/apps/audit/index.ts +++ b/packages/astro/src/runtime/client/dev-toolbar/apps/audit/index.ts @@ -10,6 +10,7 @@ import { import { closeOnOutsideClick, createWindowElement } from '../utils/window.js'; import { a11y } from './a11y.js'; import { perf } from './perf.js'; +import { settings } from '../../settings.js'; const icon = ''; @@ -69,8 +70,51 @@ export default { await lint(); - document.addEventListener('astro:after-swap', async () => lint()); - document.addEventListener('astro:page-load', async () => refreshLintPositions); + let mutationDebounce: ReturnType; + const observer = new MutationObserver(() => { + // We don't want to rerun the audit lints on every single mutation, so we'll debounce it. + if (mutationDebounce) { + clearTimeout(mutationDebounce); + } + + mutationDebounce = setTimeout(() => { + settings.logger.verboseLog('Rerunning audit lints because the DOM has been updated.'); + + // Even though we're ready to run the lints, we'll wait for the next idle period to do so, as it is less likely + // to interfere with any other work the browser is doing post-mutation. For instance, the page or the user might + // be interacting with the newly added elements, or the browser might be doing some work (layout, paint, etc.) + if ('requestIdleCallback' in window) { + window.requestIdleCallback( + async () => { + lint(); + }, + { timeout: 300 } + ); + } else { + // Fallback for old versions of Safari, we'll assume that things are less likely to be busy after 150ms. + setTimeout(() => { + lint(); + }, 150); + } + }, 250); + }); + + setupObserver(); + + document.addEventListener('astro:before-preparation', () => { + observer.disconnect(); + }); + document.addEventListener('astro:after-swap', async () => { + lint(); + }); + document.addEventListener('astro:page-load', async () => { + refreshLintPositions(); + + // HACK: View transitions add a route announcer after this event, so we need to wait for it to be added + setTimeout(() => { + setupObserver(); + }, 100); + }); closeOnOutsideClick(eventTarget); @@ -380,5 +424,12 @@ export default { .replace(/"/g, '"') .replace(/'/g, '''); } + + function setupObserver() { + observer.observe(document.body, { + childList: true, + subtree: true, + }); + } }, } satisfies DevToolbarApp;