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:
- Create a form that submits to an endpoint returning a 302/303 redirect
- Click submit
- Observe: exit animation starts, then aborts mid-way
- Expected: exit animation completes, redirect happens, enter animation plays
Root Cause
Turbo dispatches two turbo:visit events during a redirect:
- The first
turbo:visit for the original request (Turn sets up the transition)
- 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
-
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
-
Check if an initiator preceded the visit (turbo:click / turbo:submit-start) - fragile timing assumptions
-
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)
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:
Root Cause
Turbo dispatches two
turbo:visitevents during a redirect:turbo:visitfor the original request (Turn sets up the transition)turbo:visitwhen Turbo internally follows the redirect viafollowRedirect()invisit.jsTurn's
visit()handler callsreset(), which aborts any in-progress transition: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:visitevent 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:Approaches explored
Listen to
turbo:before-fetch-response(hasfetchResponse.redirected) and correlate with subsequentturbo:visit- but there's no reliable identifier to tie them togetherCheck if an initiator preceded the visit (
turbo:click/turbo:submit-start) - fragile timing assumptionsTrack 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:Turn does not:
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:visitTurbo could include
willRender,redirectFollowUp, or similar flag in theturbo:visitevent 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
ViewTransitionerhandledocument.startViewTransition(). Turn would only manage CSS animations (data-turn-exit,data-turn-enter).This sidesteps the problem because:
reset()still runs on the redirect follow-up, but there's no view transition to abort