`data-turbo-action="advance"` on a frame link permanently removes `[data-turbo-temporary]` elements from the live DOM
Summary
When a <turbo-frame> is navigated by a link carrying data-turbo-action="advance", Turbo promotes the frame navigation to a page visit with willRender: false. That visit still fires turbo:before-cache, which makes CacheObserver remove every [data-turbo-temporary] element from the live document. Because the visit never renders, nothing ever puts them back.
The result is that any [data-turbo-temporary] element outside the navigated frame is silently destroyed by the first in-frame navigation, and only comes back on a full page load.
In our case the destroyed element is a global modal host containing <turbo-frame id="modal">. After one navigation inside an unrelated frame, every link targeting data-turbo-frame="modal" stops opening the modal, because the target frame no longer exists — document.querySelector("turbo-frame#modal") returns null.
Versions and configuration
@hotwired/turbo8.0.20 (via Symfony AssetMapper / importmap, unbundled ESM)- No
Turbo.*configuration calls at runtime other thanTurbo.visit()in one Stimulus controller - Meta tags in
<head>:
<meta name="turbo-refresh-method" content="morph" />
<meta name="view-transition" content="same-origin" />
<meta name="turbo-prefetch" content="false" />
<meta name="turbo-cache-control" content="no-preview" />We use morph as the refresh method. Reading the code path below, we do not think morphing is required to reproduce — CacheObserver runs on turbo:before-cache regardless of the refresh method — but we have not tested with turbo-refresh-method removed, so we are flagging it rather than asserting it.
DOM structure
The modal host is a sibling of the page content, directly under <body>, and is marked data-turbo-temporary so it never gets cached in an open state:
<body data-controller="notification-listener">
<!-- page content -->
<div class="…admin shell…">
…
<div class="px-4 py-4 sm:px-6"> <!-- layout padding -->
<turbo-frame id="planning"> <!-- the frame being navigated -->
…
<!-- navigation link: triggers the bug -->
<a href="/admin/planning/month/2026-07-01" data-turbo-action="advance">Next month</a>
<!-- modal trigger: broken after the navigation above -->
<a href="/admin/planning/event/exam_access/1" data-turbo-frame="modal">Event</a>
…
</turbo-frame>
</div>
</div>
<!-- modal host: sibling of the content, OUTSIDE the navigated frame -->
<div data-controller="modal" data-turbo-temporary>
<dialog data-modal-target="dialog">
<turbo-frame id="modal" data-modal-target="dynamicContent"></turbo-frame>
</dialog>
<template data-modal-target="loadingContent">…</template>
</div>
</body><turbo-frame id="modal"> is therefore at body > div[data-turbo-temporary] > dialog > turbo-frame#modal, and <turbo-frame id="planning"> is a cousin several levels deep inside the page content. Neither frame is nested inside the other — we verified this on the rendered HTML (frame open/close tags are balanced, #modal starts after #planning closes).
Steps to reproduce
- Load a page containing both a
<turbo-frame>and a[data-turbo-temporary]element outside it, as above. - Confirm
document.querySelector("turbo-frame#modal")returns the element. - Click a link inside
#planningthat carriesdata-turbo-action="advance"(the frame navigates, the URL advances — both work as expected). - Run
document.querySelector("turbo-frame#modal")again.
Expected
The [data-turbo-temporary] element is still in the document. A frame navigation should not destroy unrelated parts of the page that the visit never re-renders.
Actual
It has been removed. document.querySelector("turbo-frame#modal") returns null, and any subsequent data-turbo-frame="modal" link falls back to navigating its closest ancestor frame (#planning) instead of opening the modal. A full page load restores it.
Root cause
Two behaviours combine.
1. FrameController#proposeVisitIfNavigatedWithAction promotes the frame navigation to a page visit that never renders:
// src/core/frames/frame_controller.js
proposeVisitIfNavigatedWithAction(frame, action = null) {
this.action = action
if (this.action) {
const pageSnapshot = PageSnapshot.fromElement(frame).clone()
const { visitCachedSnapshot } = frame.delegate
frame.delegate.fetchResponseLoaded = async (fetchResponse) => {
if (frame.src) {
const options = {
response: { … },
visitCachedSnapshot,
willRender: false, // ← the visit will not render
updateHistory: false,
restorationIdentifier: this.restorationIdentifier,
snapshot: pageSnapshot
}
if (this.action) options.action = this.action
session.visit(frame.src, options)
}
}
}
}2. CacheObserver removes temporary elements from the live document, not from a clone:
// src/observers/cache_observer.js
export class CacheObserver {
selector = "[data-turbo-temporary]"
deprecatedSelector = "[data-turbo-cache=false]"
start() {
if (!this.started) {
this.started = true
addEventListener("turbo:before-cache", this.removeTemporaryElements, false)
}
}
removeTemporaryElements = (_event) => {
for (const element of this.temporaryElements) {
element.remove() // ← live DOM
}
}
get temporaryElements() {
return [...document.querySelectorAll(this.selector), ...this.temporaryElementsWithDeprecation]
}
}Removing temporary elements from the live DOM is harmless for a normal Turbo Drive visit, because the visit immediately renders a fresh document that contains them again. With willRender: false, that restoration never happens — so the removal is permanent for the lifetime of the page.
Workarounds
Any one of these avoids it:
- Drop
data-turbo-action="advance"from the in-frame links (the frame still navigates; the URL no longer advances). - Drop
data-turbo-temporaryfrom the element that must survive. - Do not wrap the content in a frame at all, and let Turbo Drive handle navigation (what we settled on).
What we ruled out
- The modal host is not nested inside the navigated frame (verified on the rendered HTML).
- Nothing in our application code removes it: no
turbo:before-cache/turbo:before-renderlistener of ours touches it. - Our modal controller extends
@stimulus-components/dialog, whose only Turbo hook isforceClose()onturbo:before-render, which callsdialogTarget.close()and never removes the element.
Suggested fix
Skip CacheObserver's live-DOM removal when the visit is not going to render — or restrict the promoted frame visit so it does not emit turbo:before-cache at all, since no snapshot of the current document is going to be replaced.
Source: hotwired/turbo