Skip to content

Commit

Permalink
feat: add experimental client prerender (#9644)
Browse files Browse the repository at this point in the history
* feat: add experimental client prerender

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <[email protected]>

* 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 <[email protected]>

* Update packages/astro/src/prefetch/index.ts

Co-authored-by: Bjorn Lu <[email protected]>

* Update .changeset/sixty-dogs-sneeze.md

Co-authored-by: Sarah Rainsberger <[email protected]>

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <[email protected]>

* Update .changeset/sixty-dogs-sneeze.md

Co-authored-by: Sarah Rainsberger <[email protected]>

* Update .changeset/sixty-dogs-sneeze.md

Co-authored-by: Sarah Rainsberger <[email protected]>

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <[email protected]>

---------

Co-authored-by: Emanuele Stoppa <[email protected]>
Co-authored-by: Sarah Rainsberger <[email protected]>
Co-authored-by: Bjorn Lu <[email protected]>
  • Loading branch information
4 people authored Jan 17, 2024
1 parent 9680cf2 commit a5f1682
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 2 deletions.
24 changes: 24 additions & 0 deletions .changeset/sixty-dogs-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"astro": minor
---

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).

```js
// astro.config.mjs
{
prefetch: {
prefetchAll: true,
defaultStrategy: 'viewport',
},
experimental: {
clientPrerender: true,
},
}
```

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.

Client side prerendering requires browser support. If the Speculation Rules API is not supported, `prefetch` will fallback to the supported strategy.

See the [Prefetch Guide](https://docs.astro.build/en/guides/prefetch/) for more `prefetch` options and usage.
103 changes: 103 additions & 0 deletions packages/astro/e2e/prefetch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,106 @@ test.describe("Prefetch (prefetchAll: true, defaultStrategy: 'load')", () => {
expect(page.locator('link[rel="prefetch"][href$="/prefetch-load"]')).toBeDefined();
});
});

// Playwrights `request` event does not appear to fire when using the speculation rules API
// Instead of checking for the added url, each test checks to see if `document.head`
// contains a `script[type=speculationrules]` that has the `url` in it.
test.describe('Prefetch (default), Experimental ({ clientPrerender: true })', () => {
/**
* @param {import('@playwright/test').Page} page
* @param {string} url
* @returns the number of script[type=speculationrules] that have the url
*/
async function scriptIsInHead(page, url) {
return await page.evaluate((testUrl) => {
const scripts = document.head.querySelectorAll('script[type="speculationrules"]');
let count = 0;
for (const script of scripts) {
/** @type {{ prerender: { urls: string[] }[] }} */
const speculationRules = JSON.parse(script.textContent);
const specUrl = speculationRules.prerender.at(0).urls.at(0);
const indexOf = specUrl.indexOf(testUrl);
if (indexOf > -1) count++;
}
return count;
}, url);
}

let devServer;

test.beforeAll(async ({ astro }) => {
devServer = await astro.startDevServer({
experimental: {
clientPrerender: true,
},
});
});

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

test('Link without data-astro-prefetch should not prefetch', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
expect(await scriptIsInHead(page, '/prefetch-default')).toBeFalsy();
});

test('data-astro-prefetch="false" should not prefetch', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
expect(await scriptIsInHead(page, '/prefetch-false')).toBeFalsy();
});

test('Link with search param should prefetch', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
expect(await scriptIsInHead(page, '?search-param=true')).toBeFalsy();
await page.locator('#prefetch-search-param').hover();
await page.waitForFunction(
() => document.querySelectorAll('script[type=speculationrules]').length === 2
);
expect(await scriptIsInHead(page, '?search-param=true')).toBeTruthy();
});

test('data-astro-prefetch="tap" should prefetch on tap', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
expect(await scriptIsInHead(page, '/prefetch-tap')).toBeFalsy();
await page.locator('#prefetch-tap').dragTo(page.locator('#prefetch-hover'));
expect(await scriptIsInHead(page, '/prefetch-tap')).toBeTruthy();
});

test('data-astro-prefetch="hover" should prefetch on hover', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
expect(await scriptIsInHead(page, '/prefetch-hover')).toBeFalsy();
await page.locator('#prefetch-hover').hover();
await page.waitForFunction(
() => document.querySelectorAll('script[type=speculationrules]').length === 2
);
expect(await scriptIsInHead(page, '/prefetch-hover')).toBeTruthy();
});

test('data-astro-prefetch="viewport" should prefetch on viewport', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
expect(await scriptIsInHead(page, '/prefetch-viewport')).toBeFalsy();
// Scroll down to show the element
await page.locator('#prefetch-viewport').scrollIntoViewIfNeeded();
await page.waitForFunction(
() => document.querySelectorAll('script[type=speculationrules]').length === 2
);
expect(await scriptIsInHead(page, '/prefetch-viewport')).toBeTruthy();
});

test('manual prefetch() works once', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
expect(await scriptIsInHead(page, '/prefetch-manual')).toEqual(0);
await page.locator('#prefetch-manual').click();
expect(await scriptIsInHead(page, '/prefetch-manual')).toEqual(1);

// prefetch again should have no effect
await page.locator('#prefetch-manual').click();
expect(await scriptIsInHead(page, '/prefetch-manual')).toEqual(1);
});

test('data-astro-prefetch="load" should prefetch', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
expect(await scriptIsInHead(page, 'prefetch-load')).toBeTruthy();
});
});
36 changes: 36 additions & 0 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1566,6 +1566,42 @@ export interface AstroUserConfig {
* ```
*/
contentCollectionCache?: boolean;

/**
* @docs
* @name experimental.clientPrerender
* @type {boolean}
* @default `false`
* @version: 4.2.0
* @description
* Enables pre-rendering your prefetched pages on the client in supported browsers.
*
* 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.
* 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.
*
* Enable client side prerendering in your `astro.config.mjs` along with any desired `prefetch` configuration options:
*
* ```js
* // astro.config.mjs
* {
* prefetch: {
* prefetchAll: true,
* defaultStrategy: 'viewport',
* },
* experimental: {
* clientPrerender: true,
* },
* }
* ```
*
* Continue to use the `data-astro-prefetch` attribute on any `<a />` link on your site to opt in to prefetching.
* 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.
*
* Client side prerendering requires browser support. If the Speculation Rules API is not supported, `prefetch` will fallback to the supported strategy.
*
* See the [Prefetch Guide](https://docs.astro.build/en/guides/prefetch/) for more `prefetch` options and usage.
*/
clientPrerender?: boolean;
};
}

Expand Down
5 changes: 5 additions & 0 deletions packages/astro/src/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const ASTRO_CONFIG_DEFAULTS = {
experimental: {
optimizeHoistedScript: false,
contentCollectionCache: false,
clientPrerender: false,
},
} satisfies AstroUserConfig & { server: { open: boolean } };

Expand Down Expand Up @@ -393,6 +394,10 @@ export const AstroConfigSchema = z.object({
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.contentCollectionCache),
clientPrerender: z
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.clientPrerender),
})
.strict(
`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.`
Expand Down
34 changes: 33 additions & 1 deletion packages/astro/src/prefetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const listenedAnchors = new WeakSet<HTMLAnchorElement>();
let prefetchAll: boolean = __PREFETCH_PREFETCH_ALL__;
// @ts-expect-error injected global
let defaultStrategy: string = __PREFETCH_DEFAULT_STRATEGY__;
// @ts-expect-error injected global
let clientPrerender: boolean = __EXPERIMENTAL_CLIENT_PRERENDER__;

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

if (priority === 'link') {
if (
clientPrerender &&
HTMLScriptElement.supports &&
HTMLScriptElement.supports('speculationrules')
) {
// this code is tree-shaken if unused
appendSpeculationRules(url);
} else if (priority === 'link') {
const link = document.createElement('link');
link.rel = 'prefetch';
link.setAttribute('href', url);
Expand Down Expand Up @@ -301,3 +310,26 @@ function onPageLoad(cb: () => void) {
cb();
});
}

/**
* Appends a `<script type="speculationrules">` tag to the head of the
* document that prerenders the `url` passed in.
*
* Modifying the script and appending a new link does not trigger the prerender.
* A new script must be added for each `url`.
*
* @param url The url of the page to prerender.
*/
function appendSpeculationRules(url: string) {
const script = document.createElement('script');
script.type = 'speculationrules';
script.textContent = JSON.stringify({
prerender: [
{
source: 'list',
urls: [url],
},
],
});
document.head.append(script);
}
6 changes: 5 additions & 1 deletion packages/astro/src/prefetch/vite-plugin-prefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ export default function astroPrefetch({ settings }: { settings: AstroSettings })
if (id.includes(prefetchInternalModuleFsSubpath)) {
return code
.replace('__PREFETCH_PREFETCH_ALL__', JSON.stringify(prefetch?.prefetchAll))
.replace('__PREFETCH_DEFAULT_STRATEGY__', JSON.stringify(prefetch?.defaultStrategy));
.replace('__PREFETCH_DEFAULT_STRATEGY__', JSON.stringify(prefetch?.defaultStrategy))
.replace(
'__EXPERIMENTAL_CLIENT_PRERENDER__',
JSON.stringify(settings.config.experimental.clientPrerender)
);
}
},
};
Expand Down

0 comments on commit a5f1682

Please sign in to comment.