From e48c701552be9a21fb1f764d369b7805b4f627ed Mon Sep 17 00:00:00 2001 From: David Alejandro <15317732+davidalejandroaguilar@users.noreply.github.com> Date: Mon, 22 Jan 2024 03:58:49 -0700 Subject: [PATCH] Add InstantClick behavior (#1101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move doesNotTargetIFrame to util.js * Move findLinkFromClickTarget to util.js * Move getLocationForLink to util.js * Allow request to be intercepted and overriden on turbo:before-fetch-request * Add instantclick behavior * Allow customizing the event that triggers prefetching * Allow customizing the cache time for prefetching * Rename LinkPrefetchOnMouseoverObserver to LinkPrefetchObserver Because it is not only triggered on mouseover, but could also be on mousedown, or eventually touchstart. * Use private methods in LinkPrefetchObserver * Reorganize methods on LinkPrefetchObserver * Require a shorter sleep time in the test Since turbo-prefetch-cache-time is set to 1 millisecond in the html fixture * Standardize anchor IDs in link_prefetch_observer_tests anchor_ prefix is used for all anchors in the tests * Don't try traverse DOM to determine if the target is a link This is not necessary, since we can just check if the target is an anchor element with an href attribute. We were just using findLinkFromClickTarget because it had the selector we needed, but we can just use the selector directly. * Keep the closing tag on the same line as the rest of the tag * Remove unnecessary nesting in tests * Add missing newline at end of file * Check for prefetch meta tag before prefetching (on hover event) * Use FetchRequest to build request for LinkPrefetchObserver * LinkPrefetchObserver implements the FetchRequest interface, so it can be used to build a request. * It also adds this.response to FetchRequest to store the non-awaited `fetch` response, because we need to FetchRequest#receive() a `fetch` response, not a FetchRequest. * Add Turbo Stream header to Accept header when link has data-turbo-stream * Bring back prefetching links with inner elements * Add cancelable delay to prefetching links on hover * Fix clearing cache on every prefetch after b9e82f2 * Add tests for the delay on the meta tag * Use mouseenter and mouseleave instead of mouseover and mouseout To avoid having to traverse the DOM to find the link element * Remove unneeded comment * Use double quotes instead of single quotes for consistency * Move link variable declaration inside if statement Since target is only a link if isLink is true * Use correct key name for mouseenter event on LinkPrefetchObserver.triggerEvents On 5078e0b we started using the `mouseenter` event instead of the `mouseover` event to trigger prefetching. However, we forgot to update the key name on the `LinkPrefetchObserver.triggerEvents` object. * Allow prefetching when visiting page without meta, then visiting one with it * Allow create and delete posts with comments on the test server * Clear prefetch cache after form submission * Add test for nested data-turbo-prefetch=true within data-turbo-prefetch=false * No longer allow customizing the prefetch trigger event * No longer allow customizing the prefetch delay * Add touchstart event to prefetch observer * Fix flaky tests This commit fixes the flaky tests by ensuring that each worker has its own database file. This is done by adding a `worker_id` query parameter to the URLs of the pages that are being tested. This `worker_id` is passed to the database functions, which then use it to determine the name of the database file. It's necessary because the tests are running in parallel, and the database file is shared between all the workers. This means that if one worker creates a post, the other workers will see that post, and the tests will fail. * Use double quotes instead of single quotes * Only cache the link you're currently hovering Instead of maintaining a cache of all the links that have been hovered in the last 10 seconds. This solves issues where the user hovers a link, then performs a non-safe action and then later clicks the link. In this case, we would be showing stale content from before the action was performed. * Remove unused files after ETA template rendered removal * Remove unused variable * Clear prefetch cache when the link is no longer hovered This avoids a flurry of requests when casually scrolling down a page * Style changes --------- Co-authored-by: Alberto Fernández-Capel --- playwright.config.js | 6 +- src/core/drive/form_submission.js | 10 +- src/core/drive/prefetch_cache.js | 34 ++ src/core/session.js | 13 + src/http/fetch_request.js | 13 +- src/observers/form_link_click_observer.js | 10 + src/observers/link_click_observer.js | 27 +- src/observers/link_prefetch_observer.js | 179 +++++++++++ src/tests/fixtures/hover_to_prefetch.html | 45 +++ .../hover_to_prefetch_custom_cache_time.html | 14 + .../fixtures/hover_to_prefetch_disabled.html | 13 + .../fixtures/hover_to_prefetch_iframe.html | 13 + ...t_meta_tag_with_link_to_with_meta_tag.html | 19 ++ src/tests/fixtures/prefetched.html | 12 + .../link_prefetch_observer_tests.js | 303 ++++++++++++++++++ src/util.js | 20 ++ 16 files changed, 702 insertions(+), 29 deletions(-) create mode 100644 src/core/drive/prefetch_cache.js create mode 100644 src/observers/link_prefetch_observer.js create mode 100644 src/tests/fixtures/hover_to_prefetch.html create mode 100644 src/tests/fixtures/hover_to_prefetch_custom_cache_time.html create mode 100644 src/tests/fixtures/hover_to_prefetch_disabled.html create mode 100644 src/tests/fixtures/hover_to_prefetch_iframe.html create mode 100644 src/tests/fixtures/hover_to_prefetch_without_meta_tag_with_link_to_with_meta_tag.html create mode 100644 src/tests/fixtures/prefetched.html create mode 100644 src/tests/functional/link_prefetch_observer_tests.js 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