Skip to content

fix(client-router): reuse the response downloaded by prefetch on navigation - #17550

Open
Mohith26 wants to merge 3 commits into
withastro:mainfrom
Mohith26:fix/client-router-prefetch-reuse
Open

fix(client-router): reuse the response downloaded by prefetch on navigation#17550
Mohith26 wants to merge 3 commits into
withastro:mainfrom
Mohith26:fix/client-router-prefetch-reuse

Conversation

@Mohith26

@Mohith26 Mohith26 commented Jul 29, 2026

Copy link
Copy Markdown

Fixes #17549, reported by @iseraph-dev, following the issue's "no new API" design preference.

The ClientRouter re-downloaded pages its own prefetch had just fetched: fetchHTML() used the default cache mode, and SSR pages carry no cache headers, so navigation always refetched.

The fix adds a tiny dependency-free prefetch/registry.ts shared by both bundles that records when each URL was prefetched, under one normalization (now used by prefetch() itself too, so the link href, speculation rules, and registry key are byte-identical and resolve against location.href rather than a divergent <base href>). fetchHTML() uses cache: 'force-cache' when the URL was prefetched within Chromium's 5-minute prefetch-reuse window, gated on !init?.method so form POSTs always hit the server.

Per @iseraph-dev's measured review: the reuse entry is consumed on first navigation (consumePrefetchReuse() zeroes the timestamp, hasBeenPrefetched() keeps deduplicating), so this is "reuse the prefetch once", not a five-minute client-side HTML cache; later visits revalidate normally. The registry call is hoisted above fetchHTML's catch-all so a registry error can never fall back to a full page load. Whether hasBeenPrefetched() should itself expire (allowing re-prefetch) was considered and left out deliberately.

Two scope notes worth naming: this also fixes the same double-download for experimental.clientPrerender users (speculation-rules prefetches populate the HTTP cache the same way, measured in the review); and for Cache-Control: no-cache + ETag setups the single reused navigation skips one revalidation, bounded to seconds after the prefetch, which we think is defensible without an opt-out.

Thirteen unit tests lock the registry semantics including one-shot consumption; the unit suite passes with zero new failures, biome/eslint gates clean. The force-cache wiring itself is only verifiable in the Playwright e2e suite; happy to add the SSR fixture e2e from the review's pointers if you want it in this PR.

Mohith26 added 2 commits July 29, 2026 11:18
…gation

When ClientRouter navigates to a URL that its own prefetch downloaded
moments earlier, fetchHTML() used the default cache mode. On-demand
rendered pages carry no cache headers, so the browser downloaded the
page a second time.

The prefetch script now records when each URL was prefetched in a
small shared registry, and the router fetches with cache: 'force-cache'
when the URL was prefetched within the last five minutes, matching
Chromium's own reuse window for prefetched responses
(kPrefetchReuseMins). Form submissions are excluded so they always
reach the server. The registry also normalizes URLs consistently
(resolved against location.href, hash stripped), fixing the mismatch
where prefetch() stored relative URLs while lookups used absolute ones.

Fixes withastro#17549
Adds unit tests for the registry shared between the prefetch script
and the view transitions router: URL normalization (relative vs
absolute, hash stripping, query preservation), the five-minute reuse
window, and the distinction between deduplication (hasBeenPrefetched)
and reuse eligibility (wasPrefetchedRecently).
@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2310fb7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 399 packages
Name Type
astro Patch
@e2e/astro-linked-lib Patch
@e2e/actions-blog Patch
@e2e/actions-react-19 Patch
@e2e/astro-component Patch
@e2e/astro-envs Patch
@e2e/astro-island-hydration-error Patch
@test/astro-cloudflare-node-prerender-mdx Patch
@test/astro-cloudflare Patch
@e2e/content-collections Patch
@e2e/csp-server-islands Patch
@e2e/css Patch
@test/custom-client-directives Patch
@e2e/dev-toolbar Patch
@e2e/error-cyclic Patch
@e2e/error-sass Patch
@e2e/errors Patch
@e2e/hydration-race Patch
@e2e/i18n Patch
@test/nested-style-bug-e22e Patch
@e2e/preact-compat-component Patch
@e2e/preact-component Patch
@e2e/preact-lazy-component Patch
@e2e/prefetch Patch
@e2e/react-component Patch
@e2e/server-islands-key Patch
@e2e/server-islands Patch
@e2e/solid-circular Patch
@e2e/solid-component Patch
@e2e/solid-recurse Patch
@e2e/svelte-component Patch
@e2e/e2e-tailwindcss Patch
@e2e/ts-resolution Patch
@e2e/view-transitions Patch
@e2e/vue-component Patch
@performance/md Patch
@performance/mdoc Patch
@performance/mdx Patch
@test/0-css Patch
fake-astro-library Patch
@test/actions Patch
@test/alias-css-url Patch
@test/alias-path-alias-style Patch
@test/ts-paths-no-baseurl Patch
@test/aliases-tsconfig Patch
@test/aliases Patch
@test/api-routes Patch
@test/asset-query-params-chunks Patch
@test/asset-url-base Patch
@test/astro-pages Patch
@test/astro-assets-prefix Patch
@test/astro-assets Patch
@test/astro-basic Patch
@test/astro-check-errors Patch
@test/astro-check-no-errors Patch
@test/astro-check-watch Patch
@test/astro-children Patch
@test/astro-client-only Patch
@test/astro-component-bundling Patch
@test/astro-component-code Patch
@test/astro-css-bundling Patch
@test/astro-dev-headers Patch
@test/astro-dev-http2 Patch
@test/astro-doctype Patch
@test/astro-dynamic Patch
@test/astro-env-content-collections Patch
@test/astro-env-required-public Patch
@test/astro-env-server-fail Patch
@test/astro-env-server-secret Patch
@test/astro-env Patch
@test/astro-envs Patch
@test/astro-expr Patch
@test/astro-get-static-paths Patch
@test/astro-head Patch
@test/astro-manifest-client-script Patch
@test/astro-manifest-invalid Patch
@test/astro-manifest Patch
@test/astro-markdown-frontmatter-injection Patch
@test/astro-markdown-plugins Patch
@test/astro-markdown-remarkRehype Patch
@test/astro-markdown-skiki-default-color Patch
@test/astro-markdown-skiki-langs Patch
@test/astro-markdown-skiki-themes-custom Patch
@test/astro-markdown-skiki-themes-integrated Patch
@test/astro-markdown-skiki-wrap-false Patch
@test/astro-markdown-skiki-wrap-null Patch
@test/astro-markdown-skiki-wrap-true Patch
@test/astro-markdown-url Patch
@test/astro-markdown Patch
@test/astro-mode Patch
@test/astro-page-directory-url Patch
@test/astro-partial-html Patch
@test/astro-preview-allowed-hosts Patch
@test/astro-preview-headers Patch
@test/astro-public Patch
@test/astro-script-template-dedup Patch
@test/astro-scripts Patch
@test/astro-slots-nested Patch
@test/concurrency Patch
@test/build-readonly-file Patch
@test/cache-memory-query-include Patch
@test/cache-memory-query Patch
@test/client-address-node Patch
@test/client-only-child-styles Patch
@test/client-only-css-chunk-leak Patch
@test/code-component Patch
@test/component-library Patch
@test/config-vite-css-target-no-minify Patch
@test/config-vite-css-target Patch
@test/config-vite Patch
@test/react-container Patch
@test/content-with-spaces-in-folder-name Patch
@test/content-collection-picture-render Patch
@example/content-collection-references Patch
@test/content-collection-tla-svg Patch
@test/content-collections-base Patch
@test/content-collections-empty-dir Patch
@test/content-collections-empty-md-file Patch
@test/content-collections-image-hmr Patch
@test/content-collections-mutation Patch
@test/content-collections-number-id Patch
@test/content-collections-type-inference Patch
@test/content-collections-with-config-mjs Patch
@test/content-collections Patch
@test/content-frontmatter Patch
@test/content-intellisense Patch
@test/content-layer-loader-schema-function Patch
@test/content-layer-remark-plugins Patch
@test/content-layer Patch
@test/content-ssr-integration Patch
@test/content-static-paths-integration Patch
@test/content Patch
@test/core-image-data-url Patch
@test/core-image-deletion-ssr Patch
@test/core-image-deletion Patch
@test/core-image-errors Patch
@test/core-image-fs-config Patch
@test/core-image-remark-infersize Patch
@test/core-image-layout Patch
@test/core-image-picture-emit-file Patch
@test/core-image-remark-imgattr Patch
@test/core-image-ssg Patch
@test/core-image-ssr Patch
@test/core-image-svg-in-client Patch
@test/core-image-svg Patch
@test/core-image-unconventional-settings Patch
@test/core-image Patch
@test/csp-adapter Patch
@test/csp-fonts Patch
@test/csp Patch
@test/css-assets Patch
@test/css-dangling-references Patch
@test/css-deduplication Patch
@test/css-double-bundle Patch
@test/css-dynamic-import-dev Patch
@test/css-import-as-inline Patch
@test/css-inline-stylesheets Patch
@test/css-no-code-split Patch
@test/css-path-case Patch
@test/css-pure-chunk-query-params Patch
@test/css-server-output-dedup Patch
@test/custom-404-injected-from-dep Patch
@test/custom-404-pkg Patch
custom-fetch-error-pages Patch
@test/custom-renderer Patch
@test/data-collections-schema Patch
@test/data-collections Patch
@test/debug-component Patch
@test/dev-container Patch
@test/dev-render Patch
@test/dev-request-url Patch
@test/dynamic-endpoint-collision Patch
@test/dynamic-route-build-file Patch
@test/endpoint-routing Patch
@test/error-bad-js Patch
@test/error-build-location Patch
@test/error-non-error Patch
@test/extension-matching Patch
@test/fetch Patch
@test/fonts Patch
@test/astro-fontsource-package Patch
@test/get-static-paths-pages Patch
@test/glob-pages-css Patch
@test/head-propagation-prerender-env Patch
@test/hmr-markdown Patch
@test/hmr-new-page Patch
@test/hmr-slots-render Patch
@test/hoisted-imports Patch
@test/html-component Patch
@test/html-escape Patch
@test/html-page Patch
@test/html-slots Patch
@test/hydration-race Patch
@test/i18n-client-import Patch
@test/i18n-css-leak-basic Patch
@test/import-ts-with-js Patch
@test/impostor-md-file Patch
@test/integration-add-page-extension Patch
@test/integration-server-setup Patch
@test/jsx-queue-rendering Patch
@test/large-array-solid Patch
@test/legacy-collections-backwards-compat Patch
@test/lightningcss-css-modules-content Patch
@test/lightningcss-scoped-nesting Patch
@test/live-loaders Patch
@test/markdown Patch
@test/middleware-dev Patch
@test/middleware-full-ssr Patch
@test/middleware-no-user-middlewaqre Patch
@test/middleware-tailwind Patch
@test/minification-html-default Patch
@test/minification-html-jsx Patch
@test/minification-html Patch
@test/non-ascii-path Patch
@test/non-html-pages Patch
@test/page-format Patch
@test/page-level-styles Patch
@test/parallel-components Patch
@test/partials-css-boundary Patch
@test/partials Patch
@test/passthrough-image-service Patch
@test/postcss Patch
@test/preact-compat-component Patch
@test/preact-component Patch
@test/remote-css Patch
@test/request-signal Patch
@test/reuse-injected-entrypoint Patch
@test/root-srcdir-css Patch
@test/scoped-style-strategy Patch
@test/server-entry-fake-adapter Patch
@test/server-entry Patch
@test/server-islands-hybrid Patch
@test/server-islands-ssr Patch
@test/sessions Patch
@test/slots-preact Patch
@test/slots-react Patch
@test/slots-solid Patch
@test/slots-svelte Patch
@test/slots-vue Patch
@test/solid-component Patch
@test/sourcemap Patch
@test/space-in-folder-name Patch
@test/special-chars-in-component-imports Patch
@test/ssr-assets Patch
@test/ssr-dynamic Patch
@test/ssr-partytown Patch
@test/ssr-prerender-get-static-paths Patch
@test/ssr-prerender Patch
@test/ssr-preview Patch
@test/ssr-renderers-static-vue Patch
@test/ssr-request Patch
@test/ssr-hoisted-script Patch
@test/ssr-scripts Patch
@test/static-build-code-component Patch
@test/static-build-dir Patch
@test/static-build-frameworks Patch
@test/static-build-page-url-format Patch
@test/static-build-ssr Patch
@test/static-build Patch
@test/static-redirect Patch
@test/svelte-component Patch
@test/svg-deduplication Patch
@test/tailwindcss Patch
@e2e/third-party-astro Patch
@test/url-import-suffix Patch
@test/view-transitions Patch
@test/virtual-astro-file Patch
@test/vite-virtual-modules Patch
@test/vitest Patch
@test/vue-component Patch
@test/vue-with-multi-renderer Patch
@test/alpinejs-basics Patch
@test/alpinejs-directive Patch
@test/alpinejs-plugin-script-import Patch
@test/astro-cloudflare-allowed-hosts Patch
@test/astro-cloudflare-astro-dev-platform Patch
@test/astro-cloudflare-astro-env Patch
@test/astro-cloudflare-binding-image-service Patch
@test/astro-cloudflare-cache-provider-wait-until Patch
@test/astro-cloudflare-cache-provider Patch
@test/astro-cloudflare-client-address Patch
@test/astro-cloudflare-compile-custom-image-service Patch
@test/astro-cloudflare-compile-image-service Patch
@test/astro-cloudflare-custom-entryfile Patch
@test/astro-cloudflare-dev-image-endpoint Patch
@test/astro-cloudflare-external-image-service Patch
@test/astro-cloudflare-external-redirects Patch
@test/astro-cloudflare-internal-redirects Patch
@test/astro-cloudflare-no-output Patch
@test/astro-cloudflare-prerender-node-env Patch
@test/astro-cloudflare-prerender-queue-consumers Patch
@test/astro-cloudflare-prerender-styles Patch
@test/astro-cloudflare-prerenderer-errors Patch
@test/astro-cloudflare-prerenderer-render-error Patch
@test/routing-priority-cloudflare Patch
@test/cf-server-entry Patch
@test/astro-cloudflare-server-island-prerender-framework Patch
@test/astro-cloudflare-sql-import Patch
@test/cf-ssr-deps Patch
@test/astro-cloudflare-static Patch
@test/astro-cloudflare-svelte-rune-deps Patch
@test/astro-cloudflare-top-level-return Patch
@test/cf-user-optimize-deps Patch
@test/astro-cloudflare-vite-plugin Patch
@test/astro-cloudflare-with-base Patch
@test/astro-cloudflare-with-react Patch
@test/astro-cloudflare-with-solid-js Patch
@test/astro-cloudflare-with-svelte Patch
@test/astro-cloudflare-with-vue Patch
@test/astro-cloudflare-wrangler-preview-platform Patch
@test/markdoc-content-collections Patch
@test/content-layer-markdoc Patch
@test/headings-custom Patch
@test/headings Patch
@test/image-assets-custom Patch
@test/image-assets Patch
@test/markdoc-propagated-assets Patch
@test/markdoc-render-with-space Patch
@test/markdoc-render-html Patch
@test/markdoc-render-null Patch
@test/markdoc-render-partials Patch
@test/markdoc-render-simple Patch
@test/markdoc-render-table-attrs Patch
@test/markdoc-render-this-context Patch
@test/markdoc-render-typographer Patch
@test/markdoc-render-with-components Patch
@test/markdoc-render-with-config Patch
@test/markdoc-render-with-extends-components Patch
@test/markdoc-render-with-indented-components Patch
@test/markdoc-render-with-transform Patch
@test/markdoc-variables Patch
@test/content-layer-rendering Patch
@test/mdx-css-head-mdx Patch
@test/image-remark-imgattr Patch
@test/mdx-astro-container-escape Patch
@test/mdx-frontmatter-injection Patch
@test/netlify-skew-protection Patch
@test/netlify-hosted-astro-project Patch
@test/nodejs-api-route Patch
@test/nodejs-badurls Patch
@test/nodejs-encoded Patch
@test/nodejs-errors Patch
@test/nodejs-headers Patch
@test/nodejs-image Patch
@test/locals Patch
@test/node-middleware Patch
@test/nodejs-prerender-404-500 Patch
@test/nodejs-prerender Patch
@test/nodejs-prerendered-error-page-fetch Patch
@test/nodejs-preview-headers Patch
@test/redirects Patch
@test/node-sessions Patch
@test/ssr-assets-middleware Patch
@test/node-static-headers Patch
@test/node-trailingslash Patch
@test/url Patch
@test/well-known-locations Patch
@test/react-component Patch
@test/sitemap-chunks Patch
@test/sitemap-dynamic Patch
@test/sitemap-i18n-fallback Patch
@test/sitemap-ssr Patch
@test/sitemap-static Patch
@test/sitemap-trailing-slash Patch
async-rendering Patch
conditional-rendering Patch
@test/empty-class Patch
svelte-prop-types Patch
@test/astro-vercel-basic Patch
@test/astro-vercel-image Patch
@test/astro-vercel-integration-assets Patch
@test/vercel-isr Patch
@test/vercel-max-duration Patch
@test/vercel-edge-middleware-with-edge-file Patch
@test/vercel-edge-middleware-without-edge-file Patch
@test/astro-vercel-no-output Patch
@test/astro-vercel-prerendered-error-pages Patch
@test/astro-vercel-redirects-serverless Patch
@test/astro-vercel-redirects Patch
@test/vercel-server-islands Patch
@test/astro-vercel-serverless-prerender Patch
@test/astro-vercel-serverless-with-dynamic-routes Patch
@test/astro-vercel-static-assets Patch
@test/vercel-static-headers Patch
@test/astro-vercel-static Patch
@test/vercel-streaming Patch
@test/astro-vercel-with-web-analytics-enabled-output-as-static Patch
vercel-hosted-astro-project Patch
@test/vue-app-entrypoint-async Patch
@test/vue-app-entrypoint-css Patch
@test/vue-app-entrypoint-no-export-default Patch
@test/vue-app-entrypoint-relative Patch
@test/vue-app-entrypoint-src-absolute Patch
@test/vue-app-entrypoint Patch
@test/vue-basics Patch
vue-prop-types Patch
astro-benchmark Patch
@benchmark/adapter Patch
@benchmark/timer Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added the pkg: astro Related to the core `astro` package (scope) label Jul 29, 2026
@codspeed-hq

codspeed-hq Bot commented Jul 29, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 7 untouched benchmarks
⏩ 14 skipped benchmarks1


Comparing Mohith26:fix/client-router-prefetch-reuse (2310fb7) with main (bbc1ec9)2

Open in CodSpeed

Footnotes

  1. 14 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (4806275) during the generation of this report, so bbc1ec9 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@iseraph-dev

Copy link
Copy Markdown
Contributor

Thanks for picking this up, and for reading the issue closely. The mechanism, the 5 minute constant, the !init?.method guard, the separate dependency-free module and the changeset all match what I had in mind, and the shared normalization is exactly right.

I applied your patch locally and measured it against the reproduction. The fix works. It also does one thing you probably did not intend, and that part I would fix before this lands.

Setup for everything below. The repro from the issue (output: 'server', @astrojs/node standalone, nothing in front of it). Astro 7.1.6 with your client code transplanted verbatim into node_modules/astro/dist (added prefetch/registry.js, applied your edits to prefetch/index.js and transitions/router.js), app rebuilt. Chrome, each run in a fresh browser context so the HTTP cache starts empty.

The reuse entry is never consumed, so this is a five minute stale HTML window

Nothing removes a URL from prefetchedUrls, and canPrefetchUrl() refuses to prefetch a URL that is already in it. So the timestamp is never refreshed, and force-cache on a cache hit does not refresh the stored response either. The result is not "reuse the prefetch once", it is "serve the T=0 body for every navigation to that URL for five minutes".

Control, stock 7.1.6, hover /about, click, go back to /, hover and click again:

step initiatorType transferSize deliveryType /about renders
hover prefetch link 7027 ""
click fetch 7027 "" 17:02:31.628
second visit fetch 7027 "" 17:03:03.218

The double download is the bug from the issue. The second visit renders fresh, which is correct.

Same sequence with your branch:

step initiatorType transferSize deliveryType /about renders
hover prefetch link 7027 ""
click fetch 0 cache 17:05:11.408
second visit fetch 0 cache 17:05:11.408
third visit, about a minute later fetch 0 cache 17:05:11.408

The first click is the fix working, and it is a clean win. After that /about is frozen. Three visits, one render. In between, / rendered fresh at 17:05:34.479, so the server was up and rendering on demand the whole time.

The second hover appends no new <link rel="prefetch">, which is what closes the loop: hasBeenPrefetched() is a permanent .has(), so a fresher copy is never fetched and never recorded.

The justification in my issue ("the prefetch had already fetched exactly that copy and the user is one click behind it") only covers the first navigation. Past that this is a silent client side HTML cache on on-demand rendered pages, which is the one thing a cart, an inbox or a dashboard cannot have. A user who submits a form and navigates back to a page they hovered four minutes ago sees pre-submit HTML.

Consuming the entry fixes it and keeps the whole win

The key can keep meaning "already prefetched" while the value means "still reusable":

// registry.ts, replacing wasPrefetchedRecently()
export function consumePrefetchReuse(url: string): boolean {
	const key = normalizePrefetchUrl(url);
	const prefetchedAt = prefetchedUrls.get(key);
	if (prefetchedAt === undefined || Date.now() - prefetchedAt >= PREFETCH_REUSE_WINDOW) return false;
	prefetchedUrls.set(key, 0); // still deduped by hasBeenPrefetched(), no longer reusable
	return true;
}

// router.ts
if (!init?.method && consumePrefetchReuse(href)) fetchInit.cache = 'force-cache';

Measured with that applied:

step initiatorType transferSize deliveryType /about renders
hover prefetch link 7027 ""
click fetch 0 cache 17:07:03.220
second visit fetch 7027 "" 17:07:04.201

First click still served from cache, second visit back to normal. One known trade-off worth a comment: a navigation aborted mid-fetch and retried loses the reuse. That is a missed optimization, not a correctness problem.

Whether hasBeenPrefetched() should also expire is a separate question. Today a URL prefetched forty minutes ago still blocks a re-prefetch, which the Set to Map change makes fixable for the first time. I would leave it out of this PR, but it is worth a line saying you considered it.

The PR does more than it claims, and clientPrerender is the case

I expected experimental.clientPrerender to be a no-op here, on the assumption that speculation rules prefetches sit outside the HTTP cache. That is wrong, measured both ways:

clientPrerender: true initiatorType transferSize deliveryType
stock 7.1.6, click fetch 7027 ""
your branch, click fetch 0 cache

So today clientPrerender users pay for a speculation rules prefetch, a prerender, and then a full download on click, and this PR fixes that too. Worth saying in the description, it is a stronger result than the one you are claiming. The staleness above applies there as well, since it is the same registry entry.

Test coverage

The ten new tests cover the Map, and you are upfront that the wiring itself is not covered. I think that gap is worth closing in this PR rather than after it, because the Playwright suite already has everything needed:

  • e2e/prefetch.test.ts:9-18 already records every request through page.on('request').
  • build() plus preview() against an SSR fixture is an established pattern, see e2e/server-islands.test.ts and e2e/csp-server-islands.test.ts.
  • playwright.config.js runs Chrome Stable only, so the performance.getEntriesByType('resource') assertions above transfer directly.

An output: 'server' fixture with <ClientRouter /> and a page rendering a timestamp covers it: hover, wait past the 80 ms debounce, click, assert deliveryType === 'cache' and that the rendered timestamp is the prefetch's. Two more asserts are cheap and would have caught the issue above: a second visit gets a fresh render, and a form POST still reaches the server.

One thing you do not need to worry about: I checked whether the two entry points really share one instance of the registry, since separate Maps would make this a silent no-op with green unit tests. In a production build everything lands in a single client chunk with a single Map, so the sharing holds.

Smaller things

  • Name the no-cache interaction in the description. The issue's "honest downside" covers max-age=0, must-revalidate. The sharper case is Cache-Control: no-cache with an ETag, the canonical "SSR HTML, always revalidate" setup, which works well today: prefetch stores it, the click revalidates, the server answers 304, no body crosses the wire. This PR turns that into a skipped revalidation. Bounded to one navigation seconds after its own prefetch it is defensible and I would still argue for no opt-out. Left as a five minute window it is the kind of thing a maintainer will want prefetch.reusePrefetchedResponse for. Either way it belongs in the PR body rather than only in the issue.
  • One normalization instead of two. prefetch() still does url = url.replace(/#.*/, '') at prefetch/index.ts:222 and the registry normalizes again. Using url = normalizePrefetchUrl(url) there makes the string handed to <link href> and to the speculation rules identical to the registry key, drops a regex, and removes a latent <base href> discrepancy, since link[href] resolves against document.baseURI while the registry resolves against location.href. The existing e2e assertions use href$= suffix matching so absolute hrefs still pass.
  • The new call sits inside fetchHTML's catch-all. A throw anywhere in that try returns null, and null makes the router fall back to a full page load. wasPrefetchedRecently() calls new URL(). It cannot throw for preparationEvent.to.href, but hoisting the call above the try costs nothing and keeps a hard reload off that path for good.
  • prefetch: false still pays for this. router.ts imports the registry unconditionally, so with prefetch off every navigation runs a new URL() and a map lookup that can never be true. __PREFETCH_DISABLED__ is only substituted into ClientRouter.astro, so gating it means widening that transform to router.ts. Probably not worth it, just flagging that I looked.
  • Bundle rationale. The comment in registry.ts says importing prefetch/index.ts from the router would pull the prefetch script into bundles that do not use it. ClientRouter.astro:151-153 already imports init from it, so that is only true for a bare astro:transitions/client import that just uses navigate(). Still a good reason to keep the module separate, just a narrower one than the comment suggests.
  • Docs. The prefetch guide describes navigation behaviour, so this probably wants a companion withastro/docs PR, or a line saying it does not.

Happy to help verify anything here, and the repro is set up for it if you want the exact sequence.

@iseraph-dev iseraph-dev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestions for the one blocking item from my comment above, plus the rename it pulls through the tests. They are written to be committed as a batch (Add suggestion to batch on each, then Commit suggestions), which keeps them in a single commit and leaves CI green: dropping wasPrefetchedRecently without the test edits would fail the unit suite.

Everything else in my comment is a judgement call for you, so I have not put it in a suggestion. Take, edit or ignore any of these freely.

Comment on lines +41 to +48
/**
* Whether a URL was prefetched recently enough that the response the prefetch downloaded
* can be reused without revalidation, mirroring how browsers treat their own prefetches.
*/
export function wasPrefetchedRecently(url: string): boolean {
const prefetchedAt = prefetchedUrls.get(normalizePrefetchUrl(url));
return prefetchedAt !== undefined && Date.now() - prefetchedAt < PREFETCH_REUSE_WINDOW;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one change I would not merge without. Measured on your branch against the reproduction: after one hover, /about served the same T=0 body on three consecutive visits (transferSize: 0, deliveryType: "cache" each time) while / kept rendering fresh. Stock 7.1.6 renders fresh on the second visit, so this is new behaviour rather than something the PR inherits. Details and tables in my comment above.

Consuming the entry keeps the whole win: with this applied the first click was still 0 B / cache and the second visit went back to the network. Known trade-off worth knowing about: a navigation aborted mid-fetch and retried loses the reuse, which is a missed optimization rather than a correctness problem.

Suggested change
/**
* Whether a URL was prefetched recently enough that the response the prefetch downloaded
* can be reused without revalidation, mirroring how browsers treat their own prefetches.
*/
export function wasPrefetchedRecently(url: string): boolean {
const prefetchedAt = prefetchedUrls.get(normalizePrefetchUrl(url));
return prefetchedAt !== undefined && Date.now() - prefetchedAt < PREFETCH_REUSE_WINDOW;
}
/**
* Whether a URL was prefetched recently enough that the response the prefetch downloaded
* can be reused without revalidation, mirroring how browsers treat their own prefetches.
*
* The reuse is one-shot. `canPrefetchUrl()` refuses to prefetch a URL it already tracks, and
* `force-cache` does not refresh a cache hit, so leaving the timestamp in place would replay
* the same prefetched body for every navigation to that URL until the window expires.
*/
export function consumePrefetchReuse(url: string): boolean {
const key = normalizePrefetchUrl(url);
const prefetchedAt = prefetchedUrls.get(key);
if (prefetchedAt === undefined || Date.now() - prefetchedAt >= PREFETCH_REUSE_WINDOW) {
return false;
}
// Keep the key so `hasBeenPrefetched()` still deduplicates, but drop the reusable timestamp.
prefetchedUrls.set(key, 0);
return true;
}

@@ -1,4 +1,5 @@
import { internalFetchHeaders } from 'virtual:astro:adapter-config/client';
import { wasPrefetchedRecently } from '../prefetch/registry.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import { wasPrefetchedRecently } from '../prefetch/registry.js';
import { consumePrefetchReuse } from '../prefetch/registry.js';

// downloaded. On-demand rendered pages typically carry no cache headers, so with the
// default cache mode the browser would download the page a second time (#17549).
// Form submissions must always reach the server, so they never reuse the cache.
if (!init?.method && wasPrefetchedRecently(href)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!init?.method && wasPrefetchedRecently(href)) {
if (!init?.method && consumePrefetchReuse(href)) {

Comment on lines +3 to +8
import {
hasBeenPrefetched,
normalizePrefetchUrl,
recordPrefetch,
wasPrefetchedRecently,
} from '../../../dist/prefetch/registry.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import list re-sorted so biome's organize-imports stays happy.

Suggested change
import {
hasBeenPrefetched,
normalizePrefetchUrl,
recordPrefetch,
wasPrefetchedRecently,
} from '../../../dist/prefetch/registry.js';
import {
consumePrefetchReuse,
hasBeenPrefetched,
normalizePrefetchUrl,
recordPrefetch,
} from '../../../dist/prefetch/registry.js';

Comment on lines +50 to +83
describe('wasPrefetchedRecently()', () => {
it('matches an absolute URL with a hash after prefetching a relative URL', () => {
// `prefetch()` can be handed a relative URL, while the router looks up
// `preparationEvent.to.href`, which is absolute and may carry a hash.
recordPrefetch('/relative-vs-absolute');
assert.equal(wasPrefetchedRecently('https://example.com/relative-vs-absolute#section'), true);
});

it('matches a relative URL after prefetching an absolute URL', () => {
recordPrefetch('https://example.com/absolute-vs-relative');
assert.equal(wasPrefetchedRecently('/absolute-vs-relative'), true);
});

it('does not match URLs that were never prefetched', () => {
assert.equal(wasPrefetchedRecently('/never-prefetched'), false);
});

it('does not match a URL with a different query string', () => {
recordPrefetch('/list?page=1');
assert.equal(wasPrefetchedRecently('/list?page=2'), false);
});

it('stops matching once the reuse window has passed', () => {
const start = 1_000_000;
mock.timers.enable({ apis: ['Date'], now: start });
recordPrefetch('/expires');

mock.timers.setTime(start + PREFETCH_REUSE_WINDOW - 1);
assert.equal(wasPrefetchedRecently('/expires'), true);

mock.timers.setTime(start + PREFETCH_REUSE_WINDOW);
assert.equal(wasPrefetchedRecently('/expires'), false);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renames the block and adds the case the change exists for. Note that the original stops matching once the reuse window has passed test has to be split in two: it asserted true just inside the window and false at the boundary on the same URL, and once the first call consumes the entry the second assertion would pass for the wrong reason. Two tests keep both edges honest.

Suggested change
describe('wasPrefetchedRecently()', () => {
it('matches an absolute URL with a hash after prefetching a relative URL', () => {
// `prefetch()` can be handed a relative URL, while the router looks up
// `preparationEvent.to.href`, which is absolute and may carry a hash.
recordPrefetch('/relative-vs-absolute');
assert.equal(wasPrefetchedRecently('https://example.com/relative-vs-absolute#section'), true);
});
it('matches a relative URL after prefetching an absolute URL', () => {
recordPrefetch('https://example.com/absolute-vs-relative');
assert.equal(wasPrefetchedRecently('/absolute-vs-relative'), true);
});
it('does not match URLs that were never prefetched', () => {
assert.equal(wasPrefetchedRecently('/never-prefetched'), false);
});
it('does not match a URL with a different query string', () => {
recordPrefetch('/list?page=1');
assert.equal(wasPrefetchedRecently('/list?page=2'), false);
});
it('stops matching once the reuse window has passed', () => {
const start = 1_000_000;
mock.timers.enable({ apis: ['Date'], now: start });
recordPrefetch('/expires');
mock.timers.setTime(start + PREFETCH_REUSE_WINDOW - 1);
assert.equal(wasPrefetchedRecently('/expires'), true);
mock.timers.setTime(start + PREFETCH_REUSE_WINDOW);
assert.equal(wasPrefetchedRecently('/expires'), false);
});
});
describe('consumePrefetchReuse()', () => {
it('matches an absolute URL with a hash after prefetching a relative URL', () => {
// `prefetch()` can be handed a relative URL, while the router looks up
// `preparationEvent.to.href`, which is absolute and may carry a hash.
recordPrefetch('/relative-vs-absolute');
assert.equal(consumePrefetchReuse('https://example.com/relative-vs-absolute#section'), true);
});
it('matches a relative URL after prefetching an absolute URL', () => {
recordPrefetch('https://example.com/absolute-vs-relative');
assert.equal(consumePrefetchReuse('/absolute-vs-relative'), true);
});
it('does not match URLs that were never prefetched', () => {
assert.equal(consumePrefetchReuse('/never-prefetched'), false);
});
it('does not match a URL with a different query string', () => {
recordPrefetch('/list?page=1');
assert.equal(consumePrefetchReuse('/list?page=2'), false);
});
it('reuses a prefetch only once', () => {
// Nothing ever records a fresher copy, so a second navigation has to go back to the
// network instead of replaying the body the first one already used.
recordPrefetch('/reuse-once');
assert.equal(consumePrefetchReuse('/reuse-once'), true);
assert.equal(consumePrefetchReuse('/reuse-once'), false);
});
it('still reuses just inside the window', () => {
const start = 1_000_000;
mock.timers.enable({ apis: ['Date'], now: start });
recordPrefetch('/just-inside');
mock.timers.setTime(start + PREFETCH_REUSE_WINDOW - 1);
assert.equal(consumePrefetchReuse('/just-inside'), true);
});
it('stops matching once the reuse window has passed', () => {
const start = 1_000_000;
mock.timers.enable({ apis: ['Date'], now: start });
recordPrefetch('/expires');
mock.timers.setTime(start + PREFETCH_REUSE_WINDOW);
assert.equal(consumePrefetchReuse('/expires'), false);
});
});

// Still deduplicated by the prefetch script...
assert.equal(hasBeenPrefetched('/old-prefetch'), true);
// ...but too old for the router to reuse without revalidation.
assert.equal(wasPrefetchedRecently('/old-prefetch'), false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert.equal(wasPrefetchedRecently('/old-prefetch'), false);
assert.equal(consumePrefetchReuse('/old-prefetch'), false);

Mohith26 added a commit to Mohith26/astro that referenced this pull request Jul 29, 2026
Review follow-up from withastro#17550: reusing the prefetched response for the
whole five-minute window froze on-demand rendered pages on their first
prefetched body (measured by the reporter: three visits, one render).
consumePrefetchReuse() zeroes the entry on first use so exactly one
navigation reuses the prefetch, while hasBeenPrefetched() keeps
deduplicating. Also hoists the registry call above fetchHTML's
catch-all so a registry error can never cause a full page load, and
uses the registry normalization in prefetch() itself so the link href,
speculation rules, and registry key are byte-identical (resolving
against location.href rather than a divergent <base href>).
Review follow-up from withastro#17550: reusing the prefetched response for the
whole five-minute window froze on-demand rendered pages on their first
prefetched body (measured by the reporter: three visits, one render).
consumePrefetchReuse() zeroes the entry on first use so exactly one
navigation reuses the prefetch, while hasBeenPrefetched() keeps
deduplicating. Also hoists the registry call above fetchHTML's
catch-all so a registry error can never cause a full page load, and
uses the registry normalization in prefetch() itself so the link href,
speculation rules, and registry key are byte-identical (resolving
against location.href rather than a divergent <base href>).
@Mohith26
Mohith26 force-pushed the fix/client-router-prefetch-reuse branch from caa99f1 to 2310fb7 Compare July 29, 2026 19:11
@Mohith26

Copy link
Copy Markdown
Author

This is a phenomenal review, thank you for measuring everything - you were right on all counts, and the staleness finding especially. All three fixes are in as of 2310fb7:

  • Consumption semantics: wasPrefetchedRecently() is now consumePrefetchReuse() exactly as you sketched: the timestamp is zeroed on first use so precisely one navigation reuses the prefetched body, hasBeenPrefetched() keeps deduplicating, and recordPrefetch() after consumption restores reusability only when the prefetch script really fetched a fresh copy. Your aborted-navigation trade-off and the deliberate non-expiry of hasBeenPrefetched() are both noted in the code comment.
  • Hoisted out of the catch-all: the registry call now runs before the try, so a registry error can never degrade into a full page load.
  • One normalization: prefetch() now uses normalizePrefetchUrl() directly, so the link href, speculation rules, and registry key are byte-identical and resolve against location.href (goodbye latent <base href> divergence).

Three new unit tests pin the one-shot behavior (consume once, still deduped, re-prefetch restores reuse); full unit suite is green (3221). The PR body now names the clientPrerender win and the no-cache + ETag revalidation trade-off, per your notes. On the e2e: your pointers (the request recording in e2e/prefetch.test.ts, the SSR fixture pattern, deliveryType assertions) make it very concrete - happy to add the output: 'server' fixture test with the fresh-second-visit and form-POST asserts to this PR if you and the maintainers want it here rather than as a follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg: astro Related to the core `astro` package (scope)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ClientRouter refetches the HTML that prefetch just downloaded

2 participants