Turbo Internals - What Happens When You Click a Link?

April 20, 2022   • turbo-internals

In the last post in the Turbo Internals series, we saw how to set up the Turbo source code for debugging and development. In this post, let’s dive into the codebase and try to understand the sequence of events that take place when you click a Turbo link.

When you are using Hotwire, unless explicitly disabled, Turbo Drive intercepts all links and form submissions to the same domain. Here’re the actions Turbo performs behind the scenes after you click a link:

  1. Prevent the browser from following the link,
  2. Change the browser’s URL using the History API,
  3. Request the new page using fetch, and
  4. Render the HTML response by
    1. replacing the current <body> element, and
    2. merging the contents of the <head> element.

We will start our code exploration by following three distinct phases in the Turbo lifecycle. Initial setup, intercepting the link click, and following the visit to the location.

Setup

The control flow starts with the src/index.ts file, when your app loads in the browser. It imports everything from the ./core directory in the Turbo global variable and starts framework by calling Turbo.start() method.

// src/index.ts

import "./polyfills"
import "./elements"
import "./script_warning"

import * as Turbo from "./core"

window.Turbo = Turbo
Turbo.start()

export * from "./core"

The start method in turn starts the session by calling session.start() method. This method starts all the relevant actors, such as PageObserver, LinkClickObserver, and FormSubmitObserver.

// core/session.ts

start() {
  if (!this.started) {
    this.pageObserver.start()
    this.cacheObserver.start()
    this.linkClickObserver.start()
    this.formSubmitObserver.start()
    this.scrollObserver.start()
    this.streamObserver.start()
    this.frameRedirector.start()
    this.history.start()
    this.started = true
    this.enabled = true
  }
}

When LinkClickObserver starts, it adds an event listener for the click event on the document . However, instead of attaching a handler on event bubbling phase, it uses event capturing by setting the third optional, boolean parameter useCapture to true.

Capturing phase is the inverse of the bubbling phase. Capturing happens before bubbling, and in reverse order. Instead of starting on the target element and propagating outwards (which is what bubbling does), the outermost element is notified of the event first. Then the event propagates down the hierarchy until reaching the target.

The mechanics of the capturing phase make it ideal for preparing or preventing behavior that will later be applied by event delegation during the bubbling phase. source

function start() {
  if (!this.started) {
    addEventListener("click", this.clickCaptured, true)
    this.started = true
  }
}

// The handler removes the previous `clickBubbled` handler if it exists and adds it again. 
clickCaptured = () => {
  removeEventListener("click", this.clickBubbled, false
  addEventListener("click", this.clickBubbled, false)
}

The clickBubbled handler first checks if the click event is significant, i.e. it was not triggered on an editable element, or performed by one of the special keys such as Alt, Ctrl or Shift etc. If it’s not a significant event, it simply returns.

/* link_click_observer.ts */

clickBubbled = (event: MouseEvent) => {
  if (this.clickEventIsSignificant(event)) {
    const target = (event.composedPath && event.composedPath()[0]) || event.target
    // <a href="/pages/about">About</a>
    const link = this.findLinkFromClickTarget(target)
    if (link) {
      // URL { "http://localhost:8080/pages/about" }  
      const location = this.getLocationForLink(link)
      if (this.delegate.willFollowLinkToLocation(link, location)) {
        event.preventDefault()  // prevent browser link navigation
        this.delegate.followedLinkToLocation(link, location)
      }
    }
  }
}

If it’s a significant event (i.e. mouse click), it finds the link (<a> element) and location (URL) from the click target and delegates to the Session to verify the following conditions:

  1. Turbo Drive is enabled for the element
  2. The location is visitable, i.e. it belongs to the same domain (location is prefixed by the root location) as your application and if it’s an HTML request
  3. The application allows following the link to the location by triggering the turbo:click event and checking if the application didn’t stop the event propogation

turbo:click fires when you click a Turbo-enabled link. The clicked element is the event target. You can access the requested location with event.detail.url. Cancel this event to let the click fall through to the browser as normal navigation.

/* session.ts */

// check if the link should be followed
function willFollowLinkToLocation(link: Element, location: URL) {
    return this.elementDriveEnabled(link)
        && locationIsVisitable(location, this.snapshot.rootLocation)
        && this.applicationAllowsFollowingLinkToLocation(link, location)
}

If your application didn’t prevent the event by intercepting it, then Turbo prevents the regular browser link navigation, gets the action for the link (advance, replace, or restore) and delegates the task of proposing the location visit to the Navigator.

/* session.ts */

// actually follow the link
function followedLinkToLocation(link: Element, location: URL) {
    const action = this.getActionForLink(link)  // "advance"
    this.visit(location.href, { action })
}

// propose visit to the location
function visit(location, options) {
    this.navigator.proposeVisit(expandURL(location), options)
}

Visiting the Location

The Navigator.proposeVisit() method first checks if the application allows visiting the location with given action (default is advance) by triggering the turbo:before-visit custom event and checking if it was prevented by the application.

turbo:before-visit fires before visiting a location, except when navigating by history. Access the requested location with event.detail.url. Cancel this event to prevent navigation.

// navigator.ts

function proposeVisit(location: URL, options: Partial<VisitOptions> = {}) {
    if (this.delegate.allowsVisitingLocationWithAction(location, options.action)) {
        if (locationIsVisitable(location, this.view.snapshot.rootLocation)) {
            this.delegate.visitProposedToLocation(location, options)
        } else {
            window.location.href = location.toString()
        }
    }
}

If the location can be visited, then the Navigator checks if the location is visitable by verifying if it belongs to the same domain (location is prefixed by the root location) as your application and if it’s an HTML request.

export function locationIsVisitable(location: URL, rootLocation: URL) {
  return isPrefixedBy(location, rootLocation) && isHTML(location)
}

If the location is not visitable, i.e., it refers to the different origin or different content-type than HTML, Turbo simply redirects the browser to the requested location by setting the window.location.href property. This happens for non-origin or non-HTML URLs.

window.location.href = location.toString()

If the location is visitable, Turbo uses the BrowserAdapter class to co-ordinate the action of visiting the location, which is topic for a whole new blog post in itself. So we will stop for now, and in the next post, we will try to understand how BrowserAdapter and Visit classes (which actually perform the visit) work.

Hope this was useful, and you have a better understanding of magic that happens when you click a Turbo link. I sure do.