Skip to content

View transitions abort prematurely when server responds with a redirect #19

Description

@pascallaliberte

Problem

When a form submission or link click results in a server redirect (e.g., 302/303), the view transition starts but doesn't complete - it gets aborted mid-animation.

Turbo's own view transitions survive a redirect, however.

Steps to reproduce:

  1. Create a form that submits to an endpoint returning a 302/303 redirect
  2. Click submit
  3. Observe: exit animation starts, then aborts mid-way
  4. Expected: exit animation completes, redirect happens, enter animation plays

Root Cause

Turbo dispatches two turbo:visit events during a redirect:

  1. The first turbo:visit for the original request (Turn sets up the transition)
  2. A second turbo:visit when Turbo internally follows the redirect via followRedirect() in visit.js

Turn's visit() handler calls reset(), which aborts any in-progress transition:

// controller.js
visit (event) {
  this.reset(event)  // <-- This aborts the previous transition

  this.animationTurn = create(AnimationTurn, ...)
  this.viewTransitionTurn = create(ViewTransitionTurn, ...)
  // ...
}

reset (event) {
  // ...
  this.animationTurn.abort()
  this.viewTransitionTurn.abort()
  // ...
}

This behavior is correct for user-initiated navigations (to cancel stale transitions), but incorrect for redirect follow-ups where the transition should complete.

Why this is hard to solve

There's no way to properly string together the initiating and follow-up turbo events, to reliably detect a redirect occur.

Turbo's turbo:visit event only includes { url, action } - it doesn't expose whether this is a redirect follow-up. The distinguishing information exists on the Visit object but isn't passed to the event:

// turbo/src/core/drive/visit.js
followRedirect() {
  if (this.redirectedToLocation && !this.followedRedirect && this.response?.redirected) {
    this.adapter.visitProposedToLocation(this.redirectedToLocation, {
      action: "replace",
      response: this.response,
      shouldCacheSnapshot: false,
      willRender: false  // <-- This info isn't in the turbo:visit event
    })
    this.followedRedirect = true
  }
}

Approaches explored

  1. Listen to turbo:before-fetch-response (has fetchResponse.redirected) and correlate with subsequent turbo:visit - but there's no reliable identifier to tie them together

  2. Check if an initiator preceded the visit (turbo:click / turbo:submit-start) - fragile timing assumptions

  3. Track active transitions and skip reset if one is in progress - risks keeping stale transitions alive for genuine user cancellations

How Turn stays safe with Turbo

Turn interacts with Turbo only through its public event API. The key interaction is turbo:before-render:

async beforeRender (event) {
  event.preventDefault()  // Pause Turbo's rendering (official API)

  await this.animationTurn.beforeEnter(detail)
  await this.viewTransitionTurn.beforeEnter(detail)

  event.detail.resume()   // Tell Turbo to continue (official API)
}

Turn does not:

  • Monkey-patch Turbo internals
  • Access Turbo's Visit object directly
  • Intercept or modify fetch requests

This means Turn cannot easily detect redirect follow-ups without Turbo exposing that information in its events.

Possible solutions

Option 1: Turbo exposes redirect info in turbo:visit

Turbo could include willRender, redirectFollowUp, or similar flag in the turbo:visit event detail. This would let Turn distinguish redirect follow-ups from user-initiated navigations.

Option 2: Turn delegates view transitions to Turbo

Turn could offer a mode where it doesn't manage view transitions itself, instead letting Turbo's native ViewTransitioner handle document.startViewTransition(). Turn would only manage CSS animations (data-turn-exit, data-turn-enter).

This sidesteps the problem because:

  • Turbo's view transition naturally spans the entire navigation including redirects
  • Turn's reset() still runs on the redirect follow-up, but there's no view transition to abort
  • CSS animations are either complete (exit) or not started (enter)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions