htmx icon indicating copy to clipboard operation
htmx copied to clipboard

History back - page restoration fails for nested ionic lists

Open Gleek opened this issue 2 months ago • 1 comments

Hello, I'm currently experimenting with ionic and htmx and facing an issue with history restore for ionic lists that are nested in some other ionic webcomponent. I'm not sure if it's an issue with WebComponents in general or something special that ionic is doing. I'm attaching steps to reproduce and a possible fix.

Replication

  1. Create two files:
  • page1.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>HTMX Ionic History Issue</title>
    <!-- Ionic CSS and JS -->
    <link href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css" rel="stylesheet" />
    <script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"></script>
    <script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.js"></script>

    <!-- HTMX -->
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx.min.js"></script>

    <style>
      body {padding: 20px;}
      #main-content {
        border: 1px dashed #aaa;
        padding: 20px;
        min-height: 200px;
        margin-top: 20px;
      }
      ion-card {margin-top: 20px;}
      nav {margin-top: 30px;}
    </style>
  </head>
  <body>
    <h1>Home Page (with Ionic List)</h1>

    <div id="main-content" hx-preserve-attrs="class">
      <h2>Ionic Section (Expected: Content missing after history back)</h2>
      <ion-card>
        <ion-card-content>
          <ion-list>
            <ion-item button href="#">
              <ion-label>Item 1</ion-label>
            </ion-item>
            <ion-item button href="#">
              <ion-label>Item 2</ion-label>
            </ion-item>
            <ion-item button href="#">
              <ion-label>Item 3</ion-label>
            </ion-item>
          </ion-list>
        </ion-card-content>
      </ion-card>
    </div>
    <nav hx-boost="true">
      <a href="page2.html">Go to Another Page</a>
    </nav>
  </body>
</html>

  • page2.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Another Page</title>
</head>
<body>
    <h1>Another Page Content</h1>
    <p>This content was loaded via HTMX boosting from index.html.</p>
    <p>Now, press your browser's **BACK button** to return to the previous page and observe its restored state.</p>
</body>
</html>
  1. Run a http server in the same directory (Eg: python -m http.server 6060
  2. Open localhost:6060
  3. Notice the 3 items in the list
  4. Click the "Go to Another Page" link and press back
  5. Notice the list items are gone

Possible fix

On debugging this, I realised that htmx uses cloneNode to save the document in history: https://github.com/bigskysoftware/htmx/blob/0da136f4a502089de6d01f7b7f33c32ecc8f709b/src/htmx.js#L3235 Replacing this line with:

const clone = /** @type Element */ getDocument().importNode(elt, true)

seems to fix the issue.

Further considerations

I'm unsure on the cause for the original issue and the internal differences of importNode and cloneNode that is fixing it.

A few things that I noticed though: Doing:

document.body.children[1].children[1].children[0].children.length

Should give 1 as output (ion-list element) but gives 0 instead. though doing:

document.body.children[1].children[1].children[0].querySelector(':scope > *') 

Does return me the ion-list element

They querySelector line fails on the cloned element (with cloneNode) but works on the element returned by importNode Console screenshot: Image

So there is definitely some implementation difference between both these functions that is the cause. While MDN suggests (here):

To clone a node to insert into a different document, use Document.importNode() instead.

I couldn't find a suitable explanation for this behaviour.

Someone, with deeper insights into these APIs would be able to better comment

Regards.

Gleek avatar Oct 10 '25 10:10 Gleek

e.cloneNode = function(e) {
    if ("TEMPLATE" === this.nodeName)
        return o.call(this, e);
    const t = o.call(this, !1)  // Clone with deep=false!
    const n = this.childNodes;
    if (e)
        for (let e = 0; e < n.length; e++)
            2 !== n[e].nodeType && t.appendChild(n[e].cloneNode(!0));
    return t
}

I found this code in ionic.esm.js!!!!

It is altering the clone node function on us which prevents it working as expected and cloning out the internal content! So importNode only worked because it was not being mocked by ionic.

// Before save: backup innerHTML for all Web Components
document.body.addEventListener('htmx:beforeHistorySave', function(evt) {
  evt.detail.historyElt.querySelectorAll('*').forEach(el => {
    if (el.tagName.includes('-') && el.innerHTML) {
      el.setAttribute('data-html-backup', el.innerHTML);
    }
  });
});

// After restore: restore innerHTML if it's broken
document.body.addEventListener('htmx:historyRestore', function() {
  setTimeout(() => {
    document.querySelectorAll('[data-html-backup]').forEach(el => {
      if (!el.innerHTML || el.innerHTML.length < 100) {
        el.innerHTML = el.getAttribute('data-html-backup');
      }
      el.removeAttribute('data-html-backup');
    });
  }, 0);
});

Something like this can allow you to workaround this limitation of ionic by manually saving out the contents into an attribute on save and then restoring it back after restore

MichaelWest22 avatar Oct 17 '25 11:10 MichaelWest22