diff --git a/.changeset/great-turtles-clap.md b/.changeset/great-turtles-clap.md new file mode 100644 index 000000000000..2634ec5dc014 --- /dev/null +++ b/.changeset/great-turtles-clap.md @@ -0,0 +1,10 @@ +--- +"astro": patch +--- + +The prefetch feature is updated to better support different browsers and different cache headers setup, including: + +1. All prefetch strategies will now always try to use `` if supported, or will fall back to `fetch()`. +2. The `prefetch()` programmatic API's `with` option is deprecated in favour of an automatic approach that will also try to use ` u.includes(url)).length; + expect( + fetchCount, + `${url} should be prefetched ${count} time(s), but is prefetch with link ${linkCount} time(s) and with fetch ${fetchCount} time(s)` + ).toEqual(count); + } + } +} + +/** + * Check if url is not prefetched via `link[rel="prefetch"]` and `fetch()` (from `reqUrls`) + * @param {string} url + * @param {import('@playwright/test').Page} page + */ +async function expectUrlNotPrefetched(url, page) { + await expect(page.locator(`link[rel="prefetch"][href$="${url}"]`)).not.toBeAttached(); + expect(reqUrls).not.toContainEqual(url); +} + test.describe('Prefetch (default)', () => { let devServer; - /** @type {string[]} */ - const reqUrls = []; test.beforeAll(async ({ astro }) => { devServer = await astro.startDevServer(); }); - test.beforeEach(async ({ page }) => { - page.on('request', (req) => { - const urlObj = new URL(req.url()); - reqUrls.push(urlObj.pathname + urlObj.search); - }); - }); - - test.afterEach(() => { - reqUrls.length = 0; - }); - test.afterAll(async () => { await devServer.stop(); }); test('Link without data-astro-prefetch should not prefetch', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/prefetch-default'); + await expectUrlNotPrefetched('/prefetch-default', page); }); test('data-astro-prefetch="false" should not prefetch', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/prefetch-false'); + await expectUrlNotPrefetched('/prefetch-false', page); }); test('Link with search param should prefetch', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/?search-param=true'); + await expectUrlNotPrefetched('/?search-param=true', page); await Promise.all([ page.waitForEvent('request'), // wait prefetch request page.locator('#prefetch-search-param').hover(), ]); - expect(reqUrls).toContainEqual('/?search-param=true'); + await expectUrlPrefetched('/?search-param=true', page); }); test('data-astro-prefetch="tap" should prefetch on tap', async ({ page, astro }) => { @@ -61,52 +99,47 @@ test.describe('Prefetch (default)', () => { test('data-astro-prefetch="hover" should prefetch on hover', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/prefetch-hover'); + await expectUrlNotPrefetched('/prefetch-hover', page); await Promise.all([ page.waitForEvent('request'), // wait prefetch request page.locator('#prefetch-hover').hover(), ]); - expect(reqUrls).toContainEqual('/prefetch-hover'); + await expectUrlPrefetched('/prefetch-hover', page); }); test('data-astro-prefetch="viewport" should prefetch on viewport', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/prefetch-viewport'); + await expectUrlNotPrefetched('/prefetch-viewport', page); // Scroll down to show the element await Promise.all([ page.waitForEvent('request'), // wait prefetch request page.locator('#prefetch-viewport').scrollIntoViewIfNeeded(), ]); - expect(reqUrls).toContainEqual('/prefetch-viewport'); - expect(page.locator('link[rel="prefetch"][href$="/prefetch-viewport"]')).toBeDefined(); + await expectUrlPrefetched('/prefetch-viewport', page); }); test('manual prefetch() works once', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/prefetch-manual'); + await expectUrlNotPrefetched('/prefetch-manual', page); await Promise.all([ page.waitForEvent('request'), // wait prefetch request page.locator('#prefetch-manual').click(), ]); - expect(reqUrls).toContainEqual('/prefetch-manual'); - expect(page.locator('link[rel="prefetch"][href$="/prefetch-manual"]')).toBeDefined(); + await expectUrlPrefetched('/prefetch-manual', page); // prefetch again should have no effect await page.locator('#prefetch-manual').click(); - expect(reqUrls.filter((u) => u.includes('/prefetch-manual')).length).toEqual(1); + await expectUrlPrefetched('/prefetch-manual', page, 1); }); test('data-astro-prefetch="load" should prefetch', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).toContainEqual('/prefetch-load'); - expect(page.locator('link[rel="prefetch"][href$="/prefetch-load"]')).toBeDefined(); + await expectUrlPrefetched('/prefetch-load', page); }); }); test.describe("Prefetch (prefetchAll: true, defaultStrategy: 'tap')", () => { let devServer; - /** @type {string[]} */ - const reqUrls = []; test.beforeAll(async ({ astro }) => { devServer = await astro.startDevServer({ @@ -117,89 +150,74 @@ test.describe("Prefetch (prefetchAll: true, defaultStrategy: 'tap')", () => { }); }); - test.beforeEach(async ({ page }) => { - page.on('request', (req) => { - const urlObj = new URL(req.url()); - reqUrls.push(urlObj.pathname + urlObj.search); - }); - }); - - test.afterEach(() => { - reqUrls.length = 0; - }); - test.afterAll(async () => { await devServer.stop(); }); test('Link without data-astro-prefetch should prefetch', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/prefetch-default'); + await expectUrlNotPrefetched('/prefetch-default', page); await Promise.all([ page.waitForEvent('request'), // wait prefetch request page.locator('#prefetch-default').click(), ]); - expect(reqUrls).toContainEqual('/prefetch-default'); + await expectUrlPrefetched('/prefetch-default', page); }); test('data-astro-prefetch="false" should not prefetch', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/prefetch-false'); + await expectUrlNotPrefetched('/prefetch-false', page); }); test('Link with search param should prefetch', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/?search-param=true'); + await expectUrlNotPrefetched('/?search-param=true', page); await Promise.all([ page.waitForEvent('request'), // wait prefetch request page.locator('#prefetch-search-param').hover(), ]); - expect(reqUrls).toContainEqual('/?search-param=true'); +await expectUrlPrefetched('/?search-param=true', page); }); test('data-astro-prefetch="tap" should prefetch on tap', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/prefetch-tap'); + await expectUrlNotPrefetched('/prefetch-tap', page); await Promise.all([ page.waitForEvent('request'), // wait prefetch request page.locator('#prefetch-tap').click(), ]); - expect(reqUrls).toContainEqual('/prefetch-tap'); +await expectUrlPrefetched('/prefetch-tap', page); }); test('data-astro-prefetch="hover" should prefetch on hover', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/prefetch-hover'); + await expectUrlNotPrefetched('/prefetch-hover', page); await Promise.all([ page.waitForEvent('request'), // wait prefetch request page.locator('#prefetch-hover').hover(), ]); - expect(reqUrls).toContainEqual('/prefetch-hover'); + await expectUrlPrefetched('/prefetch-hover', page); }); test('data-astro-prefetch="viewport" should prefetch on viewport', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/prefetch-viewport'); + await expectUrlNotPrefetched('/prefetch-viewport', page); // Scroll down to show the element await Promise.all([ page.waitForEvent('request'), // wait prefetch request page.locator('#prefetch-viewport').scrollIntoViewIfNeeded(), ]); - expect(reqUrls).toContainEqual('/prefetch-viewport'); - expect(page.locator('link[rel="prefetch"][href$="/prefetch-viewport"]')).toBeDefined(); + await expectUrlPrefetched('/prefetch-viewport', page); }); test('data-astro-prefetch="load" should prefetch', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).toContainEqual('/prefetch-load'); - expect(page.locator('link[rel="prefetch"][href$="/prefetch-load"]')).toBeDefined(); + await expectUrlPrefetched('/prefetch-load', page); }); }); test.describe("Prefetch (prefetchAll: true, defaultStrategy: 'load')", () => { let devServer; - /** @type {string[]} */ - const reqUrls = []; test.beforeAll(async ({ astro }) => { devServer = await astro.startDevServer({ @@ -210,78 +228,64 @@ test.describe("Prefetch (prefetchAll: true, defaultStrategy: 'load')", () => { }); }); - test.beforeEach(async ({ page }) => { - page.on('request', (req) => { - const urlObj = new URL(req.url()); - reqUrls.push(urlObj.pathname + urlObj.search); - }); - }); - - test.afterEach(() => { - reqUrls.length = 0; - }); - test.afterAll(async () => { await devServer.stop(); }); test('Link without data-astro-prefetch should prefetch', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).toContainEqual('/prefetch-default'); - expect(page.locator('link[rel="prefetch"][href$="/prefetch-default"]')).toBeDefined(); + await expectUrlPrefetched('/prefetch-default', page); }); test('data-astro-prefetch="false" should not prefetch', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/prefetch-false'); + await expectUrlNotPrefetched('/prefetch-false', page); }); test('Link with search param should prefetch', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/?search-param=true'); + await expectUrlNotPrefetched('/?search-param=true', page); await Promise.all([ page.waitForEvent('request'), // wait prefetch request page.locator('#prefetch-search-param').hover(), ]); - expect(reqUrls).toContainEqual('/?search-param=true'); + await expectUrlPrefetched('/?search-param=true', page); }); test('data-astro-prefetch="tap" should prefetch on tap', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/prefetch-tap'); + await expectUrlNotPrefetched('/prefetch-tap', page); await Promise.all([ page.waitForEvent('request'), // wait prefetch request page.locator('#prefetch-tap').click(), ]); - expect(reqUrls).toContainEqual('/prefetch-tap'); + await expectUrlPrefetched('/prefetch-tap', page); }); test('data-astro-prefetch="hover" should prefetch on hover', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/prefetch-hover'); + await expectUrlNotPrefetched('/prefetch-hover', page); await Promise.all([ page.waitForEvent('request'), // wait prefetch request page.locator('#prefetch-hover').hover(), ]); - expect(reqUrls).toContainEqual('/prefetch-hover'); + await expectUrlPrefetched('/prefetch-hover', page); }); test('data-astro-prefetch="viewport" should prefetch on viewport', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).not.toContainEqual('/prefetch-viewport'); + await expectUrlNotPrefetched('/prefetch-viewport', page); // Scroll down to show the element await Promise.all([ page.waitForEvent('request'), // wait prefetch request page.locator('#prefetch-viewport').scrollIntoViewIfNeeded(), ]); - expect(reqUrls).toContainEqual('/prefetch-viewport'); - expect(page.locator('link[rel="prefetch"][href$="/prefetch-viewport"]')).toBeDefined(); + await expectUrlPrefetched('/prefetch-viewport', page); }); test('data-astro-prefetch="load" should prefetch', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); - expect(reqUrls).toContainEqual('/prefetch-load'); - expect(page.locator('link[rel="prefetch"][href$="/prefetch-load"]')).toBeDefined(); + await expectUrlPrefetched('/prefetch-load', page); }); }); @@ -311,7 +315,9 @@ test.describe('Prefetch (default), Experimental ({ clientPrerender: true })', () let devServer; - test.beforeAll(async ({ astro }) => { + test.beforeAll(async ({ astro, browserName }) => { + test.skip(browserName !== 'chromium', 'Only Chromium supports clientPrerender') + devServer = await astro.startDevServer({ experimental: { clientPrerender: true, @@ -320,7 +326,7 @@ test.describe('Prefetch (default), Experimental ({ clientPrerender: true })', () }); test.afterAll(async () => { - await devServer.stop(); + await devServer?.stop(); }); test('Link without data-astro-prefetch should not prefetch', async ({ page, astro }) => { diff --git a/packages/astro/playwright.firefox.config.js b/packages/astro/playwright.firefox.config.js index 1934a68ed1eb..d8ec35031db6 100644 --- a/packages/astro/playwright.firefox.config.js +++ b/packages/astro/playwright.firefox.config.js @@ -5,7 +5,7 @@ process.stdout.isTTY = false; const config = { // TODO: add more tests like view transitions and audits, and fix them. Some of them are failing. - testMatch: ['e2e/css.test.js'], + testMatch: ['e2e/css.test.js', 'e2e/prefetch.test.js'], /* Maximum time one test can run for. */ timeout: 40 * 1000, expect: { diff --git a/packages/astro/src/prefetch/index.ts b/packages/astro/src/prefetch/index.ts index 6d7cb294bbb6..bdf8676e7e08 100644 --- a/packages/astro/src/prefetch/index.ts +++ b/packages/astro/src/prefetch/index.ts @@ -59,7 +59,7 @@ function initTapStrategy() { event, (e) => { if (elMatchesStrategy(e.target, 'tap')) { - prefetch(e.target.href, { with: 'fetch', ignoreSlowConnection: true }); + prefetch(e.target.href, { ignoreSlowConnection: true }); } }, { passive: true } @@ -107,7 +107,7 @@ function initHoverStrategy() { clearTimeout(timeout); } timeout = setTimeout(() => { - prefetch(href, { with: 'fetch' }); + prefetch(href); }, 80) as unknown as number; } @@ -158,7 +158,7 @@ function createViewportIntersectionObserver() { setTimeout(() => { observer.unobserve(anchor); timeouts.delete(anchor); - prefetch(anchor.href, { with: 'link' }); + prefetch(anchor.href); }, 300) as unknown as number ); } else { @@ -180,7 +180,7 @@ function initLoadStrategy() { for (const anchor of document.getElementsByTagName('a')) { if (elMatchesStrategy(anchor, 'load')) { // Prefetch every link in this page - prefetch(anchor.href, { with: 'link' }); + prefetch(anchor.href); } } }); @@ -189,8 +189,12 @@ function initLoadStrategy() { export interface PrefetchOptions { /** * How the prefetch should prioritize the URL. (default `'link'`) - * - `'link'`: use ``, has lower loading priority. - * - `'fetch'`: use `fetch()`, has higher loading priority. + * - `'link'`: use ``. + * - `'fetch'`: use `fetch()`. + * + * @deprecated It is recommended to not use this option, and let prefetch use `'link'` whenever it's supported, + * or otherwise fall back to `'fetch'`. `'link'` works better if the URL doesn't set an appropriate cache header, + * as the browser will continue to cache it as long as it's used subsequently. */ with?: 'link' | 'fetch'; /** @@ -215,28 +219,27 @@ export function prefetch(url: string, opts?: PrefetchOptions) { if (!canPrefetchUrl(url, ignoreSlowConnection)) return; prefetchedUrls.add(url); - const priority = opts?.with ?? 'link'; - debug?.(`[astro] Prefetching ${url} with ${priority}`); - - if ( - clientPrerender && - HTMLScriptElement.supports && - HTMLScriptElement.supports('speculationrules') - ) { - // this code is tree-shaken if unused + // Prefetch with speculationrules if `clientPrerender` is enabled and supported + // NOTE: This condition is tree-shaken if `clientPrerender` is false as its a static value + if (clientPrerender && HTMLScriptElement.supports?.('speculationrules')) { + debug?.(`[astro] Prefetching ${url} with