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 +
+ 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