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,