diff --git a/.changeset/sixty-laws-argue.md b/.changeset/sixty-laws-argue.md
new file mode 100644
index 000000000000..808106f884ab
--- /dev/null
+++ b/.changeset/sixty-laws-argue.md
@@ -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.
diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts
index f20c3d089632..0d2333da274a 100644
--- a/packages/astro/client.d.ts
+++ b/packages/astro/client.d.ts
@@ -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';
}
diff --git a/packages/astro/components/ViewTransitions.astro b/packages/astro/components/ViewTransitions.astro
index 0bdaf1d46002..5ceed5d3d83a 100644
--- a/packages/astro/components/ViewTransitions.astro
+++ b/packages/astro/components/ViewTransitions.astro
@@ -25,11 +25,9 @@ const { fallback = 'animate' } = Astro.props;
diff --git a/packages/astro/e2e/fixtures/prefetch/astro.config.mjs b/packages/astro/e2e/fixtures/prefetch/astro.config.mjs
new file mode 100644
index 000000000000..bec1677fd8a7
--- /dev/null
+++ b/packages/astro/e2e/fixtures/prefetch/astro.config.mjs
@@ -0,0 +1,6 @@
+import { defineConfig } from 'astro/config';
+
+// https://astro.build/config
+export default defineConfig({
+ prefetch: true
+});
diff --git a/packages/astro/e2e/fixtures/prefetch/package.json b/packages/astro/e2e/fixtures/prefetch/package.json
new file mode 100644
index 000000000000..85fbd19695af
--- /dev/null
+++ b/packages/astro/e2e/fixtures/prefetch/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@e2e/prefetch",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/astro/e2e/fixtures/prefetch/src/pages/index.astro b/packages/astro/e2e/fixtures/prefetch/src/pages/index.astro
new file mode 100644
index 000000000000..e61bc1c6c33e
--- /dev/null
+++ b/packages/astro/e2e/fixtures/prefetch/src/pages/index.astro
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+ Prefetch
+ default
+
+ false
+
+ tap
+
+ hover
+
+
+
+ Scroll down to trigger viewport prefetch
+
+
+ viewport
+
+
+
\ No newline at end of file
diff --git a/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-default.astro b/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-default.astro
new file mode 100644
index 000000000000..85621966e303
--- /dev/null
+++ b/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-default.astro
@@ -0,0 +1 @@
+Prefetch default
diff --git a/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-false.astro b/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-false.astro
new file mode 100644
index 000000000000..63fa81b51062
--- /dev/null
+++ b/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-false.astro
@@ -0,0 +1 @@
+Prefetch false
diff --git a/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-hover.astro b/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-hover.astro
new file mode 100644
index 000000000000..a8e36237899a
--- /dev/null
+++ b/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-hover.astro
@@ -0,0 +1 @@
+Prefetch hover
diff --git a/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-tap.astro b/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-tap.astro
new file mode 100644
index 000000000000..83675033782e
--- /dev/null
+++ b/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-tap.astro
@@ -0,0 +1 @@
+Prefetch tap
diff --git a/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-viewport.astro b/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-viewport.astro
new file mode 100644
index 000000000000..0823786b4bdc
--- /dev/null
+++ b/packages/astro/e2e/fixtures/prefetch/src/pages/prefetch-viewport.astro
@@ -0,0 +1 @@
+Prefetch viewport
diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/prefetch.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/prefetch.astro
new file mode 100644
index 000000000000..51bebdae992e
--- /dev/null
+++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/prefetch.astro
@@ -0,0 +1,6 @@
+---
+import Layout from '../components/Layout.astro';
+---
+
+ Go to one with prefetch on hover
+
diff --git a/packages/astro/e2e/prefetch.test.js b/packages/astro/e2e/prefetch.test.js
new file mode 100644
index 000000000000..dc29bde33f07
--- /dev/null
+++ b/packages/astro/e2e/prefetch.test.js
@@ -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();
+ });
+});
diff --git a/packages/astro/e2e/view-transitions.test.js b/packages/astro/e2e/view-transitions.test.js
index 83830ac737c3..06a90366bdee 100644
--- a/packages/astro/e2e/view-transitions.test.js
+++ b/packages/astro/e2e/view-transitions.test.js
@@ -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');
+ });
});
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 684eed0cfe7a..6b2009460b59 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -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"
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index 4ae5e7138656..93c370a0e2cd 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -536,6 +536,71 @@ export interface AstroUserConfig {
*/
redirects?: Record;
+ /**
+ * @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 `` 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 `` link on your page to enable prefetching for that page.
+ *
+ * ```html
+ * About
+ * ```
+ * 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 `` 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
+ * About
+ *```
+ */
+ 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
+ * About
+ * ```
+ */
+ defaultStrategy?: 'tap' | 'hover' | 'viewport';
+ };
+
/**
* @docs
* @name site
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
index 08507a548325..5a5964a12d9f 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -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(),
diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts
index 6a459be2a18f..a4841bfdbcaa 100644
--- a/packages/astro/src/core/create-vite.ts
+++ b/packages/astro/src/core/create-vite.ts
@@ -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),
diff --git a/packages/astro/src/prefetch/index.ts b/packages/astro/src/prefetch/index.ts
new file mode 100644
index 000000000000..f47cff0606f9
--- /dev/null
+++ b/packages/astro/src/prefetch/index.ts
@@ -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();
+// Track listened anchors so we don't attach duplicated listeners
+const listenedAnchors = new WeakSet();
+
+// 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();
+
+ 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 ``, 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();
+ });
+}
diff --git a/packages/astro/src/prefetch/vite-plugin-prefetch.ts b/packages/astro/src/prefetch/vite-plugin-prefetch.ts
new file mode 100644
index 000000000000..73ae53f63b53
--- /dev/null
+++ b/packages/astro/src/prefetch/vite-plugin-prefetch.ts
@@ -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));
+ }
+ },
+ };
+}
diff --git a/packages/astro/src/transitions/vite-plugin-transitions.ts b/packages/astro/src/transitions/vite-plugin-transitions.ts
index e530fc1e2bae..8d5dbe553804 100644
--- a/packages/astro/src/transitions/vite-plugin-transitions.ts
+++ b/packages/astro/src/transitions/vite-plugin-transitions.ts
@@ -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));
+ }
+ },
};
}
diff --git a/packages/integrations/prefetch/README.md b/packages/integrations/prefetch/README.md
index 82a608eddbfa..a357618e4f30 100644
--- a/packages/integrations/prefetch/README.md
+++ b/packages/integrations/prefetch/README.md
@@ -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).
+
- [Why Prefetch?](#why-prefetch)
- [Installation](#installation)
- [Usage](#usage)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0bfaef637100..c7c110995d1f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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':