mirror of
https://github.com/withastro/astro.git
synced 2025-03-24 23:21:57 -05:00
Support prefetch in core (#8951)
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
parent
23c9a30ad8
commit
38e21d1275
23 changed files with 688 additions and 41 deletions
21
.changeset/sixty-laws-argue.md
Normal file
21
.changeset/sixty-laws-argue.md
Normal file
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
'astro': minor
|
||||
---
|
||||
|
||||
Prefetching is now supported in core
|
||||
|
||||
You can enable prefetching for your site with the `prefetch: true` config. It is enabled by default when using [View Transitions](https://docs.astro.build/en/guides/view-transitions/) and can also be used to configure the `prefetch` behaviour used by View Transitions.
|
||||
|
||||
You can enable prefetching by setting `prefetch:true` in your Astro config:
|
||||
|
||||
```js
|
||||
// astro.config.js
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
export default defineConfig({
|
||||
prefetch: true
|
||||
})
|
||||
```
|
||||
|
||||
This replaces the `@astrojs/prefetch` integration, which is now deprecated and will eventually be removed.
|
||||
Visit the [Prefetch guide](https://docs.astro.build/en/guides/prefetch/) for more information.
|
4
packages/astro/client.d.ts
vendored
4
packages/astro/client.d.ts
vendored
|
@ -121,6 +121,10 @@ declare module 'astro:transitions/client' {
|
|||
export const navigate: TransitionRouterModule['navigate'];
|
||||
}
|
||||
|
||||
declare module 'astro:prefetch' {
|
||||
export { prefetch, PrefetchOptions } from 'astro/prefetch';
|
||||
}
|
||||
|
||||
declare module 'astro:middleware' {
|
||||
export * from 'astro/middleware/namespace';
|
||||
}
|
||||
|
|
|
@ -25,11 +25,9 @@ const { fallback = 'animate' } = Astro.props;
|
|||
<meta name="astro-view-transitions-enabled" content="true" />
|
||||
<meta name="astro-view-transitions-fallback" content={fallback} />
|
||||
<script>
|
||||
import {
|
||||
supportsViewTransitions,
|
||||
transitionEnabledOnThisPage,
|
||||
navigate,
|
||||
} from 'astro:transitions/client';
|
||||
import { supportsViewTransitions, navigate } from 'astro:transitions/client';
|
||||
// NOTE: import from `astro/prefetch` as `astro:prefetch` requires the `prefetch` config to be enabled
|
||||
import { init } from 'astro/prefetch';
|
||||
export type Fallback = 'none' | 'animate' | 'swap';
|
||||
|
||||
function getFallback(): Fallback {
|
||||
|
@ -40,21 +38,6 @@ const { fallback = 'animate' } = Astro.props;
|
|||
return 'animate';
|
||||
}
|
||||
|
||||
// Prefetching
|
||||
function maybePrefetch(pathname: string) {
|
||||
if (document.querySelector(`link[rel=prefetch][href="${pathname}"]`)) return;
|
||||
// @ts-expect-error: connection might exist
|
||||
if (navigator.connection) {
|
||||
// @ts-expect-error: connection does exist
|
||||
let conn = navigator.connection;
|
||||
if (conn.saveData || /(2|3)g/.test(conn.effectiveType || '')) return;
|
||||
}
|
||||
let link = document.createElement('link');
|
||||
link.setAttribute('rel', 'prefetch');
|
||||
link.setAttribute('href', pathname);
|
||||
document.head.append(link);
|
||||
}
|
||||
|
||||
if (supportsViewTransitions || getFallback() !== 'none') {
|
||||
document.addEventListener('click', (ev) => {
|
||||
let link = ev.target;
|
||||
|
@ -89,23 +72,9 @@ const { fallback = 'animate' } = Astro.props;
|
|||
});
|
||||
});
|
||||
|
||||
['mouseenter', 'touchstart', 'focus'].forEach((evName) => {
|
||||
document.addEventListener(
|
||||
evName,
|
||||
(ev) => {
|
||||
if (ev.target instanceof HTMLAnchorElement) {
|
||||
let el = ev.target;
|
||||
if (
|
||||
el.origin === location.origin &&
|
||||
el.pathname !== location.pathname &&
|
||||
transitionEnabledOnThisPage()
|
||||
) {
|
||||
maybePrefetch(el.pathname);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: true, capture: true }
|
||||
);
|
||||
});
|
||||
// @ts-expect-error injected by vite-plugin-transitions for treeshaking
|
||||
if (!__PREFETCH_DISABLED__) {
|
||||
init({ prefetchAll: true });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
6
packages/astro/e2e/fixtures/prefetch/astro.config.mjs
Normal file
6
packages/astro/e2e/fixtures/prefetch/astro.config.mjs
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
prefetch: true
|
||||
});
|
8
packages/astro/e2e/fixtures/prefetch/package.json
Normal file
8
packages/astro/e2e/fixtures/prefetch/package.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@e2e/prefetch",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
30
packages/astro/e2e/fixtures/prefetch/src/pages/index.astro
Normal file
30
packages/astro/e2e/fixtures/prefetch/src/pages/index.astro
Normal file
|
@ -0,0 +1,30 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Prefetch</h1>
|
||||
<a id="prefetch-default" href="/prefetch-default">default</a>
|
||||
<br>
|
||||
<a id="prefetch-false" href="/prefetch-false" data-astro-prefetch="false">false</a>
|
||||
<br>
|
||||
<a id="prefetch-tap" href="/prefetch-tap" data-astro-prefetch="tap">tap</a>
|
||||
<br>
|
||||
<a id="prefetch-hover" href="/prefetch-hover" data-astro-prefetch="hover">hover</a>
|
||||
<br>
|
||||
<button id="prefetch-manual">manual</button>
|
||||
<br>
|
||||
<span>Scroll down to trigger viewport prefetch</span>
|
||||
<!-- Large empty space to test viewport -->
|
||||
<div style="height: 1000px;"></div>
|
||||
<a id="prefetch-viewport" href="/prefetch-viewport" data-astro-prefetch="viewport">viewport</a>
|
||||
<script>
|
||||
// @ts-nocheck
|
||||
import { prefetch } from 'astro:prefetch'
|
||||
document.getElementById('prefetch-manual').addEventListener('click', () => {
|
||||
prefetch('/prefetch-manual', { with: 'link' })
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1 @@
|
|||
<h1>Prefetch default</h1>
|
|
@ -0,0 +1 @@
|
|||
<h1>Prefetch false</h1>
|
|
@ -0,0 +1 @@
|
|||
<h1>Prefetch hover</h1>
|
|
@ -0,0 +1 @@
|
|||
<h1>Prefetch tap</h1>
|
|
@ -0,0 +1 @@
|
|||
<h1>Prefetch viewport</h1>
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
import Layout from '../components/Layout.astro';
|
||||
---
|
||||
<Layout>
|
||||
<a id="prefetch-one" href="/one">Go to one with prefetch on hover</a>
|
||||
</Layout>
|
163
packages/astro/e2e/prefetch.test.js
Normal file
163
packages/astro/e2e/prefetch.test.js
Normal file
|
@ -0,0 +1,163 @@
|
|||
import { expect } from '@playwright/test';
|
||||
import { testFactory } from './test-utils.js';
|
||||
|
||||
const test = testFactory({
|
||||
root: './fixtures/prefetch/',
|
||||
});
|
||||
|
||||
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) => {
|
||||
reqUrls.push(new URL(req.url()).pathname);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
test('data-astro-prefetch="false" should not prefetch', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/'));
|
||||
expect(reqUrls).not.toContainEqual('/prefetch-false');
|
||||
});
|
||||
|
||||
test('data-astro-prefetch="tap" should prefetch on tap', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/'));
|
||||
expect(reqUrls).not.toContainEqual('/prefetch-tap');
|
||||
await Promise.all([
|
||||
page.waitForEvent('request'), // wait prefetch request
|
||||
page.locator('#prefetch-tap').click(),
|
||||
]);
|
||||
expect(reqUrls).toContainEqual('/prefetch-tap');
|
||||
});
|
||||
|
||||
test('data-astro-prefetch="hover" should prefetch on hover', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/'));
|
||||
expect(reqUrls).not.toContainEqual('/prefetch-hover');
|
||||
await Promise.all([
|
||||
page.waitForEvent('request'), // wait prefetch request
|
||||
page.locator('#prefetch-hover').hover(),
|
||||
]);
|
||||
expect(reqUrls).toContainEqual('/prefetch-hover');
|
||||
});
|
||||
|
||||
test('data-astro-prefetch="viewport" should prefetch on viewport', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/'));
|
||||
expect(reqUrls).not.toContainEqual('/prefetch-viewport');
|
||||
// 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();
|
||||
});
|
||||
|
||||
test('manual prefetch() works once', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/'));
|
||||
expect(reqUrls).not.toContainEqual('/prefetch-manual');
|
||||
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();
|
||||
|
||||
// prefetch again should have no effect
|
||||
await page.locator('#prefetch-manual').click();
|
||||
expect(reqUrls.filter((u) => u.includes('/prefetch-manual')).length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Prefetch (prefetchAll: true, defaultStrategy: 'tap')", () => {
|
||||
let devServer;
|
||||
/** @type {string[]} */
|
||||
const reqUrls = [];
|
||||
|
||||
test.beforeAll(async ({ astro }) => {
|
||||
devServer = await astro.startDevServer({
|
||||
prefetch: {
|
||||
prefetchAll: true,
|
||||
defaultStrategy: 'tap',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on('request', (req) => {
|
||||
reqUrls.push(new URL(req.url()).pathname);
|
||||
});
|
||||
});
|
||||
|
||||
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 Promise.all([
|
||||
page.waitForEvent('request'), // wait prefetch request
|
||||
page.locator('#prefetch-default').click(),
|
||||
]);
|
||||
expect(reqUrls).toContainEqual('/prefetch-default');
|
||||
});
|
||||
|
||||
test('data-astro-prefetch="false" should not prefetch', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/'));
|
||||
expect(reqUrls).not.toContainEqual('/prefetch-false');
|
||||
});
|
||||
|
||||
test('data-astro-prefetch="tap" should prefetch on tap', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/'));
|
||||
expect(reqUrls).not.toContainEqual('/prefetch-tap');
|
||||
await Promise.all([
|
||||
page.waitForEvent('request'), // wait prefetch request
|
||||
page.locator('#prefetch-tap').click(),
|
||||
]);
|
||||
expect(reqUrls).toContainEqual('/prefetch-tap');
|
||||
});
|
||||
|
||||
test('data-astro-prefetch="hover" should prefetch on hover', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/'));
|
||||
expect(reqUrls).not.toContainEqual('/prefetch-hover');
|
||||
await Promise.all([
|
||||
page.waitForEvent('request'), // wait prefetch request
|
||||
page.locator('#prefetch-hover').hover(),
|
||||
]);
|
||||
expect(reqUrls).toContainEqual('/prefetch-hover');
|
||||
});
|
||||
|
||||
test('data-astro-prefetch="viewport" should prefetch on viewport', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/'));
|
||||
expect(reqUrls).not.toContainEqual('/prefetch-viewport');
|
||||
// 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();
|
||||
});
|
||||
});
|
|
@ -901,4 +901,19 @@ test.describe('View Transitions', () => {
|
|||
let announcer = page.locator('.astro-route-announcer');
|
||||
await expect(announcer, 'should have content').toHaveCSS('width', '1px');
|
||||
});
|
||||
|
||||
test('should prefetch on hover by default', async ({ page, astro }) => {
|
||||
/** @type {string[]} */
|
||||
const reqUrls = [];
|
||||
page.on('request', (req) => {
|
||||
reqUrls.push(new URL(req.url()).pathname);
|
||||
});
|
||||
await page.goto(astro.resolveUrl('/prefetch'));
|
||||
expect(reqUrls).not.toContainEqual('/one');
|
||||
await Promise.all([
|
||||
page.waitForEvent('request'), // wait prefetch request
|
||||
page.locator('#prefetch-one').hover(),
|
||||
]);
|
||||
expect(reqUrls).toContainEqual('/one');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -78,7 +78,8 @@
|
|||
"default": "./dist/core/middleware/namespace.js"
|
||||
},
|
||||
"./transitions": "./dist/transitions/index.js",
|
||||
"./transitions/router": "./dist/transitions/router.js"
|
||||
"./transitions/router": "./dist/transitions/router.js",
|
||||
"./prefetch": "./dist/prefetch/index.js"
|
||||
},
|
||||
"imports": {
|
||||
"#astro/*": "./dist/*.js"
|
||||
|
|
|
@ -536,6 +536,71 @@ export interface AstroUserConfig {
|
|||
*/
|
||||
redirects?: Record<string, RedirectConfig>;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @name prefetch
|
||||
* @type {boolean | object}
|
||||
* @description
|
||||
* Enable prefetching for links on your site to provide faster page transitions.
|
||||
* (Enabled by default on pages using the `<ViewTransitions />` router. Set `prefetch: false` to opt out of this behaviour.)
|
||||
*
|
||||
* This configuration automatically adds a prefetch script to every page in the project
|
||||
* giving you access to the `data-astro-prefetch` attribute.
|
||||
* Add this attribute to any `<a />` link on your page to enable prefetching for that page.
|
||||
*
|
||||
* ```html
|
||||
* <a href="/about" data-astro-prefetch>About</a>
|
||||
* ```
|
||||
* Further customize the default prefetching behavior using the [`prefetch.defaultStrategy`](#prefetchdefaultstrategy) and [`prefetch.prefetchAll`](#prefetchprefetchall) options.
|
||||
*
|
||||
* See the [Prefetch guide](https://docs.astro.build/en/guides/prefetch/) for more information.
|
||||
*/
|
||||
prefetch?:
|
||||
| boolean
|
||||
| {
|
||||
/**
|
||||
* @docs
|
||||
* @name prefetch.prefetchAll
|
||||
* @type {boolean}
|
||||
* @description
|
||||
* Enable prefetching for all links, including those without the `data-astro-prefetch` attribute.
|
||||
* This value defaults to `true` when using the `<ViewTransitions />` router. Otherwise, the default value is `false`.
|
||||
*
|
||||
* ```js
|
||||
* prefetch: {
|
||||
* prefetchAll: true
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* When set to `true`, you can disable prefetching individually by setting `data-astro-prefetch="false"` on any individual links.
|
||||
*
|
||||
* ```html
|
||||
* <a href="/about" data-astro-prefetch="false">About</a>
|
||||
*```
|
||||
*/
|
||||
prefetchAll?: boolean;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @name prefetch.defaultStrategy
|
||||
* @type {'tap' | 'hover' | 'viewport'}
|
||||
* @default `'hover'`
|
||||
* @description
|
||||
* The default prefetch strategy to use when the `data-astro-prefetch` attribute is set on a link with no value.
|
||||
*
|
||||
* - `'tap'`: Prefetch just before you click on the link.
|
||||
* - `'hover'`: Prefetch when you hover over or focus on the link. (default)
|
||||
* - `'viewport'`: Prefetch as the links enter the viewport.
|
||||
*
|
||||
* You can override this default value and select a different strategy for any individual link by setting a value on the attribute.
|
||||
*
|
||||
* ```html
|
||||
* <a href="/about" data-astro-prefetch="viewport">About</a>
|
||||
* ```
|
||||
*/
|
||||
defaultStrategy?: 'tap' | 'hover' | 'viewport';
|
||||
};
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @name site
|
||||
|
|
|
@ -192,6 +192,15 @@ export const AstroConfigSchema = z.object({
|
|||
])
|
||||
)
|
||||
.default(ASTRO_CONFIG_DEFAULTS.redirects),
|
||||
prefetch: z
|
||||
.union([
|
||||
z.boolean(),
|
||||
z.object({
|
||||
prefetchAll: z.boolean().optional(),
|
||||
defaultStrategy: z.enum(['tap', 'hover', 'viewport']).optional(),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
image: z
|
||||
.object({
|
||||
endpoint: z.string().optional(),
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
astroContentImportPlugin,
|
||||
astroContentVirtualModPlugin,
|
||||
} from '../content/index.js';
|
||||
import astroPrefetch from '../prefetch/vite-plugin-prefetch.js';
|
||||
import astroTransitions from '../transitions/vite-plugin-transitions.js';
|
||||
import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.js';
|
||||
import { vitePluginAstroServer } from '../vite-plugin-astro-server/index.js';
|
||||
|
@ -134,7 +135,8 @@ export async function createVite(
|
|||
astroContentAssetPropagationPlugin({ mode, settings }),
|
||||
vitePluginSSRManifest(),
|
||||
astroAssetsPlugin({ settings, logger, mode }),
|
||||
astroTransitions(),
|
||||
astroPrefetch({ settings }),
|
||||
astroTransitions({ settings }),
|
||||
astroDevOverlay({ settings, logger }),
|
||||
],
|
||||
publicDir: fileURLToPath(settings.config.publicDir),
|
||||
|
|
272
packages/astro/src/prefetch/index.ts
Normal file
272
packages/astro/src/prefetch/index.ts
Normal file
|
@ -0,0 +1,272 @@
|
|||
/*
|
||||
NOTE: Do not add any dependencies or imports in this file so that it can load quickly in dev.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
const debug = import.meta.env.DEV ? console.debug : undefined;
|
||||
const inBrowser = import.meta.env.SSR === false;
|
||||
// Track prefetched URLs so we don't prefetch twice
|
||||
const prefetchedUrls = new Set<string>();
|
||||
// Track listened anchors so we don't attach duplicated listeners
|
||||
const listenedAnchors = new WeakSet<HTMLAnchorElement>();
|
||||
|
||||
// User-defined config for prefetch. The values are injected by vite-plugin-prefetch
|
||||
// and can be undefined if not configured. But it will be set a fallback value in `init()`.
|
||||
// @ts-expect-error injected global
|
||||
let prefetchAll: boolean = __PREFETCH_PREFETCH_ALL__;
|
||||
// @ts-expect-error injected global
|
||||
let defaultStrategy: string = __PREFETCH_DEFAULT_STRATEGY__;
|
||||
|
||||
interface InitOptions {
|
||||
defaultStrategy?: string;
|
||||
prefetchAll?: boolean;
|
||||
}
|
||||
|
||||
let inited = false;
|
||||
/**
|
||||
* Initialize the prefetch script, only works once.
|
||||
*
|
||||
* @param defaultOpts Default options for prefetching if not already set by the user config.
|
||||
*/
|
||||
export function init(defaultOpts?: InitOptions) {
|
||||
if (!inBrowser) return;
|
||||
|
||||
// Init only once
|
||||
if (inited) return;
|
||||
inited = true;
|
||||
|
||||
debug?.(`[astro] Initializing prefetch script`);
|
||||
|
||||
// Fallback default values if not set by user config
|
||||
prefetchAll ??= defaultOpts?.prefetchAll ?? false;
|
||||
defaultStrategy ??= defaultOpts?.defaultStrategy ?? 'hover';
|
||||
|
||||
// In the future, perhaps we can enable treeshaking specific unused strategies
|
||||
initTapStrategy();
|
||||
initHoverStrategy();
|
||||
initViewportStrategy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch links with higher priority when the user taps on them
|
||||
*/
|
||||
function initTapStrategy() {
|
||||
for (const event of ['touchstart', 'mousedown']) {
|
||||
document.body.addEventListener(
|
||||
event,
|
||||
(e) => {
|
||||
if (elMatchesStrategy(e.target, 'tap')) {
|
||||
prefetch(e.target.href, { with: 'fetch' });
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch links with higher priority when the user hovers over them
|
||||
*/
|
||||
function initHoverStrategy() {
|
||||
let timeout: number;
|
||||
|
||||
// Handle focus listeners
|
||||
document.body.addEventListener(
|
||||
'focusin',
|
||||
(e) => {
|
||||
if (elMatchesStrategy(e.target, 'hover')) {
|
||||
handleHoverIn(e);
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
document.body.addEventListener('focusout', handleHoverOut, { passive: true });
|
||||
|
||||
// Handle hover listeners. Re-run each time on page load.
|
||||
onPageLoad(() => {
|
||||
for (const anchor of document.getElementsByTagName('a')) {
|
||||
// Skip if already listening
|
||||
if (listenedAnchors.has(anchor)) continue;
|
||||
// Add listeners for anchors matching the strategy
|
||||
if (elMatchesStrategy(anchor, 'hover')) {
|
||||
listenedAnchors.add(anchor);
|
||||
anchor.addEventListener('mouseenter', handleHoverIn, { passive: true });
|
||||
anchor.addEventListener('mouseleave', handleHoverOut, { passive: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function handleHoverIn(e: Event) {
|
||||
const href = (e.target as HTMLAnchorElement).href;
|
||||
|
||||
// Debounce hover prefetches by 80ms
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
prefetch(href, { with: 'fetch' });
|
||||
}, 80) as unknown as number;
|
||||
}
|
||||
|
||||
// Cancel prefetch if the user hovers away
|
||||
function handleHoverOut() {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch links with lower priority as they enter the viewport
|
||||
*/
|
||||
function initViewportStrategy() {
|
||||
let observer: IntersectionObserver;
|
||||
|
||||
onPageLoad(() => {
|
||||
for (const anchor of document.getElementsByTagName('a')) {
|
||||
// Skip if already listening
|
||||
if (listenedAnchors.has(anchor)) continue;
|
||||
// Observe for anchors matching the strategy
|
||||
if (elMatchesStrategy(anchor, 'viewport')) {
|
||||
listenedAnchors.add(anchor);
|
||||
observer ??= createViewportIntersectionObserver();
|
||||
observer.observe(anchor);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createViewportIntersectionObserver() {
|
||||
const timeouts = new WeakMap<HTMLAnchorElement, number>();
|
||||
|
||||
return new IntersectionObserver((entries, observer) => {
|
||||
for (const entry of entries) {
|
||||
const anchor = entry.target as HTMLAnchorElement;
|
||||
const timeout = timeouts.get(anchor);
|
||||
// Prefetch if intersecting
|
||||
if (entry.isIntersecting) {
|
||||
// Debounce viewport prefetches by 300ms
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeouts.set(
|
||||
anchor,
|
||||
setTimeout(() => {
|
||||
observer.unobserve(anchor);
|
||||
timeouts.delete(anchor);
|
||||
prefetch(anchor.href, { with: 'link' });
|
||||
}, 300) as unknown as number
|
||||
);
|
||||
} else {
|
||||
// If exited viewport but haven't prefetched, cancel it
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeouts.delete(anchor);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface PrefetchOptions {
|
||||
/**
|
||||
* How the prefetch should prioritize the URL. (default `'link'`)
|
||||
* - `'link'`: use `<link rel="prefetch">`, has lower loading priority.
|
||||
* - `'fetch'`: use `fetch()`, has higher loading priority.
|
||||
*/
|
||||
with?: 'link' | 'fetch';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch a URL so it's cached when the user navigates to it.
|
||||
*
|
||||
* @param url A full or partial URL string based on the current `location.href`. They are only fetched if:
|
||||
* - The user is online
|
||||
* - The user is not in data saver mode
|
||||
* - The URL is within the same origin
|
||||
* - The URL is not the current page
|
||||
* - The URL has not already been prefetched
|
||||
* @param opts Additional options for prefetching.
|
||||
*/
|
||||
export function prefetch(url: string, opts?: PrefetchOptions) {
|
||||
if (!canPrefetchUrl(url)) return;
|
||||
prefetchedUrls.add(url);
|
||||
|
||||
const priority = opts?.with ?? 'link';
|
||||
debug?.(`[astro] Prefetching ${url} with ${priority}`);
|
||||
|
||||
if (priority === 'link') {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'prefetch';
|
||||
link.setAttribute('href', url);
|
||||
document.head.append(link);
|
||||
} else {
|
||||
fetch(url).catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[astro] Failed to prefetch ${url}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function canPrefetchUrl(url: string) {
|
||||
// Skip prefetch if offline
|
||||
if (!navigator.onLine) return false;
|
||||
if ('connection' in navigator) {
|
||||
// Untyped Chrome-only feature: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection
|
||||
const conn = navigator.connection as any;
|
||||
// Skip prefetch if using data saver mode or slow connection
|
||||
if (conn.saveData || /(2|3)g/.test(conn.effectiveType)) return false;
|
||||
}
|
||||
// Else check if URL is within the same origin, not the current page, and not already prefetched
|
||||
try {
|
||||
const urlObj = new URL(url, location.href);
|
||||
return (
|
||||
location.origin === urlObj.origin &&
|
||||
location.pathname !== urlObj.pathname &&
|
||||
!prefetchedUrls.has(url)
|
||||
);
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
function elMatchesStrategy(el: EventTarget | null, strategy: string): el is HTMLAnchorElement {
|
||||
// @ts-expect-error access unknown property this way as it's more performant
|
||||
if (el?.tagName !== 'A') return false;
|
||||
const attrValue = (el as HTMLElement).dataset.astroPrefetch;
|
||||
|
||||
// Out-out if `prefetchAll` is enabled
|
||||
if (attrValue === 'false') {
|
||||
return false;
|
||||
}
|
||||
// If anchor has no dataset but we want to prefetch all, or has dataset but no value,
|
||||
// check against fallback default strategy
|
||||
if ((attrValue == null && prefetchAll) || attrValue === '') {
|
||||
return strategy === defaultStrategy;
|
||||
}
|
||||
// Else if dataset is explicitly defined, check against it
|
||||
if (attrValue === strategy) {
|
||||
return true;
|
||||
}
|
||||
// Else, no match
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to page loads and handle Astro's View Transition specific events
|
||||
*/
|
||||
function onPageLoad(cb: () => void) {
|
||||
cb();
|
||||
// Ignore first call of `astro-page-load` as we already call `cb` above.
|
||||
// We have to call `cb` eagerly as View Transitions may not be enabled.
|
||||
let firstLoad = false;
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
if (!firstLoad) {
|
||||
firstLoad = true;
|
||||
return;
|
||||
}
|
||||
cb();
|
||||
});
|
||||
}
|
56
packages/astro/src/prefetch/vite-plugin-prefetch.ts
Normal file
56
packages/astro/src/prefetch/vite-plugin-prefetch.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import * as vite from 'vite';
|
||||
import type { AstroSettings } from '../@types/astro.js';
|
||||
|
||||
const virtualModuleId = 'astro:prefetch';
|
||||
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
||||
const prefetchInternalModuleFsSubpath = 'astro/dist/prefetch/index.js';
|
||||
const prefetchCode = `import { init } from 'astro/prefetch';init()`;
|
||||
|
||||
export default function astroPrefetch({ settings }: { settings: AstroSettings }): vite.Plugin {
|
||||
const prefetchOption = settings.config.prefetch;
|
||||
const prefetch = prefetchOption
|
||||
? typeof prefetchOption === 'object'
|
||||
? prefetchOption
|
||||
: {}
|
||||
: undefined;
|
||||
|
||||
// Check against existing scripts as this plugin could be called multiple times
|
||||
if (prefetch && settings.scripts.every((s) => s.content !== prefetchCode)) {
|
||||
// Inject prefetch script to all pages
|
||||
settings.scripts.push({
|
||||
stage: 'page',
|
||||
content: `import { init } from 'astro/prefetch';init()`,
|
||||
});
|
||||
}
|
||||
|
||||
// Throw a normal error instead of an AstroError as Vite captures this in the plugin lifecycle
|
||||
// and would generate a different stack trace itself through esbuild.
|
||||
const throwPrefetchNotEnabledError = () => {
|
||||
throw new Error('You need to enable the `prefetch` Astro config to import `astro:prefetch`');
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'astro:prefetch',
|
||||
async resolveId(id) {
|
||||
if (id === virtualModuleId) {
|
||||
if (!prefetch) throwPrefetchNotEnabledError();
|
||||
return resolvedVirtualModuleId;
|
||||
}
|
||||
},
|
||||
load(id) {
|
||||
if (id === resolvedVirtualModuleId) {
|
||||
if (!prefetch) throwPrefetchNotEnabledError();
|
||||
return `export { prefetch } from "astro/prefetch";`;
|
||||
}
|
||||
},
|
||||
transform(code, id) {
|
||||
// NOTE: Handle replacing the specifiers even if prefetch is disabled so View Transitions
|
||||
// can import the interal module as not hit runtime issues.
|
||||
if (id.includes(prefetchInternalModuleFsSubpath)) {
|
||||
return code
|
||||
.replace('__PREFETCH_PREFETCH_ALL__', JSON.stringify(prefetch?.prefetchAll))
|
||||
.replace('__PREFETCH_DEFAULT_STRATEGY__', JSON.stringify(prefetch?.defaultStrategy));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import * as vite from 'vite';
|
||||
import type { AstroSettings } from '../@types/astro.js';
|
||||
|
||||
const virtualModuleId = 'astro:transitions';
|
||||
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
||||
|
@ -6,7 +7,7 @@ const virtualClientModuleId = 'astro:transitions/client';
|
|||
const resolvedVirtualClientModuleId = '\0' + virtualClientModuleId;
|
||||
|
||||
// The virtual module for the astro:transitions namespace
|
||||
export default function astroTransitions(): vite.Plugin {
|
||||
export default function astroTransitions({ settings }: { settings: AstroSettings }): vite.Plugin {
|
||||
return {
|
||||
name: 'astro:transitions',
|
||||
async resolveId(id) {
|
||||
|
@ -30,5 +31,11 @@ export default function astroTransitions(): vite.Plugin {
|
|||
`;
|
||||
}
|
||||
},
|
||||
transform(code, id) {
|
||||
if (id.includes('ViewTransitions.astro') && id.endsWith('.ts')) {
|
||||
const prefetchDisabled = settings.config.prefetch === false;
|
||||
return code.replace('__PREFETCH_DISABLED__', JSON.stringify(prefetchDisabled));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
# @astrojs/prefetch 🔗
|
||||
|
||||
> NOTE: `@astrojs/prefetch` is deprecated. Use the `prefetch` feature in Astro 3.5 instead. Check out the [migration guide](https://docs.astro.build/en/guides/prefetch/#migrating-from-astrojsprefetch).
|
||||
|
||||
- <strong>[Why Prefetch?](#why-prefetch)</strong>
|
||||
- <strong>[Installation](#installation)</strong>
|
||||
- <strong>[Usage](#usage)</strong>
|
||||
|
|
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
@ -1396,6 +1396,12 @@ importers:
|
|||
specifier: ^10.17.1
|
||||
version: 10.18.1
|
||||
|
||||
packages/astro/e2e/fixtures/prefetch:
|
||||
dependencies:
|
||||
astro:
|
||||
specifier: workspace:*
|
||||
version: link:../../..
|
||||
|
||||
packages/astro/e2e/fixtures/react-component:
|
||||
dependencies:
|
||||
'@astrojs/mdx':
|
||||
|
|
Loading…
Add table
Reference in a new issue