Skip to content

historyPoppedWithEmptyState triggers restore visits on pages with data-turbo="false" #1496

@mockdeep

Description

@mockdeep

Summary

Turbo 8.0.21 introduced historyPoppedWithEmptyState (from PR #1285 — "Simplified same-page anchor visits"). This new handler injects Turbo state into history entries that were created by non-Turbo hash-based routing. On subsequent back/forward navigation to those entries, Turbo performs a full restore visit (server fetch + DOM replacement), even on pages with data-turbo="false".

Steps to reproduce

  1. Have a page with data-turbo="false" on the <body> and <meta name="turbo-cache-control" content="no-cache">
  2. Use hash-based routing to navigate between views on the page — e.g. #step1#step2#step3
  3. Press the browser back button (or call history.back())

Expected behavior

Turbo should not interfere with navigation on pages that have data-turbo="false". Hash-based routing should work independently of Turbo, as it did in 8.0.20.

Actual behavior (8.0.21)

When popstate fires for a hash-only history entry (no Turbo state in event.state), the new else branch in onPopState calls historyPoppedWithEmptyState, which:

  1. Calls history.replaceState() to inject Turbo state into the entry
  2. Sets this.view.lastRenderedLocation
  3. Calls this.view.cacheSnapshot()

On a subsequent popstate to that same entry (e.g. navigating back again after the hash router re-navigates forward), Turbo now sees turbo state and calls historyPoppedToLocationWithRestorationIdentifierAndDirection, which starts a restore visit — regardless of data-turbo="false" on the body. Since Session.enabled is always true (it's independent of data-turbo), the visit proceeds, fetching the page from the server and replacing the entire DOM.

Example repro
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="turbo-cache-control" content="no-cache">
  <title>Turbo 8.0.21 popstate bug repro</title>
  <script type="module">
    import * as Turbo from 'https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.21/dist/turbo.es2017-esm.js';

    // Log all popstate events
    window.addEventListener('popstate', (e) => {
      log(`popstate fired — state: ${JSON.stringify(e.state)}, hash: ${location.hash}`);
    }, true); // capture phase, runs before Turbo

    // Log Turbo visits (the bug: restore visit on data-turbo="false" page)
    document.addEventListener('turbo:before-visit', (e) => {
      log(`turbo:before-visit — url: ${e.detail.url}`);
    });
    document.addEventListener('turbo:visit', (e) => {
      log(`turbo:visit — url: ${e.detail.url}, action: ${e.detail.action}`);
    });
    document.addEventListener('turbo:load', () => {
      log('turbo:load — PAGE WAS REPLACED BY TURBO');
    });
    document.addEventListener('turbo:before-render', () => {
      log('turbo:before-render — TURBO IS REPLACING THE DOM');
    });

    function log(msg) {
      const el = document.getElementById('log');
      if (el) {
        el.textContent += `[${new Date().toISOString().slice(11,23)}] ${msg}\n`;
      }
      console.log(msg);
    }

    window.log = log;

    // Simple hash-based navigation (like routie, defined inline for simplicity)
    window.navigateHash = function(hash) {
      log(`navigateHash("${hash}") — setting window.location.hash`);
      window.location.hash = hash;
    };
  </script>
</head>
<body data-turbo="false">
  <h1>Turbo 8.0.21 — <code>data-turbo="false"</code> + hash routing bug</h1>

  <p>
    This page has <code>data-turbo="false"</code> on the body and uses
    hash-based routing (like <code>routie</code>). Turbo should not
    interfere with navigation, but 8.0.21's new <code>historyPoppedWithEmptyState</code>
    injects Turbo state into hash-only history entries.
  </p>

  <h2>Steps to reproduce</h2>
  <ol>
    <li><button onclick="navigateHash('step1')">Navigate to #step1</button></li>
    <li><button onclick="navigateHash('step2')">Navigate to #step2</button></li>
    <li><button onclick="log('--- Calling history.back() ---'); history.back()">history.back()</button>
      — Turbo injects state into the #step1 entry</li>
    <li><button onclick="log('--- Calling history.back() again ---'); history.back()">history.back() again</button>
      — Turbo sees the injected state and starts a <strong>restore visit</strong>,
      fetching the page from the server and replacing the DOM</li>
  </ol>

  <p>Current hash: <strong id="current-hash"></strong></p>
  <script>
    function updateHash() {
      document.getElementById('current-hash').textContent = location.hash || '(none)';
    }
    window.addEventListener('hashchange', updateHash);
    updateHash();
  </script>

  <h2>Event log</h2>
  <pre id="log" style="background:#f0f0f0; padding:1em; max-height:400px; overflow:auto;"></pre>
</body>
</html>

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions