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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions