Skip to content

Commit 5f353e3

Browse files
authoredMay 15, 2024··
Improve prefetch behaviour for browsers (#10999)
1 parent 6cc3fb9 commit 5f353e3

File tree

5 files changed

+128
-108
lines changed

5 files changed

+128
-108
lines changed
 

‎.changeset/great-turtles-clap.md

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"astro": patch
3+
---
4+
5+
The prefetch feature is updated to better support different browsers and different cache headers setup, including:
6+
7+
1. All prefetch strategies will now always try to use `<link rel="prefetch">` if supported, or will fall back to `fetch()`.
8+
2. The `prefetch()` programmatic API's `with` option is deprecated in favour of an automatic approach that will also try to use `<link rel="prefetch>` if supported, or will fall back to `fetch()`.
9+
10+
This change shouldn't affect most sites and should instead make prefetching more effective.

‎.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ scripts/smoke/*-main/
99
scripts/memory/project/src/pages/
1010
benchmark/projects/
1111
benchmark/results/
12+
test-results/
1213
*.log
1314
package-lock.json
1415
.turbo/

‎packages/astro/e2e/prefetch.test.js

+90-84
Original file line numberDiff line numberDiff line change
@@ -5,48 +5,86 @@ const test = testFactory({
55
root: './fixtures/prefetch/',
66
});
77

8+
// Used to track fetch request urls
9+
/** @type {string[]} */
10+
const reqUrls = [];
11+
test.beforeEach(async ({ page }) => {
12+
page.on('request', (req) => {
13+
const urlObj = new URL(req.url());
14+
reqUrls.push(urlObj.pathname + urlObj.search);
15+
});
16+
});
17+
test.afterEach(() => {
18+
reqUrls.length = 0;
19+
});
20+
21+
/**
22+
* Check if url is prefetched via `link[rel="prefetch"]` or `fetch()` (from `reqUrls`)
23+
* @param {string} url
24+
* @param {import('@playwright/test').Page} page
25+
* @param {number} [count] Also expect that it's prefetched this amount of times
26+
*/
27+
async function expectUrlPrefetched(url, page, count) {
28+
try {
29+
await expect(page.locator(`link[rel="prefetch"][href$="${url}"]`)).toBeAttached();
30+
} catch {
31+
// If link is not found, check if it was fetched via `fetch()`
32+
expect(reqUrls, `${url} is not prefetched via link or fetch`).toContainEqual(url);
33+
}
34+
35+
if (count != null) {
36+
const linkCount = await page.locator(`link[rel="prefetch"][href$="${url}"]`).count();
37+
try {
38+
expect(linkCount).toBe(count);
39+
} catch {
40+
const fetchCount = reqUrls.filter((u) => u.includes(url)).length;
41+
expect(
42+
fetchCount,
43+
`${url} should be prefetched ${count} time(s), but is prefetch with link ${linkCount} time(s) and with fetch ${fetchCount} time(s)`
44+
).toEqual(count);
45+
}
46+
}
47+
}
48+
49+
/**
50+
* Check if url is not prefetched via `link[rel="prefetch"]` and `fetch()` (from `reqUrls`)
51+
* @param {string} url
52+
* @param {import('@playwright/test').Page} page
53+
*/
54+
async function expectUrlNotPrefetched(url, page) {
55+
await expect(page.locator(`link[rel="prefetch"][href$="${url}"]`)).not.toBeAttached();
56+
expect(reqUrls).not.toContainEqual(url);
57+
}
58+
859
test.describe('Prefetch (default)', () => {
960
let devServer;
10-
/** @type {string[]} */
11-
const reqUrls = [];
1261

1362
test.beforeAll(async ({ astro }) => {
1463
devServer = await astro.startDevServer();
1564
});
1665

17-
test.beforeEach(async ({ page }) => {
18-
page.on('request', (req) => {
19-
const urlObj = new URL(req.url());
20-
reqUrls.push(urlObj.pathname + urlObj.search);
21-
});
22-
});
23-
24-
test.afterEach(() => {
25-
reqUrls.length = 0;
26-
});
27-
2866
test.afterAll(async () => {
2967
await devServer.stop();
3068
});
3169

3270
test('Link without data-astro-prefetch should not prefetch', async ({ page, astro }) => {
3371
await page.goto(astro.resolveUrl('/'));
34-
expect(reqUrls).not.toContainEqual('/prefetch-default');
72+
await expectUrlNotPrefetched('/prefetch-default', page);
3573
});
3674

3775
test('data-astro-prefetch="false" should not prefetch', async ({ page, astro }) => {
3876
await page.goto(astro.resolveUrl('/'));
39-
expect(reqUrls).not.toContainEqual('/prefetch-false');
77+
await expectUrlNotPrefetched('/prefetch-false', page);
4078
});
4179

4280
test('Link with search param should prefetch', async ({ page, astro }) => {
4381
await page.goto(astro.resolveUrl('/'));
44-
expect(reqUrls).not.toContainEqual('/?search-param=true');
82+
await expectUrlNotPrefetched('/?search-param=true', page);
4583
await Promise.all([
4684
page.waitForEvent('request'), // wait prefetch request
4785
page.locator('#prefetch-search-param').hover(),
4886
]);
49-
expect(reqUrls).toContainEqual('/?search-param=true');
87+
await expectUrlPrefetched('/?search-param=true', page);
5088
});
5189

5290
test('data-astro-prefetch="tap" should prefetch on tap', async ({ page, astro }) => {
@@ -61,52 +99,47 @@ test.describe('Prefetch (default)', () => {
6199

62100
test('data-astro-prefetch="hover" should prefetch on hover', async ({ page, astro }) => {
63101
await page.goto(astro.resolveUrl('/'));
64-
expect(reqUrls).not.toContainEqual('/prefetch-hover');
102+
await expectUrlNotPrefetched('/prefetch-hover', page);
65103
await Promise.all([
66104
page.waitForEvent('request'), // wait prefetch request
67105
page.locator('#prefetch-hover').hover(),
68106
]);
69-
expect(reqUrls).toContainEqual('/prefetch-hover');
107+
await expectUrlPrefetched('/prefetch-hover', page);
70108
});
71109

72110
test('data-astro-prefetch="viewport" should prefetch on viewport', async ({ page, astro }) => {
73111
await page.goto(astro.resolveUrl('/'));
74-
expect(reqUrls).not.toContainEqual('/prefetch-viewport');
112+
await expectUrlNotPrefetched('/prefetch-viewport', page);
75113
// Scroll down to show the element
76114
await Promise.all([
77115
page.waitForEvent('request'), // wait prefetch request
78116
page.locator('#prefetch-viewport').scrollIntoViewIfNeeded(),
79117
]);
80-
expect(reqUrls).toContainEqual('/prefetch-viewport');
81-
expect(page.locator('link[rel="prefetch"][href$="/prefetch-viewport"]')).toBeDefined();
118+
await expectUrlPrefetched('/prefetch-viewport', page);
82119
});
83120

84121
test('manual prefetch() works once', async ({ page, astro }) => {
85122
await page.goto(astro.resolveUrl('/'));
86-
expect(reqUrls).not.toContainEqual('/prefetch-manual');
123+
await expectUrlNotPrefetched('/prefetch-manual', page);
87124
await Promise.all([
88125
page.waitForEvent('request'), // wait prefetch request
89126
page.locator('#prefetch-manual').click(),
90127
]);
91-
expect(reqUrls).toContainEqual('/prefetch-manual');
92-
expect(page.locator('link[rel="prefetch"][href$="/prefetch-manual"]')).toBeDefined();
128+
await expectUrlPrefetched('/prefetch-manual', page);
93129

94130
// prefetch again should have no effect
95131
await page.locator('#prefetch-manual').click();
96-
expect(reqUrls.filter((u) => u.includes('/prefetch-manual')).length).toEqual(1);
132+
await expectUrlPrefetched('/prefetch-manual', page, 1);
97133
});
98134

99135
test('data-astro-prefetch="load" should prefetch', async ({ page, astro }) => {
100136
await page.goto(astro.resolveUrl('/'));
101-
expect(reqUrls).toContainEqual('/prefetch-load');
102-
expect(page.locator('link[rel="prefetch"][href$="/prefetch-load"]')).toBeDefined();
137+
await expectUrlPrefetched('/prefetch-load', page);
103138
});
104139
});
105140

106141
test.describe("Prefetch (prefetchAll: true, defaultStrategy: 'tap')", () => {
107142
let devServer;
108-
/** @type {string[]} */
109-
const reqUrls = [];
110143

111144
test.beforeAll(async ({ astro }) => {
112145
devServer = await astro.startDevServer({
@@ -117,89 +150,74 @@ test.describe("Prefetch (prefetchAll: true, defaultStrategy: 'tap')", () => {
117150
});
118151
});
119152

120-
test.beforeEach(async ({ page }) => {
121-
page.on('request', (req) => {
122-
const urlObj = new URL(req.url());
123-
reqUrls.push(urlObj.pathname + urlObj.search);
124-
});
125-
});
126-
127-
test.afterEach(() => {
128-
reqUrls.length = 0;
129-
});
130-
131153
test.afterAll(async () => {
132154
await devServer.stop();
133155
});
134156

135157
test('Link without data-astro-prefetch should prefetch', async ({ page, astro }) => {
136158
await page.goto(astro.resolveUrl('/'));
137-
expect(reqUrls).not.toContainEqual('/prefetch-default');
159+
await expectUrlNotPrefetched('/prefetch-default', page);
138160
await Promise.all([
139161
page.waitForEvent('request'), // wait prefetch request
140162
page.locator('#prefetch-default').click(),
141163
]);
142-
expect(reqUrls).toContainEqual('/prefetch-default');
164+
await expectUrlPrefetched('/prefetch-default', page);
143165
});
144166

145167
test('data-astro-prefetch="false" should not prefetch', async ({ page, astro }) => {
146168
await page.goto(astro.resolveUrl('/'));
147-
expect(reqUrls).not.toContainEqual('/prefetch-false');
169+
await expectUrlNotPrefetched('/prefetch-false', page);
148170
});
149171

150172
test('Link with search param should prefetch', async ({ page, astro }) => {
151173
await page.goto(astro.resolveUrl('/'));
152-
expect(reqUrls).not.toContainEqual('/?search-param=true');
174+
await expectUrlNotPrefetched('/?search-param=true', page);
153175
await Promise.all([
154176
page.waitForEvent('request'), // wait prefetch request
155177
page.locator('#prefetch-search-param').hover(),
156178
]);
157-
expect(reqUrls).toContainEqual('/?search-param=true');
179+
await expectUrlPrefetched('/?search-param=true', page);
158180
});
159181

160182
test('data-astro-prefetch="tap" should prefetch on tap', async ({ page, astro }) => {
161183
await page.goto(astro.resolveUrl('/'));
162-
expect(reqUrls).not.toContainEqual('/prefetch-tap');
184+
await expectUrlNotPrefetched('/prefetch-tap', page);
163185
await Promise.all([
164186
page.waitForEvent('request'), // wait prefetch request
165187
page.locator('#prefetch-tap').click(),
166188
]);
167-
expect(reqUrls).toContainEqual('/prefetch-tap');
189+
await expectUrlPrefetched('/prefetch-tap', page);
168190
});
169191

170192
test('data-astro-prefetch="hover" should prefetch on hover', async ({ page, astro }) => {
171193
await page.goto(astro.resolveUrl('/'));
172-
expect(reqUrls).not.toContainEqual('/prefetch-hover');
194+
await expectUrlNotPrefetched('/prefetch-hover', page);
173195
await Promise.all([
174196
page.waitForEvent('request'), // wait prefetch request
175197
page.locator('#prefetch-hover').hover(),
176198
]);
177-
expect(reqUrls).toContainEqual('/prefetch-hover');
199+
await expectUrlPrefetched('/prefetch-hover', page);
178200
});
179201

180202
test('data-astro-prefetch="viewport" should prefetch on viewport', async ({ page, astro }) => {
181203
await page.goto(astro.resolveUrl('/'));
182-
expect(reqUrls).not.toContainEqual('/prefetch-viewport');
204+
await expectUrlNotPrefetched('/prefetch-viewport', page);
183205
// Scroll down to show the element
184206
await Promise.all([
185207
page.waitForEvent('request'), // wait prefetch request
186208
page.locator('#prefetch-viewport').scrollIntoViewIfNeeded(),
187209
]);
188-
expect(reqUrls).toContainEqual('/prefetch-viewport');
189-
expect(page.locator('link[rel="prefetch"][href$="/prefetch-viewport"]')).toBeDefined();
210+
await expectUrlPrefetched('/prefetch-viewport', page);
190211
});
191212

192213
test('data-astro-prefetch="load" should prefetch', async ({ page, astro }) => {
193214
await page.goto(astro.resolveUrl('/'));
194-
expect(reqUrls).toContainEqual('/prefetch-load');
195-
expect(page.locator('link[rel="prefetch"][href$="/prefetch-load"]')).toBeDefined();
215+
await expectUrlPrefetched('/prefetch-load', page);
196216
});
197217
});
198218

199219
test.describe("Prefetch (prefetchAll: true, defaultStrategy: 'load')", () => {
200220
let devServer;
201-
/** @type {string[]} */
202-
const reqUrls = [];
203221

204222
test.beforeAll(async ({ astro }) => {
205223
devServer = await astro.startDevServer({
@@ -210,78 +228,64 @@ test.describe("Prefetch (prefetchAll: true, defaultStrategy: 'load')", () => {
210228
});
211229
});
212230

213-
test.beforeEach(async ({ page }) => {
214-
page.on('request', (req) => {
215-
const urlObj = new URL(req.url());
216-
reqUrls.push(urlObj.pathname + urlObj.search);
217-
});
218-
});
219-
220-
test.afterEach(() => {
221-
reqUrls.length = 0;
222-
});
223-
224231
test.afterAll(async () => {
225232
await devServer.stop();
226233
});
227234

228235
test('Link without data-astro-prefetch should prefetch', async ({ page, astro }) => {
229236
await page.goto(astro.resolveUrl('/'));
230-
expect(reqUrls).toContainEqual('/prefetch-default');
231-
expect(page.locator('link[rel="prefetch"][href$="/prefetch-default"]')).toBeDefined();
237+
await expectUrlPrefetched('/prefetch-default', page);
232238
});
233239

234240
test('data-astro-prefetch="false" should not prefetch', async ({ page, astro }) => {
235241
await page.goto(astro.resolveUrl('/'));
236-
expect(reqUrls).not.toContainEqual('/prefetch-false');
242+
await expectUrlNotPrefetched('/prefetch-false', page);
237243
});
238244

239245
test('Link with search param should prefetch', async ({ page, astro }) => {
240246
await page.goto(astro.resolveUrl('/'));
241-
expect(reqUrls).not.toContainEqual('/?search-param=true');
247+
await expectUrlNotPrefetched('/?search-param=true', page);
242248
await Promise.all([
243249
page.waitForEvent('request'), // wait prefetch request
244250
page.locator('#prefetch-search-param').hover(),
245251
]);
246-
expect(reqUrls).toContainEqual('/?search-param=true');
252+
await expectUrlPrefetched('/?search-param=true', page);
247253
});
248254

249255
test('data-astro-prefetch="tap" should prefetch on tap', async ({ page, astro }) => {
250256
await page.goto(astro.resolveUrl('/'));
251-
expect(reqUrls).not.toContainEqual('/prefetch-tap');
257+
await expectUrlNotPrefetched('/prefetch-tap', page);
252258
await Promise.all([
253259
page.waitForEvent('request'), // wait prefetch request
254260
page.locator('#prefetch-tap').click(),
255261
]);
256-
expect(reqUrls).toContainEqual('/prefetch-tap');
262+
await expectUrlPrefetched('/prefetch-tap', page);
257263
});
258264

259265
test('data-astro-prefetch="hover" should prefetch on hover', async ({ page, astro }) => {
260266
await page.goto(astro.resolveUrl('/'));
261-
expect(reqUrls).not.toContainEqual('/prefetch-hover');
267+
await expectUrlNotPrefetched('/prefetch-hover', page);
262268
await Promise.all([
263269
page.waitForEvent('request'), // wait prefetch request
264270
page.locator('#prefetch-hover').hover(),
265271
]);
266-
expect(reqUrls).toContainEqual('/prefetch-hover');
272+
await expectUrlPrefetched('/prefetch-hover', page);
267273
});
268274

269275
test('data-astro-prefetch="viewport" should prefetch on viewport', async ({ page, astro }) => {
270276
await page.goto(astro.resolveUrl('/'));
271-
expect(reqUrls).not.toContainEqual('/prefetch-viewport');
277+
await expectUrlNotPrefetched('/prefetch-viewport', page);
272278
// Scroll down to show the element
273279
await Promise.all([
274280
page.waitForEvent('request'), // wait prefetch request
275281
page.locator('#prefetch-viewport').scrollIntoViewIfNeeded(),
276282
]);
277-
expect(reqUrls).toContainEqual('/prefetch-viewport');
278-
expect(page.locator('link[rel="prefetch"][href$="/prefetch-viewport"]')).toBeDefined();
283+
await expectUrlPrefetched('/prefetch-viewport', page);
279284
});
280285

281286
test('data-astro-prefetch="load" should prefetch', async ({ page, astro }) => {
282287
await page.goto(astro.resolveUrl('/'));
283-
expect(reqUrls).toContainEqual('/prefetch-load');
284-
expect(page.locator('link[rel="prefetch"][href$="/prefetch-load"]')).toBeDefined();
288+
await expectUrlPrefetched('/prefetch-load', page);
285289
});
286290
});
287291

@@ -311,7 +315,9 @@ test.describe('Prefetch (default), Experimental ({ clientPrerender: true })', ()
311315

312316
let devServer;
313317

314-
test.beforeAll(async ({ astro }) => {
318+
test.beforeAll(async ({ astro, browserName }) => {
319+
test.skip(browserName !== 'chromium', 'Only Chromium supports clientPrerender')
320+
315321
devServer = await astro.startDevServer({
316322
experimental: {
317323
clientPrerender: true,
@@ -320,7 +326,7 @@ test.describe('Prefetch (default), Experimental ({ clientPrerender: true })', ()
320326
});
321327

322328
test.afterAll(async () => {
323-
await devServer.stop();
329+
await devServer?.stop();
324330
});
325331

326332
test('Link without data-astro-prefetch should not prefetch', async ({ page, astro }) => {

‎packages/astro/playwright.firefox.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ process.stdout.isTTY = false;
55

66
const config = {
77
// TODO: add more tests like view transitions and audits, and fix them. Some of them are failing.
8-
testMatch: ['e2e/css.test.js'],
8+
testMatch: ['e2e/css.test.js', 'e2e/prefetch.test.js'],
99
/* Maximum time one test can run for. */
1010
timeout: 40 * 1000,
1111
expect: {

‎packages/astro/src/prefetch/index.ts

+26-23
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ function initTapStrategy() {
5959
event,
6060
(e) => {
6161
if (elMatchesStrategy(e.target, 'tap')) {
62-
prefetch(e.target.href, { with: 'fetch', ignoreSlowConnection: true });
62+
prefetch(e.target.href, { ignoreSlowConnection: true });
6363
}
6464
},
6565
{ passive: true }
@@ -107,7 +107,7 @@ function initHoverStrategy() {
107107
clearTimeout(timeout);
108108
}
109109
timeout = setTimeout(() => {
110-
prefetch(href, { with: 'fetch' });
110+
prefetch(href);
111111
}, 80) as unknown as number;
112112
}
113113

@@ -158,7 +158,7 @@ function createViewportIntersectionObserver() {
158158
setTimeout(() => {
159159
observer.unobserve(anchor);
160160
timeouts.delete(anchor);
161-
prefetch(anchor.href, { with: 'link' });
161+
prefetch(anchor.href);
162162
}, 300) as unknown as number
163163
);
164164
} else {
@@ -180,7 +180,7 @@ function initLoadStrategy() {
180180
for (const anchor of document.getElementsByTagName('a')) {
181181
if (elMatchesStrategy(anchor, 'load')) {
182182
// Prefetch every link in this page
183-
prefetch(anchor.href, { with: 'link' });
183+
prefetch(anchor.href);
184184
}
185185
}
186186
});
@@ -189,8 +189,12 @@ function initLoadStrategy() {
189189
export interface PrefetchOptions {
190190
/**
191191
* How the prefetch should prioritize the URL. (default `'link'`)
192-
* - `'link'`: use `<link rel="prefetch">`, has lower loading priority.
193-
* - `'fetch'`: use `fetch()`, has higher loading priority.
192+
* - `'link'`: use `<link rel="prefetch">`.
193+
* - `'fetch'`: use `fetch()`.
194+
*
195+
* @deprecated It is recommended to not use this option, and let prefetch use `'link'` whenever it's supported,
196+
* or otherwise fall back to `'fetch'`. `'link'` works better if the URL doesn't set an appropriate cache header,
197+
* as the browser will continue to cache it as long as it's used subsequently.
194198
*/
195199
with?: 'link' | 'fetch';
196200
/**
@@ -215,28 +219,27 @@ export function prefetch(url: string, opts?: PrefetchOptions) {
215219
if (!canPrefetchUrl(url, ignoreSlowConnection)) return;
216220
prefetchedUrls.add(url);
217221

218-
const priority = opts?.with ?? 'link';
219-
debug?.(`[astro] Prefetching ${url} with ${priority}`);
220-
221-
if (
222-
clientPrerender &&
223-
HTMLScriptElement.supports &&
224-
HTMLScriptElement.supports('speculationrules')
225-
) {
226-
// this code is tree-shaken if unused
222+
// Prefetch with speculationrules if `clientPrerender` is enabled and supported
223+
// NOTE: This condition is tree-shaken if `clientPrerender` is false as its a static value
224+
if (clientPrerender && HTMLScriptElement.supports?.('speculationrules')) {
225+
debug?.(`[astro] Prefetching ${url} with <script type="speculationrules">`);
227226
appendSpeculationRules(url);
228-
} else if (priority === 'link') {
227+
}
228+
// Prefetch with link if supported
229+
else if (
230+
document.createElement('link').relList?.supports?.('prefetch') &&
231+
opts?.with !== 'fetch'
232+
) {
233+
debug?.(`[astro] Prefetching ${url} with <link rel="prefetch">`);
229234
const link = document.createElement('link');
230235
link.rel = 'prefetch';
231236
link.setAttribute('href', url);
232237
document.head.append(link);
233-
} else {
234-
fetch(url).catch((e) => {
235-
// eslint-disable-next-line no-console
236-
console.log(`[astro] Failed to prefetch ${url}`);
237-
// eslint-disable-next-line no-console
238-
console.error(e);
239-
});
238+
}
239+
// Otherwise, fallback prefetch with fetch
240+
else {
241+
debug?.(`[astro] Prefetching ${url} with fetch`);
242+
fetch(url, { priority: 'low' });
240243
}
241244
}
242245

0 commit comments

Comments
 (0)
Please sign in to comment.