Skip to content

htmx 4.0.1: the first swap overwrites history.state instead of merging into it #4193

Open

Description

@wdceng

Is this change intended? If so, please close this issue.

Summary

Since 4.0.1, the first swap after a full page load replaces history.state with {htmx: true}. Any key the page stored there before is lost.

4.0.0 marked the entry in initialize(), and only when the state was empty:
src/htmx.js#L1234 (v4.0.0)

if (!history.state) history.replaceState({htmx: true}, '', location.href);

4.0.1 (#4067) marks it in #handleHistoryUpdate, whenever the htmx key is missing:
src/htmx.js#L1703 (v4.0.1)

if (!history.state?.htmx) history.replaceState({htmx: true}, '', location.href);

Where we found it

Our browser tests: Back no longer restored a list's scroll position in Firefox 159 and Safari 27.2 (macOS 27.2), where htmx uses popstate. The page saves its scroll in history.state before the first swap. The tests pass on 4.0.0 and fail on 4.0.1.

Repro

  1. Load a page with htmx 4.0.1 and history on.
  2. Run history.replaceState({...history.state, myKey: 1}, '').
  3. Click a boosted link.
  4. history.state of that page's entry is {htmx: true}. On 4.0.0 it is {htmx: true, myKey: 1}.

Suggested fix

if (!history.state?.htmx) {
    let state = history.state;
    history.replaceState(
        {...(state && typeof state === 'object' ? state : {}), htmx: true},
        '',
        location.href
    );
}

This keeps #4067's timing, so POST result pages stay unmarked (#4059), and hx-history-cache already merges the same way ({ ...state, htmxId: id }). #4192 (for #4183) moves this line into #stampCurrentHistoryEntry, where both replaceState calls need the same change. It also matches the advice in #4071: code that replaces the state should keep what is already in it.


Drafted by AI under my instructions, reviewed and edited by me.

Activity

  1. MichaelWest22 commented on Nov 4, 2026

    @MichaelWest22
    Collaborator

    I think the issue you have here is a limitation we already found in firefox and safari internal state restoration and from my testing your suggested fix made no difference to this for me in my manual history testing suite. The issue here is that during back navigation with htmx we use the new browser navigation api to restore the page state back as it was and then apply the old page data from a new server request. in htmx 2 we implemented a full page state and did all this manually ourselves but this causes some problems for third party js etc so we have moved to a simpler more native way now where we don't try and cache the page and scroll positions. the new navigation api lets us restore the page easily and the browser restores the scroll for us and our page update is in a special managed timed window. This works well in chrome that implements the navigation api well but firefox has only just recently implemented this feature and it has long standing bugs and issues with scroll restoration and so we have disabled navigation api for firefox in htmx4 for now and fall back to the older popstate history api's that still restore some scroll satiations but not all. safari also has some issues restoring scroll at times as well. We didn't want to go back to the old htmx v2 way of doing things by default which had its own issues. Instead we have moved this all into an optional hx-history-cache extension which you can opt into when you want better history restoration support and you don't have third party js issues that may cause issues. It has fully support for restoring vertical page scroll and also restores previous page inner scroll positions and input values and apline js state.

    So i would test out this extension for your use case to see if it helps.

    But if you can test again with your suggested fix and confirm it does restore scroll better with your state fix for you then can you please try and make a minimal repro in a couple of html files so I can see if I can validate if it is something we can improve.

  2. wdceng commented on Nov 4, 2026

    @wdceng
    Author

    Thanks, I'll send a minimal repro in a couple of files. I tested all three on 4.0.1 in Chrome, Firefox, Safari and iOS. The suggested fix is the only one that works without a workaround and keeps Back fresh. Details below.

    Fresh page on Back Scroll restored Scroll on a cache miss
    1. Suggested fix (merge in core) ✅ ✅ ✅
    2. hx-history-cache ❌ stale ✅ ❌
    3. Current workaround (page merges htmx: true) ✅ ✅ ✅

    1. Suggested fix: merge the state in #handleHistoryUpdate instead of replacing it.

    if (!history.state?.htmx) {
        let state = history.state;
        history.replaceState(
            {...(state && typeof state === 'object' ? state : {}), htmx: true},
            '',
            location.href
        );
    }

    Repro:

    <!doctype html>
    <script src="https://unpkg.com/htmx.org@4.0.1/dist/htmx.min.js"></script>
    <script>
      history.replaceState({mine: 1}, '');
      document.addEventListener('htmx:after:settle', () => {
        document.body.append(JSON.stringify(history.state));
      });
    </script>
    <body hx-boost:inherited="true">
      <a href="?next">Click, then Back</a>
    </body>

    After Back: 4.0.1 shows {"htmx":true}, with the fix {"mine":1,"htmx":true}.


    2. hx-history-cache: restores the scroll, but Back shows stale pages, and a cache miss loses the scroll. Tested on 4.0.1 (list scrolled to ~2250, a product added to the basket, then Back):

    Chrome Firefox Safari iOS
    Scroll on a cache hit 2268 ✅ 2239 ✅ 2234 ✅ 2672 ✅
    Server asked on Back no no no no
    Basket count (should be 1) 1 0 0 0
    Scroll on a cache miss 526 0 0 255

    After an order, Back shows the checkout page with the old basket, in all four.


    3. Current workaround: before each request, and whenever the scroll rests, the page writes its scroll into the state and merges htmx: true itself, so the stamp finds it marked and leaves it alone.

    history.replaceState({...history.state, scroll: scrollY, htmx: true}, '')

    Drafted by AI under my instructions, reviewed and edited by me.

  3. wdceng commented on Nov 4, 2026

    @wdceng
    Author

    My guess why your suite showed no difference: Chrome brings the scroll back on its own. We only lose it in Firefox 159 and Safari 27.2, where our page has to restore it from the state. I added a unit test in #4198.

  4. reopened this on Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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