From 1a7c56f7b7f98152ae8a27d3056138c7f1cd8cce Mon Sep 17 00:00:00 2001 From: "M. Vondano" Date: Sat, 24 Jan 2026 17:06:12 +0100 Subject: [PATCH 1/3] record and cancel pending prefetch requests --- src/observers/link_prefetch_observer.js | 32 ++++++++++++++++- src/tests/fixtures/hover_to_prefetch.html | 1 + .../link_prefetch_observer_tests.js | 36 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/observers/link_prefetch_observer.js b/src/observers/link_prefetch_observer.js index 5f5f66d43..f4c04c63d 100644 --- a/src/observers/link_prefetch_observer.js +++ b/src/observers/link_prefetch_observer.js @@ -11,6 +11,7 @@ import { prefetchCache, cacheTtl } from "../core/drive/prefetch_cache" export class LinkPrefetchObserver { started = false #prefetchedLink = null + #pendingFetchRequests = new Map() constructor(delegate, eventTarget) { this.delegate = delegate @@ -40,6 +41,7 @@ export class LinkPrefetchObserver { }) this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true) + this.eventTarget.removeEventListener("turbo:before-visit", this.#cancelPendingFetchRequests, true) this.started = false } @@ -54,6 +56,7 @@ export class LinkPrefetchObserver { }) this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true) + this.eventTarget.addEventListener("turbo:before-visit", this.#cancelPendingFetchRequests, true) this.started = true } @@ -80,11 +83,32 @@ export class LinkPrefetchObserver { fetchRequest.fetchOptions.priority = "low" + // Track pending fetch requests belonging to the current URL + const url = location.href + if (!this.#pendingFetchRequests.has(url)) { + this.#pendingFetchRequests.set(url, []) + } + this.#pendingFetchRequests.get(url).push(fetchRequest) + prefetchCache.putLater(location, fetchRequest, this.#cacheTtl) } } } + #cancelPendingFetchRequests = (event) => { + if (event.detail.fetchRequest) return + + for (const [url, fetchRequests] of this.#pendingFetchRequests.entries()) { + const isSameUrl = url === event.detail.url + + for (const fetchRequest of isSameUrl ? fetchRequests.slice(0, -1) : fetchRequests) { + fetchRequest.cancel() + } + + this.#pendingFetchRequests.set(url, isSameUrl ? [fetchRequests[fetchRequests.length - 1]] : []) + } + } + #cancelRequestIfObsolete = (event) => { if (event.target === this.#prefetchedLink) this.#cancelPrefetchRequest() } @@ -128,7 +152,13 @@ export class LinkPrefetchObserver { requestErrored(fetchRequest) {} - requestFinished(fetchRequest) {} + requestFinished(fetchRequest) { + const pendingFetchRequests = this.#pendingFetchRequests.get(fetchRequest.url.href) + if (!pendingFetchRequests?.length) return + + const index = pendingFetchRequests.indexOf(fetchRequest) + if (index !== -1) pendingFetchRequests.splice(index, 1) + } requestPreventedHandlingResponse(fetchRequest, fetchResponse) {} diff --git a/src/tests/fixtures/hover_to_prefetch.html b/src/tests/fixtures/hover_to_prefetch.html index bd0f6f944..75540dc08 100644 --- a/src/tests/fixtures/hover_to_prefetch.html +++ b/src/tests/fixtures/hover_to_prefetch.html @@ -11,6 +11,7 @@ Hover to prefetch me Hover to prefetch me + Hover to prefetch me (response, that takes long to render) Hover to prefetch me diff --git a/src/tests/functional/link_prefetch_observer_tests.js b/src/tests/functional/link_prefetch_observer_tests.js index a40ea16dc..140049ca3 100644 --- a/src/tests/functional/link_prefetch_observer_tests.js +++ b/src/tests/functional/link_prefetch_observer_tests.js @@ -232,6 +232,42 @@ test("it cancels the prefetch request if the link is no longer hovered", async ( }) }) +test("it cancels pending prefetch requests if a new request is made", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + + // Prefetch a request, that takes a long time to load + await hoverSelector({ page, selector: "#anchor_for_slow_prefetch" }) + await page.click("#anchor_for_slow_prefetch", {noWaitAfter: true}) + + // Issue a new request to a secondary resource, that loads fast + await page.click("#anchor_for_prefetch") + + // Allow for the slow request to be processed if it wasn't canceled + await sleep(1100) + + // The page should show the secondary resource + await expect(page).toHaveTitle("Prefetched Page") + await expect(page).toHaveURL("src/tests/fixtures/prefetched.html") +}) + +test("it cancels pending prefetch requests if a new prefetch request is made", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + + // Prefetch a request, that takes a long time to load + await hoverSelector({ page, selector: "#anchor_for_slow_prefetch" }) + await page.click("#anchor_for_slow_prefetch", {noWaitAfter: true}) + + // Issue a new request including prefetch to a secondary resource, that loads fast + await hoverSelector({ page, selector: "#anchor_for_prefetch" }) + await page.click("#anchor_for_prefetch") + + await sleep(1100) + + // The page should show the secondary resource + await expect(page).toHaveTitle("Prefetched Page") + await expect(page).toHaveURL("src/tests/fixtures/prefetched.html") +}) + test("it resets the cache when a link is hovered", async ({ page }) => { await goTo({ page, path: "/hover_to_prefetch.html" }) From 44a5764580d1fcce3b05554c91299d66993dc751 Mon Sep 17 00:00:00 2001 From: "M. Vondano" Date: Sun, 25 Jan 2026 12:21:39 +0100 Subject: [PATCH 2/3] simplify logic and also handle the same URL being prefetched multiple times --- src/observers/link_prefetch_observer.js | 27 +++++++++---------- .../link_prefetch_observer_tests.js | 17 ++++++++++++ 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/observers/link_prefetch_observer.js b/src/observers/link_prefetch_observer.js index f4c04c63d..09631d854 100644 --- a/src/observers/link_prefetch_observer.js +++ b/src/observers/link_prefetch_observer.js @@ -83,12 +83,12 @@ export class LinkPrefetchObserver { fetchRequest.fetchOptions.priority = "low" - // Track pending fetch requests belonging to the current URL + // Memorize fetch request and cancel previous one to the same URL const url = location.href - if (!this.#pendingFetchRequests.has(url)) { - this.#pendingFetchRequests.set(url, []) - } - this.#pendingFetchRequests.get(url).push(fetchRequest) + const lastFetchRequest = this.#pendingFetchRequests.get(url) + + if(lastFetchRequest) lastFetchRequest.cancel() + this.#pendingFetchRequests.set(url, fetchRequest) prefetchCache.putLater(location, fetchRequest, this.#cacheTtl) } @@ -98,14 +98,11 @@ export class LinkPrefetchObserver { #cancelPendingFetchRequests = (event) => { if (event.detail.fetchRequest) return - for (const [url, fetchRequests] of this.#pendingFetchRequests.entries()) { - const isSameUrl = url === event.detail.url - - for (const fetchRequest of isSameUrl ? fetchRequests.slice(0, -1) : fetchRequests) { + for (const [url, fetchRequest] of this.#pendingFetchRequests.entries()) { + if (url !== event.detail.url) { fetchRequest.cancel() + this.#pendingFetchRequests.delete(url) } - - this.#pendingFetchRequests.set(url, isSameUrl ? [fetchRequests[fetchRequests.length - 1]] : []) } } @@ -153,11 +150,11 @@ export class LinkPrefetchObserver { requestErrored(fetchRequest) {} requestFinished(fetchRequest) { - const pendingFetchRequests = this.#pendingFetchRequests.get(fetchRequest.url.href) - if (!pendingFetchRequests?.length) return + const url = fetchRequest.url.href - const index = pendingFetchRequests.indexOf(fetchRequest) - if (index !== -1) pendingFetchRequests.splice(index, 1) + if(this.#pendingFetchRequests.get(url) === fetchRequest) { + this.#pendingFetchRequests.delete(url) + } } requestPreventedHandlingResponse(fetchRequest, fetchResponse) {} diff --git a/src/tests/functional/link_prefetch_observer_tests.js b/src/tests/functional/link_prefetch_observer_tests.js index 140049ca3..212ffd182 100644 --- a/src/tests/functional/link_prefetch_observer_tests.js +++ b/src/tests/functional/link_prefetch_observer_tests.js @@ -268,6 +268,23 @@ test("it cancels pending prefetch requests if a new prefetch request is made", a await expect(page).toHaveURL("src/tests/fixtures/prefetched.html") }) +test("it cancels the pending prefetch request if the same link is hovered again", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + + let finishedRequestCount = 0 + page.on("requestfinished", async () => (finishedRequestCount++)) + + await page.hover("#anchor_for_slow_prefetch") + await sleep(150) + await page.mouse.move(0, 0) + + // Prefetching the same URL again while the last request is still not finished should abort the latter + await page.hover("#anchor_for_slow_prefetch") + + await sleep(1200) + expect(finishedRequestCount).toEqual(1) +}) + test("it resets the cache when a link is hovered", async ({ page }) => { await goTo({ page, path: "/hover_to_prefetch.html" }) From 66669556236db7878fbd94edbd8649ba40fff954 Mon Sep 17 00:00:00 2001 From: "M. Vondano" Date: Sun, 25 Jan 2026 14:56:25 +0100 Subject: [PATCH 3/3] add a test case that verifies that a pending prefetch request is still being used for a visit --- .../functional/link_prefetch_observer_tests.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/tests/functional/link_prefetch_observer_tests.js b/src/tests/functional/link_prefetch_observer_tests.js index 212ffd182..0f284544b 100644 --- a/src/tests/functional/link_prefetch_observer_tests.js +++ b/src/tests/functional/link_prefetch_observer_tests.js @@ -285,6 +285,21 @@ test("it cancels the pending prefetch request if the same link is hovered again" expect(finishedRequestCount).toEqual(1) }) +test("it keeps the running prefetch request when clicking a link", async ({ page }) => { + await goTo({ page, path: "/hover_to_prefetch.html" }) + + let requestCount = 0 + page.on("request", async () => (requestCount++)) + + await hoverSelector({ page, selector: "#anchor_for_slow_prefetch" }) + await sleep(150) + await page.click("#anchor_for_slow_prefetch") + + // The prefetch request should be used for the visit + await expect(page).toHaveTitle("One") + expect(requestCount).toEqual(1) +}) + test("it resets the cache when a link is hovered", async ({ page }) => { await goTo({ page, path: "/hover_to_prefetch.html" })