diff --git a/README.md b/README.md index e937cf3da..79843f3ae 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,30 @@ It's all done by sending HTML over the wire. And for those instances when that's Read more on [turbo.hotwired.dev](https://turbo.hotwired.dev). +## Hover prefetching + +Hover prefetching is opt-in per link. Add `data-turbo-prefetch` to fetch a link after it has been hovered for 100 milliseconds: + +```html +Messages +``` + +Use `data-turbo-prefetch-delay` to override the delay in milliseconds. Invalid or empty values use the 100 millisecond default: + +```html +Messages +``` + +Prefetching resolves `data-turbo-frame`, the closest frame's `target`, or the closest frame's `id` and sends the corresponding `Turbo-Frame` request header. A `_top` target remains a full-page request. Unsafe, cross-origin, same-page, Turbo Stream, UJS, confirmation, targeted, and download links are not prefetched. Prevent `turbo:before-prefetch` to apply additional application-specific exclusions. + +Turbo dispatches lifecycle events that can be counted to calculate prefetch effectiveness: + +* `turbo:prefetch-start` when the delayed request starts +* `turbo:prefetch-hit` when navigation reuses that request +* `turbo:prefetch-waste` when a started request is discarded, with a `reason` in `event.detail` + +Each lifecycle event includes the same `id`, plus `url`, resolved `frame`, and configured `delay`. Hit and waste events also include `duration` in milliseconds. A hover canceled before its delay does not emit a start or waste event. Calculate hit rate as hit events divided by start events, and waste rate as waste events divided by start events. + ## Contributing Please read [CONTRIBUTING.md](./CONTRIBUTING.md). diff --git a/src/core/drive/form_submission.ts b/src/core/drive/form_submission.ts index e9433cf77..ccf9bf913 100644 --- a/src/core/drive/form_submission.ts +++ b/src/core/drive/form_submission.ts @@ -3,6 +3,7 @@ import { FetchResponse } from "../../http/fetch_response" import { expandURL } from "../url" import { dispatch, getAttribute, getMetaContent, hasAttribute } from "../../util" import { StreamMessage } from "../streams/stream_message" +import { prefetchCache } from "./prefetch_cache" export interface FormSubmissionDelegate { formSubmissionStarted(formSubmission: FormSubmission): void @@ -163,6 +164,8 @@ export class FormSubmission { } requestStarted(_request: FetchRequest) { + if (!this.isIdempotent) prefetchCache.clear("form_submission") + this.state = FormSubmissionState.waiting this.submitter?.setAttribute("disabled", "") dispatch("turbo:submit-start", { diff --git a/src/core/drive/page_renderer.ts b/src/core/drive/page_renderer.ts index f0f35bfd4..582efc74a 100644 --- a/src/core/drive/page_renderer.ts +++ b/src/core/drive/page_renderer.ts @@ -98,6 +98,8 @@ export class PageRenderer extends Renderer { removeCurrentHeadProvisionalElements() { for (const element of this.currentHeadProvisionalElements) { + if (isManagedByReact(element)) continue + document.head.removeChild(element) } } @@ -146,3 +148,17 @@ export class PageRenderer extends Renderer { return this.newElement.querySelectorAll("script") } } + +const REACT_INTERNAL_PREFIXES = ["__reactFiber$", "__reactProps$", "__reactContainer$"] + +function isManagedByReact(node: Element) { + for (const key in node) { + if (key[0] !== "_" || key[1] !== "_") continue + + for (const prefix of REACT_INTERNAL_PREFIXES) { + if (key.startsWith(prefix)) return true + } + } + + return false +} diff --git a/src/core/drive/prefetch_cache.ts b/src/core/drive/prefetch_cache.ts new file mode 100644 index 000000000..d1db05aae --- /dev/null +++ b/src/core/drive/prefetch_cache.ts @@ -0,0 +1,99 @@ +import { FetchRequest } from "../../http/fetch_request" + +export const defaultPrefetchDelay = 100 +export const defaultPrefetchTtl = 10 * 1000 + +export type PrefetchWasteReason = + | "expired" + | "form_submission" + | "mouseleave" + | "navigation" + | "page_unload" + | "replaced" + | "request_error" + | "session_stopped" + +export interface PrefetchLifecycle { + started(): void + hit(): void + wasted(reason: PrefetchWasteReason): void +} + +interface PrefetchEntry { + expiresAt: number + frame: string | null + lifecycle: PrefetchLifecycle + request: FetchRequest + url: string +} + +export class PrefetchCache { + private entry?: PrefetchEntry + private expirationTimeout?: number + private pendingTimeout?: number + + putLater( + url: URL, + frame: string | null, + request: FetchRequest, + delay: number, + ttl: number, + lifecycle: PrefetchLifecycle + ) { + this.clear("replaced") + + this.pendingTimeout = window.setTimeout(() => { + delete this.pendingTimeout + + const requestPromise = request.perform() + this.entry = { expiresAt: Date.now() + ttl, frame, lifecycle, request, url: url.href } + this.expirationTimeout = window.setTimeout(() => this.clearRequest(request, "expired"), ttl) + lifecycle.started() + + requestPromise.catch(() => this.clearRequest(request, "request_error")) + }, delay) + } + + take(url: URL, frame: string | null): FetchRequest | undefined { + if (this.entry && this.entry.expiresAt <= Date.now()) { + this.clear("expired") + } + + if (this.entry?.url === url.href && this.entry.frame === frame) { + const { lifecycle, request } = this.entry + this.discardEntry() + lifecycle.hit() + return request + } + } + + clear(reason: PrefetchWasteReason) { + if (this.pendingTimeout !== undefined) { + window.clearTimeout(this.pendingTimeout) + delete this.pendingTimeout + } + + if (this.entry) { + const { lifecycle } = this.entry + this.discardEntry() + lifecycle.wasted(reason) + } + } + + private clearRequest(request: FetchRequest, reason: PrefetchWasteReason) { + if (this.entry?.request === request) { + this.clear(reason) + } + } + + private discardEntry() { + if (this.expirationTimeout !== undefined) { + window.clearTimeout(this.expirationTimeout) + delete this.expirationTimeout + } + + delete this.entry + } +} + +export const prefetchCache = new PrefetchCache() diff --git a/src/core/drive/progress_bar.ts b/src/core/drive/progress_bar.ts index 9a189be92..a7de831bb 100644 --- a/src/core/drive/progress_bar.ts +++ b/src/core/drive/progress_bar.ts @@ -10,6 +10,7 @@ export class ProgressBar { display: block; top: 0; left: 0; + width: calc(var(--turbo-progress-bar-scale, 0) * 100%); height: 3px; background: #0076ff; z-index: 2147483647; @@ -68,7 +69,7 @@ export class ProgressBar { } installProgressElement() { - this.progressElement.style.width = "0" + this.progressElement.style.setProperty("--turbo-progress-bar-scale", "0") this.progressElement.style.opacity = "1" document.documentElement.insertBefore(this.progressElement, document.body) this.refresh() @@ -102,7 +103,7 @@ export class ProgressBar { refresh() { requestAnimationFrame(() => { - this.progressElement.style.width = `${10 + this.value * 90}%` + this.progressElement.style.setProperty("--turbo-progress-bar-scale", `${(10 + this.value * 90) / 100}`) }) } diff --git a/src/core/frames/frame_controller.ts b/src/core/frames/frame_controller.ts index c39c12dfe..33ff8a093 100644 --- a/src/core/frames/frame_controller.ts +++ b/src/core/frames/frame_controller.ts @@ -184,7 +184,7 @@ export class FrameController if (this.view.renderPromise) await this.view.renderPromise this.changeHistory() - await this.view.render(renderer) + await this.view.render(renderer, fetchResponse) this.complete = true session.frameRendered(fetchResponse, this.element) session.frameLoaded(this.element) diff --git a/src/core/index.ts b/src/core/index.ts index e92070ea3..65b4e4dd3 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -30,6 +30,14 @@ export { export { TurboSubmitStartEvent, TurboSubmitEndEvent } from "./drive/form_submission" export { TurboFrameMissingEvent } from "./frames/frame_controller" +export { + PrefetchEventDetail, + TurboBeforePrefetchEvent, + TurboPrefetchHitEvent, + TurboPrefetchStartEvent, + TurboPrefetchWasteEvent, +} from "../observers/link_prefetch_observer" +export { PrefetchWasteReason } from "./drive/prefetch_cache" export { StreamActions, TurboStreamAction, TurboStreamActions } from "./streams/stream_actions" export { setCSPTrustedTypesPolicy } from "../trusted_types" diff --git a/src/core/session.ts b/src/core/session.ts index de6382c44..6b2d522fe 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -4,6 +4,7 @@ import { CacheObserver } from "../observers/cache_observer" import { FormSubmitObserver, FormSubmitObserverDelegate } from "../observers/form_submit_observer" import { FrameRedirector } from "./frames/frame_redirector" import { History, HistoryDelegate } from "./drive/history" +import { LinkPrefetchObserver, LinkPrefetchObserverDelegate } from "../observers/link_prefetch_observer" import { LinkClickObserver, LinkClickObserverDelegate } from "../observers/link_click_observer" import { FormLinkClickObserver, FormLinkClickObserverDelegate } from "../observers/form_link_click_observer" import { getAction, getExtension, expandURL, isPrefixedBy, Locatable } from "./url" @@ -42,6 +43,7 @@ export class Session FormSubmitObserverDelegate, HistoryDelegate, FormLinkClickObserverDelegate, + LinkPrefetchObserverDelegate, LinkClickObserverDelegate, NavigatorDelegate, PageObserverDelegate, @@ -56,6 +58,7 @@ export class Session readonly pageObserver = new PageObserver(this) readonly cacheObserver = new CacheObserver() + readonly linkPrefetchObserver = new LinkPrefetchObserver(this, document) readonly linkClickObserver = new LinkClickObserver(this, window) readonly formSubmitObserver = new FormSubmitObserver(this, document) readonly scrollObserver = new ScrollObserver(this) @@ -74,6 +77,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() @@ -95,6 +99,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() @@ -190,6 +195,12 @@ export class Session submittedFormLinkToLocation() {} + // Link hover observer delegate + + canPrefetchRequestToLocation(link: Element, location: URL) { + return this.elementIsNavigatable(link) && this.locationIsVisitable(location, this.snapshot.rootLocation) + } + // Link click observer delegate willFollowLinkToLocation(link: Element, location: URL, event: MouseEvent) { diff --git a/src/core/view.ts b/src/core/view.ts index 0c3416074..9d15d21ca 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -3,10 +3,12 @@ import { Renderer, Render } from "./renderer" import { Snapshot } from "./snapshot" import { Position } from "./types" import { getAnchor } from "./url" +import { FetchResponse } from "../http/fetch_response" export interface ViewRenderOptions { resume: (value: any) => void render: Render + fetchResponse?: FetchResponse } export interface ViewDelegate> { @@ -81,7 +83,7 @@ export abstract class View< // Rendering - async render(renderer: R) { + async render(renderer: R, fetchResponse?: FetchResponse) { const { isPreview, shouldRender, newSnapshot: snapshot } = renderer if (shouldRender) { try { @@ -90,7 +92,7 @@ export abstract class View< await this.prepareToRenderSnapshot(renderer) const renderInterception = new Promise((resolve) => (this.resolveInterceptionPromise = resolve)) - const options = { resume: this.resolveInterceptionPromise, render: this.renderer.renderElement } + const options = { resume: this.resolveInterceptionPromise, render: this.renderer.renderElement, fetchResponse } const immediateRender = this.delegate.allowsImmediateRender(snapshot, options) if (!immediateRender) await renderInterception diff --git a/src/http/fetch_request.ts b/src/http/fetch_request.ts index 983076469..b09673a89 100644 --- a/src/http/fetch_request.ts +++ b/src/http/fetch_request.ts @@ -1,9 +1,9 @@ import { FetchResponse } from "./fetch_response" -import { FrameElement } from "../elements/frame_element" import { dispatch } from "../util" export type TurboBeforeFetchRequestEvent = CustomEvent<{ fetchOptions: RequestInit + fetchRequest?: FetchRequest url: URL resume: (value: any) => void }> @@ -66,8 +66,9 @@ export class FetchRequest { readonly headers: FetchRequestHeaders readonly url: URL readonly body?: FetchRequestBody - readonly target?: FrameElement | HTMLFormElement | null + readonly target?: Element | null readonly abortController = new AbortController() + response?: Promise private resolveRequestPromise = (_value: any) => {} constructor( @@ -75,7 +76,7 @@ export class FetchRequest { method: FetchMethod, location: URL, body: FetchRequestBody = new URLSearchParams(), - target: FrameElement | HTMLFormElement | null = null + target: Element | null = null ) { this.delegate = delegate this.method = method @@ -104,10 +105,12 @@ export class FetchRequest { async perform(): Promise { const { fetchOptions } = this this.delegate.prepareHeadersForRequest?.(this.headers, this) - await this.allowRequestToBeIntercepted(fetchOptions) + const event = await this.allowRequestToBeIntercepted(fetchOptions) try { this.delegate.requestStarted(this) - const response = await fetch(this.url.href, fetchOptions) + + this.response = event.detail.fetchRequest?.response || fetch(this.url.href, fetchOptions) + const response = await this.response return await this.receive(response) } catch (error) { if ((error as Error).name !== "AbortError") { @@ -180,6 +183,8 @@ export class FetchRequest { target: this.target as EventTarget, }) if (event.defaultPrevented) await requestInterception + + return event } private willDelegateErrorHandling(error: Error) { diff --git a/src/observers/link_prefetch_observer.ts b/src/observers/link_prefetch_observer.ts new file mode 100644 index 000000000..706844141 --- /dev/null +++ b/src/observers/link_prefetch_observer.ts @@ -0,0 +1,241 @@ +import { + defaultPrefetchDelay, + defaultPrefetchTtl, + prefetchCache, + PrefetchWasteReason, +} from "../core/drive/prefetch_cache" +import { expandURL } from "../core/url" +import { + FetchMethod, + FetchRequest, + FetchRequestDelegate, + FetchRequestHeaders, + TurboBeforeFetchRequestEvent, +} from "../http/fetch_request" +import { FetchResponse } from "../http/fetch_response" +import { dispatch, getMetaContent, uuid } from "../util" + +export interface LinkPrefetchObserverDelegate { + canPrefetchRequestToLocation(link: Element, location: URL): boolean +} + +export interface PrefetchEventDetail { + delay: number + frame: string | null + id: string + url: string +} + +export type TurboBeforePrefetchEvent = CustomEvent +export type TurboPrefetchStartEvent = CustomEvent +export type TurboPrefetchHitEvent = CustomEvent +export type TurboPrefetchWasteEvent = CustomEvent< + PrefetchEventDetail & { duration: number; reason: PrefetchWasteReason } +> + +export class LinkPrefetchObserver implements FetchRequestDelegate { + readonly delegate: LinkPrefetchObserverDelegate + readonly eventTarget: Document + started = false + private prefetchedLink?: Element + + constructor(delegate: LinkPrefetchObserverDelegate, eventTarget: Document) { + 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() { + this.eventTarget.removeEventListener("DOMContentLoaded", this.enable) + this.eventTarget.defaultView?.removeEventListener("pagehide", this.pageWillUnload) + + if (this.started) { + this.eventTarget.removeEventListener("mouseenter", this.tryToPrefetchRequest, true) + this.eventTarget.removeEventListener("mouseleave", this.cancelRequestIfObsolete, true) + this.eventTarget.removeEventListener("turbo:before-fetch-request", this.tryToUsePrefetchedRequest, true) + this.started = false + } + + this.prefetchedLink = undefined + prefetchCache.clear("session_stopped") + } + + prepareHeadersForRequest(headers: FetchRequestHeaders, request: FetchRequest) { + headers["VND.PREFETCH"] = "true" + + const frame = request.target ? frameTargetForLink(request.target) : null + if (frame) headers["Turbo-Frame"] = frame + } + + requestStarted(_request: FetchRequest) {} + + requestPreventedHandlingResponse(_request: FetchRequest, _response: FetchResponse) {} + + requestSucceededWithResponse(_request: FetchRequest, _response: FetchResponse) {} + + requestFailedWithResponse(_request: FetchRequest, _response: FetchResponse) {} + + requestErrored(_request: FetchRequest, _error: Error) {} + + requestFinished(_request: FetchRequest) {} + + private enable = () => { + if (this.started) return + + this.eventTarget.addEventListener("mouseenter", this.tryToPrefetchRequest, { + capture: true, + passive: true, + }) + this.eventTarget.addEventListener("mouseleave", this.cancelRequestIfObsolete, { + capture: true, + passive: true, + }) + this.eventTarget.addEventListener("turbo:before-fetch-request", this.tryToUsePrefetchedRequest, true) + this.eventTarget.defaultView?.addEventListener("pagehide", this.pageWillUnload) + this.started = true + } + + private pageWillUnload = () => { + this.prefetchedLink = undefined + prefetchCache.clear("page_unload") + } + + private tryToPrefetchRequest = (event: MouseEvent) => { + if (getMetaContent("turbo-prefetch") === "false") return + + const link = event.target + if (!(link instanceof Element) || !link.matches("a[href]:not([target^=_]):not([download])")) return + if (!linkIsPrefetchable(link)) return + + const location = locationForLink(link) + if (!this.delegate.canPrefetchRequestToLocation(link, location)) return + + this.prefetchedLink = link + + const delay = prefetchDelayForLink(link) + const frame = frameTargetForLink(link) + const id = uuid() + const detail = { delay, frame, id, url: location.href } + let startedAt = 0 + const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams(), link) + + prefetchCache.putLater(location, frame, fetchRequest, delay, prefetchTtl(), { + started: () => { + startedAt = Date.now() + dispatch("turbo:prefetch-start", { target: link, detail }) + }, + hit: () => { + dispatch("turbo:prefetch-hit", { + target: link, + detail: { ...detail, duration: Date.now() - startedAt }, + }) + }, + wasted: (reason) => { + dispatch("turbo:prefetch-waste", { + target: link, + detail: { ...detail, duration: Date.now() - startedAt, reason }, + }) + }, + }) + } + + private cancelRequestIfObsolete = (event: MouseEvent) => { + if (event.target === this.prefetchedLink) { + this.prefetchedLink = undefined + prefetchCache.clear("mouseleave") + } + } + + private tryToUsePrefetchedRequest = (event: Event) => { + const fetchEvent = event as TurboBeforeFetchRequestEvent + const { fetchOptions, url } = fetchEvent.detail + + if (fetchEvent.target instanceof HTMLFormElement || fetchOptions.method !== "GET") return + if (headerValue(fetchOptions.headers, "VND.PREFETCH") === "true") return + + const frame = headerValue(fetchOptions.headers, "Turbo-Frame") + const request = prefetchCache.take(url, frame) + if (request) { + fetchEvent.detail.fetchRequest = request + } else { + prefetchCache.clear("navigation") + } + + this.prefetchedLink = undefined + } +} + +function linkIsPrefetchable(link: Element) { + const href = link.getAttribute("href") + if (!href || !link.hasAttribute("data-turbo-prefetch")) return false + if (link.getAttribute("data-turbo-prefetch") === "false") return false + + const location = locationForLink(link) + if (location.origin !== document.location.origin) return false + if (!["http:", "https:"].includes(location.protocol)) return false + if (link.hasAttribute("target")) return false + if (location.pathname + location.search === document.location.pathname + document.location.search) return false + if (href.trim().startsWith("#")) return false + if (linkIsUnsafe(link)) return false + + const event = dispatch("turbo:before-prefetch", { target: link, cancelable: true }) + return !event.defaultPrevented +} + +function linkIsUnsafe(link: Element) { + const turboMethod = link.getAttribute("data-turbo-method") + if (turboMethod && turboMethod.toLowerCase() !== "get") return true + + return ( + link.hasAttribute("data-remote") || + link.hasAttribute("data-behavior") || + link.hasAttribute("data-confirm") || + link.hasAttribute("data-method") || + link.hasAttribute("data-turbo-confirm") || + link.hasAttribute("data-turbo-stream") + ) +} + +function locationForLink(link: Element) { + return expandURL(link.getAttribute("href") || "") +} + +function frameTargetForLink(link: Element): string | null { + const frame = link.closest("turbo-frame") + const target = link.getAttribute("data-turbo-frame") || frame?.getAttribute("target") || frame?.id + + return target && target !== "_top" ? target : null +} + +function prefetchDelayForLink(link: Element) { + return nonNegativeNumber(link.getAttribute("data-turbo-prefetch-delay")) ?? defaultPrefetchDelay +} + +function prefetchTtl() { + return positiveNumber(getMetaContent("turbo-prefetch-cache-time")) ?? defaultPrefetchTtl +} + +function nonNegativeNumber(value: string | null) { + if (value === null || value.trim() === "") return + + const number = Number(value) + return Number.isFinite(number) && number >= 0 ? number : undefined +} + +function positiveNumber(value: string | null) { + const number = nonNegativeNumber(value) + return number !== undefined && number > 0 ? number : undefined +} + +function headerValue(headers: HeadersInit | undefined, name: string) { + return headers ? new Headers(headers).get(name) : null +} diff --git a/src/observers/scroll_observer.ts b/src/observers/scroll_observer.ts index c754baf47..f95706fd6 100644 --- a/src/observers/scroll_observer.ts +++ b/src/observers/scroll_observer.ts @@ -7,6 +7,7 @@ export interface ScrollObserverDelegate { export class ScrollObserver { readonly delegate: ScrollObserverDelegate started = false + scrollScheduled = false constructor(delegate: ScrollObserverDelegate) { this.delegate = delegate @@ -28,7 +29,13 @@ export class ScrollObserver { } onScroll = () => { - this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset }) + if (this.scrollScheduled) return + + this.scrollScheduled = true + requestAnimationFrame(() => { + this.scrollScheduled = false + this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset }) + }) } // Private diff --git a/src/tests/fixtures/hover_to_prefetch.html b/src/tests/fixtures/hover_to_prefetch.html new file mode 100644 index 000000000..19f9d53b6 --- /dev/null +++ b/src/tests/fixtures/hover_to_prefetch.html @@ -0,0 +1,33 @@ + + + + + Hover to prefetch + + + + +

Hover to prefetch

+ + Unmarked link + + Disabled link + Unsafe link + + + Waste prefetch + Hit prefetch + Custom delay + Canceled delay + Expiring prefetch + + + Explicit frame + + + Top navigation + + + \ No newline at end of file diff --git a/src/tests/fixtures/test.js b/src/tests/fixtures/test.js index 05975a3e8..10faf30a6 100644 --- a/src/tests/fixtures/test.js +++ b/src/tests/fixtures/test.js @@ -66,4 +66,8 @@ "turbo:frame-render", "turbo:frame-missing", "turbo:reload", + "turbo:before-prefetch", + "turbo:prefetch-start", + "turbo:prefetch-hit", + "turbo:prefetch-waste", ]) diff --git a/src/tests/functional/link_prefetch_observer_tests.ts b/src/tests/functional/link_prefetch_observer_tests.ts new file mode 100644 index 000000000..fc9c2cf6a --- /dev/null +++ b/src/tests/functional/link_prefetch_observer_tests.ts @@ -0,0 +1,169 @@ +import "../helpers/trusted_type_setup" +import { Page, Request, test } from "@playwright/test" +import { assert } from "chai" +import { nextEventOnTarget, readEventLogs, sleep } from "../helpers/page" + +test.beforeEach(async ({ page }) => { + await page.goto("/src/tests/fixtures/hover_to_prefetch.html") + await readEventLogs(page) +}) + +test("prefetches only links that opt in directly", async ({ page }) => { + const requests = collectPrefetchRequests(page) + + await page.hover("#unmarked-link") + await sleep(150) + await page.hover("#parent-only-link") + await sleep(150) + await page.hover("#disabled-link") + await sleep(150) + await page.hover("#unsafe-link") + await sleep(150) + + assert.lengthOf(requests, 0) + + const requestPromise = waitForRequest(page, "waste") + await page.hover("#waste-link") + await requestPromise + + assert.lengthOf(requests, 1) +}) + +test("uses the default delay and accepts a per-link delay", async ({ page }) => { + const requests = collectPrefetchRequests(page) + + await page.hover("#custom-delay-link") + await sleep(150) + assert.lengthOf(requests, 0) + + await waitForRequest(page, "custom-delay") + assert.lengthOf(requests, 1) + + const detail = await nextEventOnTarget(page, "custom-delay-link", "turbo:prefetch-start") + assert.equal(detail.delay, 250) + + await page.mouse.move(0, 0) + const defaultRequest = waitForRequest(page, "waste") + await page.hover("#waste-link") + await defaultRequest + + const defaultDetail = await nextEventOnTarget(page, "waste-link", "turbo:prefetch-start") + assert.equal(defaultDetail.delay, 100) +}) + +test("cancels a delayed prefetch when hover ends", async ({ page }) => { + const requests = collectPrefetchRequests(page) + + await page.hover("#cancel-delay-link") + await sleep(50) + await page.mouse.move(0, 0) + await sleep(300) + + assert.lengthOf(requests, 0) + assert.isTrue(await noLifecycleEvents(page)) +}) + +test("sends the resolved Turbo-Frame header and omits it for _top", async ({ page }) => { + const explicitRequest = waitForRequest(page, "explicit-frame") + await page.hover("#explicit-frame-link") + assert.equal((await explicitRequest).headers()["turbo-frame"], "part") + + await page.mouse.move(0, 0) + + const topRequest = waitForRequest(page, "top") + await page.hover("#top-link") + assert.notProperty((await topRequest).headers(), "turbo-frame") +}) + +test("reuses a frame prefetch and reports hit and waste rates", async ({ page }) => { + const requests = collectPrefetchRequests(page) + await installMetricCounters(page) + + const wastedRequest = waitForRequest(page, "waste") + await page.hover("#waste-link") + await wastedRequest + const wasteStart = await nextEventOnTarget(page, "waste-link", "turbo:prefetch-start") + + await page.mouse.move(0, 0) + const waste = await nextEventOnTarget(page, "waste-link", "turbo:prefetch-waste") + assert.equal(waste.id, wasteStart.id) + assert.equal(waste.reason, "mouseleave") + assert.isAtLeast(waste.duration, 0) + + const hitRequest = waitForRequest(page, "hit") + await page.hover("#hit-link") + await hitRequest + const hitStart = await nextEventOnTarget(page, "hit-link", "turbo:prefetch-start") + + await page.click("#hit-link") + const hit = await nextEventOnTarget(page, "hit-link", "turbo:prefetch-hit") + await nextEventOnTarget(page, "part", "turbo:frame-load") + + assert.equal(hit.id, hitStart.id) + assert.equal(hit.frame, "part") + assert.isAtLeast(hit.duration, 0) + assert.lengthOf(requests, 2, "click reuses the prefetched response") + + const metrics = await metricCounters(page) + assert.equal(metrics.starts, 2) + assert.equal(metrics.hits, 1) + assert.equal(metrics.wastes, 1) + assert.equal(metrics.hitRate, 0.5) + assert.equal(metrics.wasteRate, 0.5) +}) + +test("reports an unused prefetch as waste when it expires", async ({ page }) => { + await page.evaluate(() => { + const meta = document.createElement("meta") + meta.name = "turbo-prefetch-cache-time" + meta.content = "50" + document.head.appendChild(meta) + }) + + const request = waitForRequest(page, "expiry") + await page.hover("#expiry-link") + await request + + const waste = await nextEventOnTarget(page, "expiry-link", "turbo:prefetch-waste") + assert.equal(waste.reason, "expired") +}) + +function collectPrefetchRequests(page: Page) { + const requests: Request[] = [] + page.on("request", (request) => { + if (request.headers()["vnd.prefetch"] === "true") requests.push(request) + }) + return requests +} + +function waitForRequest(page: Page, search: string) { + return page.waitForRequest((request) => { + const url = new URL(request.url()) + return url.search === `?${search}` && request.headers()["vnd.prefetch"] === "true" + }) +} + +async function noLifecycleEvents(page: Page) { + const records = await readEventLogs(page) + return records.every(([name]) => !name.startsWith("turbo:prefetch-")) +} + +function installMetricCounters(page: Page) { + return page.evaluate(() => { + const metrics = ((window as any).prefetchMetrics = { starts: 0, hits: 0, wastes: 0 }) + addEventListener("turbo:prefetch-start", () => metrics.starts++) + addEventListener("turbo:prefetch-hit", () => metrics.hits++) + addEventListener("turbo:prefetch-waste", () => metrics.wastes++) + }) +} + +function metricCounters(page: Page) { + return page.evaluate(() => { + const metrics = (window as any).prefetchMetrics + return { + ...metrics, + hitRate: metrics.hits / metrics.starts, + wasteRate: metrics.wastes / metrics.starts, + } + }) +} diff --git a/src/tests/unit/export_tests.ts b/src/tests/unit/export_tests.ts index dd9beb37f..197e3c75b 100644 --- a/src/tests/unit/export_tests.ts +++ b/src/tests/unit/export_tests.ts @@ -4,6 +4,8 @@ import * as Turbo from "../../index" export { PageRenderer, PageSnapshot, + PrefetchEventDetail, + PrefetchWasteReason, FrameRenderer, FrameElement, StreamActions, @@ -13,6 +15,7 @@ export { TurboBeforeFetchRequestEvent, TurboBeforeFetchResponseEvent, TurboBeforeFrameRenderEvent, + TurboBeforePrefetchEvent, TurboBeforeRenderEvent, TurboBeforeStreamRenderEvent, TurboBeforeVisitEvent, @@ -22,6 +25,9 @@ export { TurboFrameMissingEvent, TurboFrameRenderEvent, TurboLoadEvent, + TurboPrefetchHitEvent, + TurboPrefetchStartEvent, + TurboPrefetchWasteEvent, TurboRenderEvent, TurboStreamAction, TurboStreamActions,