diff --git a/playwright.config.js b/playwright.config.js
index aae8335d2..4b4dfdc22 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -8,7 +8,8 @@ const config = {
...devices["Desktop Chrome"],
contextOptions: {
timeout: 60000
- }
+ },
+ hasTouch: true
}
},
{
@@ -17,7 +18,8 @@ const config = {
...devices["Desktop Firefox"],
contextOptions: {
timeout: 60000
- }
+ },
+ hasTouch: true
}
}
],
diff --git a/src/core/drive/form_submission.js b/src/core/drive/form_submission.js
index a5007d2c5..c2ac1a0db 100644
--- a/src/core/drive/form_submission.js
+++ b/src/core/drive/form_submission.js
@@ -2,6 +2,7 @@ import { FetchRequest, FetchMethod, fetchMethodFromString, fetchEnctypeFromStrin
import { expandURL } from "../url"
import { clearBusyState, dispatch, getAttribute, getMetaContent, hasAttribute, markAsBusy } from "../../util"
import { StreamMessage } from "../streams/stream_message"
+import { prefetchCache } from "./prefetch_cache"
export const FormSubmissionState = {
initialized: "initialized",
@@ -126,13 +127,20 @@ export class FormSubmission {
}
requestPreventedHandlingResponse(request, response) {
+ prefetchCache.clear()
+
this.result = { success: response.succeeded, fetchResponse: response }
}
requestSucceededWithResponse(request, response) {
if (response.clientError || response.serverError) {
this.delegate.formSubmissionFailedWithResponse(this, response)
- } else if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
+ return
+ }
+
+ prefetchCache.clear()
+
+ if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
const error = new Error("Form responses must redirect to another location")
this.delegate.formSubmissionErrored(this, error)
} else {
diff --git a/src/core/drive/prefetch_cache.js b/src/core/drive/prefetch_cache.js
new file mode 100644
index 000000000..ecca297b7
--- /dev/null
+++ b/src/core/drive/prefetch_cache.js
@@ -0,0 +1,34 @@
+const PREFETCH_DELAY = 100
+
+class PrefetchCache {
+ #prefetchTimeout = null
+ #prefetched = null
+
+ get(url) {
+ if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) {
+ return this.#prefetched.request
+ }
+ }
+
+ setLater(url, request, ttl) {
+ this.clear()
+
+ this.#prefetchTimeout = setTimeout(() => {
+ request.perform()
+ this.set(url, request, ttl)
+ this.#prefetchTimeout = null
+ }, PREFETCH_DELAY)
+ }
+
+ set(url, request, ttl) {
+ this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) }
+ }
+
+ clear() {
+ if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout)
+ this.#prefetched = null
+ }
+}
+
+export const cacheTtl = 10 * 1000
+export const prefetchCache = new PrefetchCache()
diff --git a/src/core/session.js b/src/core/session.js
index 8dafa6f13..7bfb20ca2 100644
--- a/src/core/session.js
+++ b/src/core/session.js
@@ -3,6 +3,7 @@ import { CacheObserver } from "../observers/cache_observer"
import { FormSubmitObserver } from "../observers/form_submit_observer"
import { FrameRedirector } from "./frames/frame_redirector"
import { History } from "./drive/history"
+import { LinkPrefetchObserver } from "../observers/link_prefetch_observer"
import { LinkClickObserver } from "../observers/link_click_observer"
import { FormLinkClickObserver } from "../observers/form_link_click_observer"
import { getAction, expandURL, locationIsVisitable } from "./url"
@@ -26,6 +27,7 @@ export class Session {
pageObserver = new PageObserver(this)
cacheObserver = new CacheObserver()
+ linkPrefetchObserver = new LinkPrefetchObserver(this, document)
linkClickObserver = new LinkClickObserver(this, window)
formSubmitObserver = new FormSubmitObserver(this, document)
scrollObserver = new ScrollObserver(this)
@@ -53,6 +55,7 @@ export class Session {
if (!this.started) {
this.pageObserver.start()
this.cacheObserver.start()
+ this.linkPrefetchObserver.start()
this.formLinkClickObserver.start()
this.linkClickObserver.start()
this.formSubmitObserver.start()
@@ -74,6 +77,7 @@ export class Session {
if (this.started) {
this.pageObserver.stop()
this.cacheObserver.stop()
+ this.linkPrefetchObserver.stop()
this.formLinkClickObserver.stop()
this.linkClickObserver.stop()
this.formSubmitObserver.stop()
@@ -199,6 +203,15 @@ export class Session {
submittedFormLinkToLocation() {}
+ // Link hover observer delegate
+
+ canPrefetchRequestToLocation(link, location) {
+ return (
+ this.elementIsNavigatable(link) &&
+ locationIsVisitable(location, this.snapshot.rootLocation)
+ )
+ }
+
// Link click observer delegate
willFollowLinkToLocation(link, location, event) {
diff --git a/src/http/fetch_request.js b/src/http/fetch_request.js
index 3d79f24ae..f4ff5adbe 100644
--- a/src/http/fetch_request.js
+++ b/src/http/fetch_request.js
@@ -121,10 +121,17 @@ export class FetchRequest {
async perform() {
const { fetchOptions } = this
this.delegate.prepareRequest(this)
- await this.#allowRequestToBeIntercepted(fetchOptions)
+ const event = await this.#allowRequestToBeIntercepted(fetchOptions)
try {
this.delegate.requestStarted(this)
- const response = await fetch(this.url.href, fetchOptions)
+
+ if (event.detail.fetchRequest) {
+ this.response = event.detail.fetchRequest.response
+ } else {
+ this.response = fetch(this.url.href, fetchOptions)
+ }
+
+ const response = await this.response
return await this.receive(response)
} catch (error) {
if (error.name !== "AbortError") {
@@ -186,6 +193,8 @@ export class FetchRequest {
})
this.url = event.detail.url
if (event.defaultPrevented) await requestInterception
+
+ return event
}
#willDelegateErrorHandling(error) {
diff --git a/src/observers/form_link_click_observer.js b/src/observers/form_link_click_observer.js
index 55b94bb64..c6471fba4 100644
--- a/src/observers/form_link_click_observer.js
+++ b/src/observers/form_link_click_observer.js
@@ -15,6 +15,16 @@ export class FormLinkClickObserver {
this.linkInterceptor.stop()
}
+ // Link hover observer delegate
+
+ canPrefetchRequestToLocation(link, location) {
+ return false
+ }
+
+ prefetchAndCacheRequestToLocation(link, location) {
+ return
+ }
+
// Link click observer delegate
willFollowLinkToLocation(link, location, originalEvent) {
diff --git a/src/observers/link_click_observer.js b/src/observers/link_click_observer.js
index e6bd2fcaf..24c6aa235 100644
--- a/src/observers/link_click_observer.js
+++ b/src/observers/link_click_observer.js
@@ -1,5 +1,4 @@
-import { expandURL } from "../core/url"
-import { findClosestRecursively } from "../util"
+import { doesNotTargetIFrame, findLinkFromClickTarget, getLocationForLink } from "../util"
export class LinkClickObserver {
started = false
@@ -31,9 +30,9 @@ export class LinkClickObserver {
clickBubbled = (event) => {
if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) {
const target = (event.composedPath && event.composedPath()[0]) || event.target
- const link = this.findLinkFromClickTarget(target)
+ const link = findLinkFromClickTarget(target)
if (link && doesNotTargetIFrame(link)) {
- const location = this.getLocationForLink(link)
+ const location = getLocationForLink(link)
if (this.delegate.willFollowLinkToLocation(link, location, event)) {
event.preventDefault()
this.delegate.followedLinkToLocation(link, location)
@@ -53,24 +52,4 @@ export class LinkClickObserver {
event.shiftKey
)
}
-
- findLinkFromClickTarget(target) {
- return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])")
- }
-
- getLocationForLink(link) {
- return expandURL(link.getAttribute("href") || "")
- }
-}
-
-function doesNotTargetIFrame(anchor) {
- if (anchor.hasAttribute("target")) {
- for (const element of document.getElementsByName(anchor.target)) {
- if (element instanceof HTMLIFrameElement) return false
- }
-
- return true
- } else {
- return true
- }
}
diff --git a/src/observers/link_prefetch_observer.js b/src/observers/link_prefetch_observer.js
new file mode 100644
index 000000000..de08e05da
--- /dev/null
+++ b/src/observers/link_prefetch_observer.js
@@ -0,0 +1,179 @@
+import {
+ doesNotTargetIFrame,
+ getLocationForLink,
+ getMetaContent,
+ findClosestRecursively
+} from "../util"
+
+import { FetchMethod, FetchRequest } from "../http/fetch_request"
+import { prefetchCache, cacheTtl } from "../core/drive/prefetch_cache"
+
+export class LinkPrefetchObserver {
+ started = false
+ hoverTriggerEvent = "mouseenter"
+ touchTriggerEvent = "touchstart"
+
+ constructor(delegate, eventTarget) {
+ this.delegate = delegate
+ this.eventTarget = eventTarget
+ }
+
+ start() {
+ if (this.started) return
+
+ if (this.eventTarget.readyState === "loading") {
+ this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, { once: true })
+ } else {
+ this.#enable()
+ }
+ }
+
+ stop() {
+ if (!this.started) return
+
+ this.eventTarget.removeEventListener(this.hoverTriggerEvent, this.#tryToPrefetchRequest, {
+ capture: true,
+ passive: true
+ })
+ this.eventTarget.removeEventListener(this.touchTriggerEvent, this.#tryToPrefetchRequest, {
+ capture: true,
+ passive: true
+ })
+ this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true)
+ this.started = false
+ }
+
+ #enable = () => {
+ this.eventTarget.addEventListener(this.hoverTriggerEvent, this.#tryToPrefetchRequest, {
+ capture: true,
+ passive: true
+ })
+ this.eventTarget.addEventListener(this.touchTriggerEvent, this.#tryToPrefetchRequest, {
+ capture: true,
+ passive: true
+ })
+ this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true)
+ this.started = true
+ }
+
+ #tryToPrefetchRequest = (event) => {
+ if (getMetaContent("turbo-prefetch") !== "true") return
+
+ const target = event.target
+ const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])")
+
+ if (isLink && this.#isPrefetchable(target)) {
+ const link = target
+ const location = getLocationForLink(link)
+
+ if (this.delegate.canPrefetchRequestToLocation(link, location)) {
+ const fetchRequest = new FetchRequest(
+ this,
+ FetchMethod.get,
+ location,
+ new URLSearchParams(),
+ target
+ )
+
+ prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl)
+
+ link.addEventListener("mouseleave", () => prefetchCache.clear(), { once: true })
+ }
+ }
+ }
+
+ #tryToUsePrefetchedRequest = (event) => {
+ if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "get") {
+ const cached = prefetchCache.get(event.detail.url.toString())
+
+ if (cached) {
+ // User clicked link, use cache response
+ event.detail.fetchRequest = cached
+ }
+
+ prefetchCache.clear()
+ }
+ }
+
+ prepareRequest(request) {
+ const link = request.target
+
+ request.headers["Sec-Purpose"] = "prefetch"
+
+ if (link.dataset.turboFrame && link.dataset.turboFrame !== "_top") {
+ request.headers["Turbo-Frame"] = link.dataset.turboFrame
+ } else if (link.dataset.turboFrame !== "_top") {
+ const turboFrame = link.closest("turbo-frame")
+
+ if (turboFrame) {
+ request.headers["Turbo-Frame"] = turboFrame.id
+ }
+ }
+
+ if (link.hasAttribute("data-turbo-stream")) {
+ request.acceptResponseType("text/vnd.turbo-stream.html")
+ }
+ }
+
+ // Fetch request interface
+
+ requestSucceededWithResponse() {}
+
+ requestStarted(fetchRequest) {}
+
+ requestErrored(fetchRequest) {}
+
+ requestFinished(fetchRequest) {}
+
+ requestPreventedHandlingResponse(fetchRequest, fetchResponse) {}
+
+ requestFailedWithResponse(fetchRequest, fetchResponse) {}
+
+ get #cacheTtl() {
+ return Number(getMetaContent("turbo-prefetch-cache-time")) || cacheTtl
+ }
+
+ #isPrefetchable(link) {
+ const href = link.getAttribute("href")
+
+ if (!href || href === "#" || link.dataset.turbo === "false" || link.dataset.turboPrefetch === "false") {
+ return false
+ }
+
+ if (link.origin !== document.location.origin) {
+ return false
+ }
+
+ if (!["http:", "https:"].includes(link.protocol)) {
+ return false
+ }
+
+ if (link.pathname + link.search === document.location.pathname + document.location.search) {
+ return false
+ }
+
+ if (link.dataset.turboMethod && link.dataset.turboMethod !== "get") {
+ return false
+ }
+
+ if (targetsIframe(link)) {
+ return false
+ }
+
+ if (link.pathname + link.search === document.location.pathname + document.location.search) {
+ return false
+ }
+
+ const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]")
+
+ if (turboPrefetchParent && turboPrefetchParent.dataset.turboPrefetch === "false") {
+ return false
+ }
+
+ return true
+ }
+}
+
+const targetsIframe = (link) => {
+ return !doesNotTargetIFrame(link)
+}
diff --git a/src/tests/fixtures/hover_to_prefetch.html b/src/tests/fixtures/hover_to_prefetch.html
new file mode 100644
index 000000000..e0748fe0e
--- /dev/null
+++ b/src/tests/fixtures/hover_to_prefetch.html
@@ -0,0 +1,45 @@
+
+
+
+
+ Hover to Prefetch
+
+
+
+
+
+ Hover to prefetch me
+ Hover to prefetch me
+
+ Hover to prefetch me
+
+ Hover to prefetch me
+
+
+ Won't prefetch when hovering me
+ Won't prefetch when hovering me
+ Won't prefetch when hovering me
+ Hover to prefetch me
+ Won't prefetch when hovering me
+ Hover to prefetch me
+ Won't prefetch when hovering me
+ Won't prefetch when hovering me
+ Won't prefetch when hovering me
+ Won't prefetch when hovering me
+
+
+
diff --git a/src/tests/fixtures/hover_to_prefetch_custom_cache_time.html b/src/tests/fixtures/hover_to_prefetch_custom_cache_time.html
new file mode 100644
index 000000000..00ed35fd2
--- /dev/null
+++ b/src/tests/fixtures/hover_to_prefetch_custom_cache_time.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Hover to Prefetch
+
+
+
+
+
+
+ Hover to prefetch me
+
+
diff --git a/src/tests/fixtures/hover_to_prefetch_disabled.html b/src/tests/fixtures/hover_to_prefetch_disabled.html
new file mode 100644
index 000000000..689041d39
--- /dev/null
+++ b/src/tests/fixtures/hover_to_prefetch_disabled.html
@@ -0,0 +1,13 @@
+
+
+
+
+ Hover to Prefetch
+
+
+
+
+
+ Hover to prefetch me
+
+
diff --git a/src/tests/fixtures/hover_to_prefetch_iframe.html b/src/tests/fixtures/hover_to_prefetch_iframe.html
new file mode 100644
index 000000000..046fefbd8
--- /dev/null
+++ b/src/tests/fixtures/hover_to_prefetch_iframe.html
@@ -0,0 +1,13 @@
+
+
+
+
+ Hover to Prefetch
+
+
+
+
+
+ Hover to prefetch me
+
+
diff --git a/src/tests/fixtures/hover_to_prefetch_without_meta_tag_with_link_to_with_meta_tag.html b/src/tests/fixtures/hover_to_prefetch_without_meta_tag_with_link_to_with_meta_tag.html
new file mode 100644
index 000000000..75a7265aa
--- /dev/null
+++ b/src/tests/fixtures/hover_to_prefetch_without_meta_tag_with_link_to_with_meta_tag.html
@@ -0,0 +1,19 @@
+
+
+
+
+ Hover to Prefetch Not Enabled
+
+
+
+
+
+
+ Click to go to page with prefetch meta tag
+
+
+
diff --git a/src/tests/fixtures/prefetched.html b/src/tests/fixtures/prefetched.html
new file mode 100644
index 000000000..492cd9bc8
--- /dev/null
+++ b/src/tests/fixtures/prefetched.html
@@ -0,0 +1,12 @@
+
+
+
+
+ Prefetched Page
+
+
+
+
+ Prefetched Page Content
+
+
diff --git a/src/tests/functional/link_prefetch_observer_tests.js b/src/tests/functional/link_prefetch_observer_tests.js
new file mode 100644
index 000000000..1d0139986
--- /dev/null
+++ b/src/tests/functional/link_prefetch_observer_tests.js
@@ -0,0 +1,303 @@
+import { test } from "@playwright/test"
+import { assert } from "chai"
+import { nextBeat, sleep } from "../helpers/page"
+import fs from "fs"
+import path from "path"
+
+// eslint-disable-next-line no-undef
+const fixturesDir = path.join(process.cwd(), "src", "tests", "fixtures")
+
+test.afterEach(() => {
+ fs.readdirSync(fixturesDir).forEach(file => {
+ if (file.startsWith("volatile_posts_database")) {
+ fs.unlinkSync(path.join(fixturesDir, file))
+ }
+ })
+})
+
+test("it prefetches the page", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" })
+})
+
+test("it doesn't follow the link", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await hoverSelector({ page, selector: "#anchor_for_prefetch" })
+
+ assert.equal(await page.title(), "Hover to Prefetch")
+})
+
+test("prefetches the page when link has a whole valid url as a href", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertPrefetchedOnHover({ page, selector: "#anchor_with_whole_url" })
+})
+
+test("it prefetches the page when link has the same location but with a query string", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertPrefetchedOnHover({ page, selector: "#anchor_for_same_location_with_query" })
+})
+
+test("it doesn't prefetch the page when link is inside an element with data-turbo=false", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_turbo_false_parent" })
+})
+
+test("it doesn't prefetch the page when link is inside an element with data-turbo-prefetch=false", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_turbo_prefetch_false_parent" })
+})
+
+test("it does prefech the page when link is inside a container with data-turbo-prefetch=true, that is within an element with data-turbo-prefetch=false", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertPrefetchedOnHover({ page, selector: "#anchor_with_turbo_prefetch_true_parent_within_turbo_prefetch_false_parent" })
+})
+
+test("it doesn't prefetch the page when link has data-turbo-prefetch=false", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_turbo_prefetch_false" })
+})
+
+test("it doesn't prefetch the page when link has data-turbo=false", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_turbo_false" })
+})
+
+test("it doesn't prefetch the page when link has the same location", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertNotPrefetchedOnHover({ page, selector: "#anchor_for_same_location" })
+})
+
+test("it doesn't prefetch the page when link has a different origin", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertNotPrefetchedOnHover({ page, selector: "#anchor_for_different_origin" })
+})
+
+test("it doesn't prefetch the page when link has a hash as a href", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_hash" })
+})
+
+test("it doesn't prefetch the page when link has a ftp protocol", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_ftp_protocol" })
+})
+
+test("it doesn't prefetch the page when links is valid but it's inside an iframe", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_iframe_target" })
+})
+
+test("it doesn't prefetch the page when link has a POST data-turbo-method", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertNotPrefetchedOnHover({ page, selector: "#anchor_with_post_method" })
+})
+
+test("it doesn't prefetch the page when turbo-prefetch meta tag is set to false", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch_disabled.html" })
+ await assertNotPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" })
+})
+
+test("it doesn't prefetch the page when turbo-prefetch meta tag is set to true, but is later set to false", async ({
+ page
+}) => {
+ await goTo({ page, path: "/hover_to_prefetch_custom_cache_time.html" })
+ await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" })
+
+ await page.evaluate(() => {
+ const meta = document.querySelector('meta[name="turbo-prefetch"]')
+ meta.setAttribute("content", "false")
+ })
+
+ await sleep(10)
+ await page.mouse.move(0, 0)
+
+ await assertNotPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" })
+})
+
+test("it prefetches when visiting a page without the meta tag, then visiting a page with it", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch_without_meta_tag_with_link_to_with_meta_tag.html" })
+
+ await clickSelector({ page, selector: "#anchor_for_page_with_meta_tag" })
+
+ await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" })
+})
+
+test("it prefetches the page when turbo-prefetch-cache-time is set to 1", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch_custom_cache_time.html" })
+ await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" })
+})
+
+test("it caches the request for 1 millisecond when turbo-prefetch-cache-time is set to 1", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch_custom_cache_time.html" })
+ await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" })
+
+ await sleep(10)
+ await page.mouse.move(0, 0)
+
+ await assertPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" })
+})
+
+test("it adds text/vnd.turbo-stream.html header to the Accept header when link has data-turbo-stream", async ({
+ page
+}) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertPrefetchedOnHover({ page, selector: "#anchor_with_turbo_stream", callback: (request) => {
+ const headers = request.headers()["accept"].split(",").map((header) => header.trim())
+
+ assert.includeMembers(headers, ["text/vnd.turbo-stream.html", "text/html", "application/xhtml+xml"])
+ }})
+})
+
+test("it prefetches links with inner elements", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertPrefetchedOnHover({ page, selector: "#anchor_with_inner_elements" })
+})
+
+test("it prefetches links with a delay", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+
+ let requestMade = false
+ page.on("request", async (request) => (requestMade = true))
+
+ await page.hover("#anchor_for_prefetch")
+ await sleep(75)
+
+ assertRequestNotMade(requestMade)
+
+ await sleep(100)
+
+ assertRequestMade(requestMade)
+})
+
+test("it cancels the prefetch request if the link is no longer hovered", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+
+ let requestMade = false
+ page.on("request", async (request) => (requestMade = true))
+
+ await page.hover("#anchor_for_prefetch")
+ await sleep(75)
+
+ assertRequestNotMade(requestMade)
+
+ await page.mouse.move(0, 0)
+
+ await sleep(100)
+
+ assertRequestNotMade(requestMade)
+})
+
+test("it resets the cache when a link is hovered", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+
+ let requestCount = 0
+ page.on("request", async () => (requestCount++))
+
+ await page.hover("#anchor_for_prefetch")
+ await sleep(200)
+
+ assert.equal(requestCount, 1)
+ await page.mouse.move(0, 0)
+
+ await page.hover("#anchor_for_prefetch")
+ await sleep(200)
+
+ assert.equal(requestCount, 2)
+})
+
+test("it prefetches page on touchstart", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await assertPrefetchedOnTouchstart({ page, selector: "#anchor_for_prefetch" })
+})
+
+test("it does not make a network request when clicking on a link that has been prefetched", async ({ page }) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await hoverSelector({ page, selector: "#anchor_for_prefetch" })
+
+ await sleep(100)
+
+ await assertNotPrefetchedOnHover({ page, selector: "#anchor_for_prefetch" })
+})
+
+test("it follows the link using the cached response when clicking on a link that has been prefetched", async ({
+ page
+}) => {
+ await goTo({ page, path: "/hover_to_prefetch.html" })
+ await hoverSelector({ page, selector: "#anchor_for_prefetch" })
+
+ await clickSelector({ page, selector: "#anchor_for_prefetch" })
+ assert.equal(await page.title(), "Prefetched Page")
+})
+
+const assertPrefetchedOnTouchstart = async ({ page, selector, callback }) => {
+ let requestMade = false
+
+ page.on("request", (request) => {
+ callback && callback(request)
+ requestMade = true
+ })
+
+ const selectorXY = await page.$eval(selector, (el) => {
+ const { x, y } = el.getBoundingClientRect()
+ return { x, y }
+ })
+
+ await page.touchscreen.tap(selectorXY.x, selectorXY.y)
+
+ await sleep(100)
+
+ assertRequestMade(requestMade)
+}
+
+const assertPrefetchedOnHover = async ({ page, selector, callback }) => {
+ let requestMade = false
+
+ page.on("request", (request) => {
+ callback && callback(request)
+ requestMade = true
+ })
+
+ await hoverSelector({ page, selector })
+
+ await sleep(100)
+
+ assertRequestMade(requestMade)
+}
+
+const assertNotPrefetchedOnHover = async ({ page, selector, callback }) => {
+ let requestMade = false
+
+ page.on("request", (request) => {
+ callback && callback(request)
+ requestMade = true
+ })
+
+ await hoverSelector({ page, selector })
+
+ await sleep(100)
+
+ assert.equal(requestMade, false, "Network request was made when it should not have been.")
+}
+
+const assertRequestMade = (requestMade) => {
+ assert.equal(requestMade, true, "Network request wasn't made when it should have been.")
+}
+
+const assertRequestNotMade = (requestMade) => {
+ assert.equal(requestMade, false, "Network request was made when it should not have been.")
+}
+
+const goTo = async ({ page, path }) => {
+ await page.goto(`/src/tests/fixtures${path}`)
+ await nextBeat()
+}
+
+const hoverSelector = async ({ page, selector }) => {
+ await page.hover(selector)
+ await nextBeat()
+}
+
+const clickSelector = async ({ page, selector }) => {
+ await page.click(selector)
+ await nextBeat()
+}
diff --git a/src/util.js b/src/util.js
index 772e9896c..dcb15c31b 100644
--- a/src/util.js
+++ b/src/util.js
@@ -1,3 +1,5 @@
+import { expandURL } from "./core/url"
+
export function activateScriptElement(element) {
if (element.getAttribute("data-turbo-eval") == "false") {
return element
@@ -216,6 +218,24 @@ export async function around(callback, reader) {
return [before, after]
}
+export function doesNotTargetIFrame(anchor) {
+ if (anchor.hasAttribute("target")) {
+ for (const element of document.getElementsByName(anchor.target)) {
+ if (element instanceof HTMLIFrameElement) return false
+ }
+ }
+
+ return true
+}
+
+export function findLinkFromClickTarget(target) {
+ return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])")
+}
+
+export function getLocationForLink(link) {
+ return expandURL(link.getAttribute("href") || "")
+}
+
export function debounce(fn, delay) {
let timeoutId = null