Turbo Frame src fetch failures produce an unhandled promise rejection
A failed <turbo-frame src="…"> request can produce an unhandled promise rejection even though Turbo has already dispatched turbo:fetch-request-error and notified the frame delegate.
We observed this in Safari when navigating away while a lazy frame request was still in flight:
UnhandledPromiseRejectionWarning: TypeError: Load failed Safari's "Load failed" message is ambiguous, so I don't think Turbo should special-case that error name or message. The problem appears to be that FrameController starts FetchRequest.perform() without observing its returned promise.
Environment
- Turbo 8.0.23
- Safari 26.6
- macOS
- Lazy
<turbo-frame>loaded through its src attribute
The same lifecycle problem can be reproduced in other browsers by forcing the frame fetch to reject.
Reproduction
Create a page containing a frame whose request rejects:
<script>
const nativeFetch = window.fetch
window.fetch = (input, options) => {
const url = new URL(input, window.location.href)
if (url.pathname === "/failing-frame") {
return Promise.reject(new TypeError("Load failed"))
}
return nativeFetch(input, options)
}
window.addEventListener("unhandledrejection", (event) => {
console.log("Unhandled rejection:", event.reason)
})
</script>
<script type="module">
import "https://cdn.jsdelivr.net/npm/@hotwired/[email protected]/+esm"
</script>
<turbo-frame id="example" src="/failing-frame">
Loading…
</turbo-frame>The unhandledrejection listener receives the rejected fetch error.
A real Safari reproduction is to use a slow frame endpoint and navigate away while the frame request is pending. Safari cancels the request with TypeError: Load failed.
Expected behavior
Turbo should dispatch turbo:fetch-request-error and invoke the frame's error lifecycle without also producing an unhandled promise rejection.
Applications should still be able to report or handle genuine network, CORS, and TLS errors through turbo:fetch-request-error.
Actual behavior
FetchRequest.perform() does the following for non-AbortError failures:
- Dispatches turbo:fetch-request-error.
- Invokes
delegate.requestErrored(...)unless the event was canceled. - Rethrows the error.
FrameController.#visit() calls request.perform() without awaiting or catching the returned promise:
return new Promise((resolve) => {
this.#resolveVisitPromise = () => {
this.#resolveVisitPromise = () => {}
this.#currentFetchRequest = null
resolve()
}
request.perform()
})FrameController.requestErrored() already logs the error and resolves the frame visit. The subsequent rejection from request.perform() therefore has no observer and becomes an unhandled rejection.
Suggested narrowly scoped solution
Handle the rejected promise specifically at the fire-and-forget call in FrameController.#visit(), rather than matching Safari's error message or changing FetchRequest.perform() globally.
For example:
void request.perform().catch(() => {
this.#resolveVisitPromise()
})The exact implementation may differ, but the important properties would be:
- Scope the change to Turbo Frame visits.
- Do not classify errors based on "Load failed".
- Continue dispatching turbo:fetch-request-error.
- Continue invoking/logging through requestErrored.
- Ensure the internal frame visit promise settles.
- Avoid an additional unhandled rejection from the ignored perform() promise.
A regression test could abort a frame route, collect unhandledrejection events, and assert that none occur while turbo:fetch-request-error still fires.
Relationship to #1512
This resembles #1512, which addressed an ignored request.perform() promise in the prefetch cache.
That proposal was closed partly because suppressing Safari's generic "Load failed" errors could hide unrelated failures. This proposal avoids inspecting or globally suppressing that error. It addresses only the unobserved promise created by FrameController, after Turbo's normal error-reporting lifecycle has already run.
Source: hotwired/turbo