Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
<a href="/messages" data-turbo-prefetch>Messages</a>
```

Use `data-turbo-prefetch-delay` to override the delay in milliseconds. Invalid or empty values use the 100 millisecond default:

```html
<a href="/messages" data-turbo-prefetch data-turbo-prefetch-delay="250">Messages</a>
```

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).
Expand Down
3 changes: 3 additions & 0 deletions src/core/drive/form_submission.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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<TurboSubmitStartEvent>("turbo:submit-start", {
Expand Down
16 changes: 16 additions & 0 deletions src/core/drive/page_renderer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,8 @@ export class PageRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {

removeCurrentHeadProvisionalElements() {
for (const element of this.currentHeadProvisionalElements) {
if (isManagedByReact(element)) continue

document.head.removeChild(element)
}
}
Expand DownExpand Up@@ -146,3 +148,17 @@ export class PageRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {
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
}
99 changes: 99 additions & 0 deletions src/core/drive/prefetch_cache.ts
Original file line numberDiff line numberDiff line change
@@ -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()
5 changes: 3 additions & 2 deletions src/core/drive/progress_bar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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()
Expand DownExpand Up@@ -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}`)
})
}

Expand Down
2 changes: 1 addition & 1 deletion src/core/frames/frame_controller.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
8 changes: 8 additions & 0 deletions src/core/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand Down
11 changes: 11 additions & 0 deletions src/core/session.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand DownExpand Up@@ -42,6 +43,7 @@ export class Session
FormSubmitObserverDelegate,
HistoryDelegate,
FormLinkClickObserverDelegate,
LinkPrefetchObserverDelegate,
LinkClickObserverDelegate,
NavigatorDelegate,
PageObserverDelegate,
Expand All@@ -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)
Expand All@@ -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()
Expand All@@ -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()
Expand DownExpand Up@@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions src/core/view.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<E> {
resume: (value: any) => void
render: Render<E>
fetchResponse?: FetchResponse
}

export interface ViewDelegate<E extends Element, S extends Snapshot<E>> {
Expand DownExpand Up@@ -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 {
Expand All@@ -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

Expand Down
15 changes: 10 additions & 5 deletions src/http/fetch_request.ts
Original file line numberDiff line numberDiff line change
@@ -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
}>
Expand DownExpand Up@@ -66,16 +66,17 @@ 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<Response>
private resolveRequestPromise = (_value: any) => {}

constructor(
delegate: FetchRequestDelegate,
method: FetchMethod,
location: URL,
body: FetchRequestBody = new URLSearchParams(),
target: FrameElement | HTMLFormElement | null = null
target: Element | null = null
) {
this.delegate = delegate
this.method = method
Expand DownExpand Up@@ -104,10 +105,12 @@ export class FetchRequest {
async perform(): Promise<FetchResponse | void> {
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") {
Expand DownExpand Up@@ -180,6 +183,8 @@ export class FetchRequest {
target: this.target as EventTarget,
})
if (event.defaultPrevented) await requestInterception

return event
}

private willDelegateErrorHandling(error: Error) {
Expand Down
Loading