diff --git a/src/observers/link_prefetch_observer.js b/src/observers/link_prefetch_observer.js index 5f5f66d43..09631d854 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,29 @@ export class LinkPrefetchObserver { fetchRequest.fetchOptions.priority = "low" + // Memorize fetch request and cancel previous one to the same URL + const url = location.href + const lastFetchRequest = this.#pendingFetchRequests.get(url) + + if(lastFetchRequest) lastFetchRequest.cancel() + this.#pendingFetchRequests.set(url, fetchRequest) + prefetchCache.putLater(location, fetchRequest, this.#cacheTtl) } } } + #cancelPendingFetchRequests = (event) => { + if (event.detail.fetchRequest) return + + for (const [url, fetchRequest] of this.#pendingFetchRequests.entries()) { + if (url !== event.detail.url) { + fetchRequest.cancel() + this.#pendingFetchRequests.delete(url) + } + } + } + #cancelRequestIfObsolete = (event) => { if (event.target === this.#prefetchedLink) this.#cancelPrefetchRequest() } @@ -128,7 +149,13 @@ export class LinkPrefetchObserver { requestErrored(fetchRequest) {} - requestFinished(fetchRequest) {} + requestFinished(fetchRequest) { + const url = fetchRequest.url.href + + if(this.#pendingFetchRequests.get(url) === fetchRequest) { + this.#pendingFetchRequests.delete(url) + } + } 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..0f284544b 100644 --- a/src/tests/functional/link_prefetch_observer_tests.js +++ b/src/tests/functional/link_prefetch_observer_tests.js @@ -232,6 +232,74 @@ 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 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 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" })