#1553·turbo

`data-turbo-action="advance"` on a frame link permanently removes `[data-turbo-temporary]` elements from the live DOM

Author: Hugo-Le-GoffCreated Jul 28, 2026Updated Aug 20, 2026

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/turbo 8.0.20 (via Symfony AssetMapper / importmap, unbundled ESM)
  • No Turbo.* configuration calls at runtime other than Turbo.visit() in one Stimulus controller
  • Meta tags in <head>:
xml
<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:

xml
<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

  1. Load a page containing both a <turbo-frame> and a [data-turbo-temporary] element outside it, as above.
  2. Confirm document.querySelector("turbo-frame#modal") returns the element.
  3. Click a link inside #planning that carries data-turbo-action="advance" (the frame navigates, the URL advances — both work as expected).
  4. 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:

javascript
// 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:

javascript
// 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-temporary from 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-render listener of ours touches it.
  • Our modal controller extends @stimulus-components/dialog, whose only Turbo hook is forceClose() on turbo:before-render, which calls dialogTarget.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.