Skip to content

Commit a5f1682

Browse files
rossrobinoematipicosarah11918bluwy
authoredJan 17, 2024
feat: add experimental client prerender (#9644)
* feat: add experimental client prerender * Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * docs: add more details about effects of the feature * add changeset * add tests * edit jsdoc and changeset with suggestions * Update packages/astro/src/@types/astro.ts Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com> * Update packages/astro/src/prefetch/index.ts Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com> * Update .changeset/sixty-dogs-sneeze.md Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * Update .changeset/sixty-dogs-sneeze.md Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * Update .changeset/sixty-dogs-sneeze.md Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> --------- Co-authored-by: Emanuele Stoppa <my.burning@gmail.com> Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>
1 parent 9680cf2 commit a5f1682

File tree

6 files changed

+206
-2
lines changed

6 files changed

+206
-2
lines changed
 

‎.changeset/sixty-dogs-sneeze.md

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"astro": minor
3+
---
4+
5+
Adds an experimental flag `clientPrerender` to prerender your prefetched pages on the client with the [Speculation Rules API](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API).
6+
7+
```js
8+
// astro.config.mjs
9+
{
10+
prefetch: {
11+
prefetchAll: true,
12+
defaultStrategy: 'viewport',
13+
},
14+
experimental: {
15+
clientPrerender: true,
16+
},
17+
}
18+
```
19+
20+
Enabling this feature overrides the default `prefetch` behavior globally to prerender links on the client according to your `prefetch` configuration. Instead of appending a `<link>` tag to the head of the document or fetching the page with JavaScript, a `<script>` tag will be appended with the corresponding speculation rules.
21+
22+
Client side prerendering requires browser support. If the Speculation Rules API is not supported, `prefetch` will fallback to the supported strategy.
23+
24+
See the [Prefetch Guide](https://docs.astro.build/en/guides/prefetch/) for more `prefetch` options and usage.

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

+103
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,106 @@ test.describe("Prefetch (prefetchAll: true, defaultStrategy: 'load')", () => {
284284
expect(page.locator('link[rel="prefetch"][href$="/prefetch-load"]')).toBeDefined();
285285
});
286286
});
287+
288+
// Playwrights `request` event does not appear to fire when using the speculation rules API
289+
// Instead of checking for the added url, each test checks to see if `document.head`
290+
// contains a `script[type=speculationrules]` that has the `url` in it.
291+
test.describe('Prefetch (default), Experimental ({ clientPrerender: true })', () => {
292+
/**
293+
* @param {import('@playwright/test').Page} page
294+
* @param {string} url
295+
* @returns the number of script[type=speculationrules] that have the url
296+
*/
297+
async function scriptIsInHead(page, url) {
298+
return await page.evaluate((testUrl) => {
299+
const scripts = document.head.querySelectorAll('script[type="speculationrules"]');
300+
let count = 0;
301+
for (const script of scripts) {
302+
/** @type {{ prerender: { urls: string[] }[] }} */
303+
const speculationRules = JSON.parse(script.textContent);
304+
const specUrl = speculationRules.prerender.at(0).urls.at(0);
305+
const indexOf = specUrl.indexOf(testUrl);
306+
if (indexOf > -1) count++;
307+
}
308+
return count;
309+
}, url);
310+
}
311+
312+
let devServer;
313+
314+
test.beforeAll(async ({ astro }) => {
315+
devServer = await astro.startDevServer({
316+
experimental: {
317+
clientPrerender: true,
318+
},
319+
});
320+
});
321+
322+
test.afterAll(async () => {
323+
await devServer.stop();
324+
});
325+
326+
test('Link without data-astro-prefetch should not prefetch', async ({ page, astro }) => {
327+
await page.goto(astro.resolveUrl('/'));
328+
expect(await scriptIsInHead(page, '/prefetch-default')).toBeFalsy();
329+
});
330+
331+
test('data-astro-prefetch="false" should not prefetch', async ({ page, astro }) => {
332+
await page.goto(astro.resolveUrl('/'));
333+
expect(await scriptIsInHead(page, '/prefetch-false')).toBeFalsy();
334+
});
335+
336+
test('Link with search param should prefetch', async ({ page, astro }) => {
337+
await page.goto(astro.resolveUrl('/'));
338+
expect(await scriptIsInHead(page, '?search-param=true')).toBeFalsy();
339+
await page.locator('#prefetch-search-param').hover();
340+
await page.waitForFunction(
341+
() => document.querySelectorAll('script[type=speculationrules]').length === 2
342+
);
343+
expect(await scriptIsInHead(page, '?search-param=true')).toBeTruthy();
344+
});
345+
346+
test('data-astro-prefetch="tap" should prefetch on tap', async ({ page, astro }) => {
347+
await page.goto(astro.resolveUrl('/'));
348+
expect(await scriptIsInHead(page, '/prefetch-tap')).toBeFalsy();
349+
await page.locator('#prefetch-tap').dragTo(page.locator('#prefetch-hover'));
350+
expect(await scriptIsInHead(page, '/prefetch-tap')).toBeTruthy();
351+
});
352+
353+
test('data-astro-prefetch="hover" should prefetch on hover', async ({ page, astro }) => {
354+
await page.goto(astro.resolveUrl('/'));
355+
expect(await scriptIsInHead(page, '/prefetch-hover')).toBeFalsy();
356+
await page.locator('#prefetch-hover').hover();
357+
await page.waitForFunction(
358+
() => document.querySelectorAll('script[type=speculationrules]').length === 2
359+
);
360+
expect(await scriptIsInHead(page, '/prefetch-hover')).toBeTruthy();
361+
});
362+
363+
test('data-astro-prefetch="viewport" should prefetch on viewport', async ({ page, astro }) => {
364+
await page.goto(astro.resolveUrl('/'));
365+
expect(await scriptIsInHead(page, '/prefetch-viewport')).toBeFalsy();
366+
// Scroll down to show the element
367+
await page.locator('#prefetch-viewport').scrollIntoViewIfNeeded();
368+
await page.waitForFunction(
369+
() => document.querySelectorAll('script[type=speculationrules]').length === 2
370+
);
371+
expect(await scriptIsInHead(page, '/prefetch-viewport')).toBeTruthy();
372+
});
373+
374+
test('manual prefetch() works once', async ({ page, astro }) => {
375+
await page.goto(astro.resolveUrl('/'));
376+
expect(await scriptIsInHead(page, '/prefetch-manual')).toEqual(0);
377+
await page.locator('#prefetch-manual').click();
378+
expect(await scriptIsInHead(page, '/prefetch-manual')).toEqual(1);
379+
380+
// prefetch again should have no effect
381+
await page.locator('#prefetch-manual').click();
382+
expect(await scriptIsInHead(page, '/prefetch-manual')).toEqual(1);
383+
});
384+
385+
test('data-astro-prefetch="load" should prefetch', async ({ page, astro }) => {
386+
await page.goto(astro.resolveUrl('/'));
387+
expect(await scriptIsInHead(page, 'prefetch-load')).toBeTruthy();
388+
});
389+
});

‎packages/astro/src/@types/astro.ts

+36
Original file line numberDiff line numberDiff line change
@@ -1566,6 +1566,42 @@ export interface AstroUserConfig {
15661566
* ```
15671567
*/
15681568
contentCollectionCache?: boolean;
1569+
1570+
/**
1571+
* @docs
1572+
* @name experimental.clientPrerender
1573+
* @type {boolean}
1574+
* @default `false`
1575+
* @version: 4.2.0
1576+
* @description
1577+
* Enables pre-rendering your prefetched pages on the client in supported browsers.
1578+
*
1579+
* This feature uses the experimental [Speculation Rules Web API](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API) and overrides the default `prefetch` behavior globally to prerender links on the client.
1580+
* You may wish to review the [possible risks when prerendering on the client](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API#unsafe_prefetching) before enabling this feature.
1581+
*
1582+
* Enable client side prerendering in your `astro.config.mjs` along with any desired `prefetch` configuration options:
1583+
*
1584+
* ```js
1585+
* // astro.config.mjs
1586+
* {
1587+
* prefetch: {
1588+
* prefetchAll: true,
1589+
* defaultStrategy: 'viewport',
1590+
* },
1591+
* experimental: {
1592+
* clientPrerender: true,
1593+
* },
1594+
* }
1595+
* ```
1596+
*
1597+
* Continue to use the `data-astro-prefetch` attribute on any `<a />` link on your site to opt in to prefetching.
1598+
* Instead of appending a `<link>` tag to the head of the document or fetching the page with JavaScript, a `<script>` tag will be appended with the corresponding speculation rules.
1599+
*
1600+
* Client side prerendering requires browser support. If the Speculation Rules API is not supported, `prefetch` will fallback to the supported strategy.
1601+
*
1602+
* See the [Prefetch Guide](https://docs.astro.build/en/guides/prefetch/) for more `prefetch` options and usage.
1603+
*/
1604+
clientPrerender?: boolean;
15691605
};
15701606
}
15711607

‎packages/astro/src/core/config/schema.ts

+5
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const ASTRO_CONFIG_DEFAULTS = {
5858
experimental: {
5959
optimizeHoistedScript: false,
6060
contentCollectionCache: false,
61+
clientPrerender: false,
6162
},
6263
} satisfies AstroUserConfig & { server: { open: boolean } };
6364

@@ -393,6 +394,10 @@ export const AstroConfigSchema = z.object({
393394
.boolean()
394395
.optional()
395396
.default(ASTRO_CONFIG_DEFAULTS.experimental.contentCollectionCache),
397+
clientPrerender: z
398+
.boolean()
399+
.optional()
400+
.default(ASTRO_CONFIG_DEFAULTS.experimental.clientPrerender),
396401
})
397402
.strict(
398403
`Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.`

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

+33-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const listenedAnchors = new WeakSet<HTMLAnchorElement>();
1616
let prefetchAll: boolean = __PREFETCH_PREFETCH_ALL__;
1717
// @ts-expect-error injected global
1818
let defaultStrategy: string = __PREFETCH_DEFAULT_STRATEGY__;
19+
// @ts-expect-error injected global
20+
let clientPrerender: boolean = __EXPERIMENTAL_CLIENT_PRERENDER__;
1921

2022
interface InitOptions {
2123
defaultStrategy?: string;
@@ -216,7 +218,14 @@ export function prefetch(url: string, opts?: PrefetchOptions) {
216218
const priority = opts?.with ?? 'link';
217219
debug?.(`[astro] Prefetching ${url} with ${priority}`);
218220

219-
if (priority === 'link') {
221+
if (
222+
clientPrerender &&
223+
HTMLScriptElement.supports &&
224+
HTMLScriptElement.supports('speculationrules')
225+
) {
226+
// this code is tree-shaken if unused
227+
appendSpeculationRules(url);
228+
} else if (priority === 'link') {
220229
const link = document.createElement('link');
221230
link.rel = 'prefetch';
222231
link.setAttribute('href', url);
@@ -301,3 +310,26 @@ function onPageLoad(cb: () => void) {
301310
cb();
302311
});
303312
}
313+
314+
/**
315+
* Appends a `<script type="speculationrules">` tag to the head of the
316+
* document that prerenders the `url` passed in.
317+
*
318+
* Modifying the script and appending a new link does not trigger the prerender.
319+
* A new script must be added for each `url`.
320+
*
321+
* @param url The url of the page to prerender.
322+
*/
323+
function appendSpeculationRules(url: string) {
324+
const script = document.createElement('script');
325+
script.type = 'speculationrules';
326+
script.textContent = JSON.stringify({
327+
prerender: [
328+
{
329+
source: 'list',
330+
urls: [url],
331+
},
332+
],
333+
});
334+
document.head.append(script);
335+
}

‎packages/astro/src/prefetch/vite-plugin-prefetch.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@ export default function astroPrefetch({ settings }: { settings: AstroSettings })
4949
if (id.includes(prefetchInternalModuleFsSubpath)) {
5050
return code
5151
.replace('__PREFETCH_PREFETCH_ALL__', JSON.stringify(prefetch?.prefetchAll))
52-
.replace('__PREFETCH_DEFAULT_STRATEGY__', JSON.stringify(prefetch?.defaultStrategy));
52+
.replace('__PREFETCH_DEFAULT_STRATEGY__', JSON.stringify(prefetch?.defaultStrategy))
53+
.replace(
54+
'__EXPERIMENTAL_CLIENT_PRERENDER__',
55+
JSON.stringify(settings.config.experimental.clientPrerender)
56+
);
5357
}
5458
},
5559
};

0 commit comments

Comments
 (0)
Please sign in to comment.