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':