bigskysoftware / htmx

</> htmx - high power tools for HTML
https://htmx.org
Other
38.19k stars 1.3k forks source link

HTMX saves modified DOM in saveHistory (hx-boost) #1015

Closed hirasso closed 2 years ago

hirasso commented 2 years ago

Hi there! I have a site with hx-boost enabled. I also want to use Alpinejs for a few things. This, for example, would render a list with the three colors in it (see documetation):

<ul x-data="{ colors: ['Red', 'Orange', 'Yellow'] }">
  <template x-for="color in colors">
    <li x-text="color"></li>
  </template>
</ul>

It works just as expected if I first visit the page that contains this snippet. But when I navigate away and then back to the page using the browser's back-button, this happens: The previously rendered list is already there, but is again evaluated, leading to errors. Basically what is happening is, HTMX saves the state of the DOM after it was altered by other scripts, which leads to side-effects like this.

Are there any known approaches to solving these kind of problems?

hirasso commented 2 years ago

As a hacky workaround, I can do this to clean-up the component before htmx takes it's snapshot:

<ul x-data="{ colors: ['Red', 'Orange', 'Yellow'], innerHTML: null, init: () => innerHTML = $el.innerHTML, destroy: () => $el.innerHTML = innerHTML }" 
  x-on:htmx:before-history-save.window.camel="destroy()">
  <template x-for="color in colors">
    <li x-text="color"></li>
  </template>
</ul>

This saves the innerHTML of the component before it renders the first time and resets it before HTMX takes it's snapshot. But probably there is a cleaner solution?

1cg commented 2 years ago

What you have is, unfortunately, the recommended solution.

In htmx 2.0, I'm going to think harder about how to deal with HTML that's been mutated by javascript and the history cache. It's a hard problem.

hirasso commented 2 years ago

Yes, it's a hard problem, indeed. I can actually imagine that it won't be possible to cleanly solve it for every last use case. But some official documentation on how to deal with DOM-altering third-party JS would already be very helpful, IMHO.

frktimo commented 1 year ago

Here's my take on @hirasso's workaround (thanks!). It stores pristine snapshots of Alpine components' HTML in data attributes (I'm not proud of it but look how easy it is! :smile:) and restores them after HTMX has restored its snapshot of the whole DOM.

window.saveHtmxHistorySnapshot = (el) => {
  el.dataset.htmxHistorySnapshot = el.outerHTML;
};

addEventListener('htmx:historyRestore', () => {
  const els = document.querySelectorAll("[data-htmx-history-snapshot]");

  for (const el of els) {
    const snapshot = document.createElement("template");
    snapshot.innerHTML = el.dataset.htmxHistorySnapshot;
    const snapshotEl = snapshot.content.firstChild;

    el.replaceWith(snapshotEl);

    htmx.process(snapshotEl);
  }
});

Usage:

<div x-data x-init="saveHtmxHistorySnapshot($el)">
  ...
</div>

Note that this hack is not specific to Alpine.js, and I use it to work around the same kind of issues with highlight.js.

billythedummy commented 1 year ago

Do these workarounds actually work? I've tried both suggestions with a Custom Element i'm building and what i'm observing is that the hx- attributes stop working if you press back sometimes. I'll try to create a minimal repro if i have time.

It also seems like the saved localStorage entry still contains the mutated HTML instead of the reset HTML despite having reset innerHTML on beforeHistorySave.

scrhartley commented 1 year ago

Here's the version of the Alpine.js solution by @hirasso that I use:

<ul x-data="{ colors: ['Red', 'Orange', 'Yellow'] }"
    x-init="$data.innerHTML = $el.innerHTML"
    x-on:htmx:before-history-save.window="$el.innerHTML = $data.innerHTML">
  <template x-for="color in colors">
    <li x-text="color"></li>
  </template>
</ul>

Note 1: .camel on the event attribute isn't necessary since htmx also fires a kebab case version of the event Note 2: when I tried to use his version, it didn't work since it was missing using this. for access to properties in x-data Note 3: $data. in the event attribute isn't actually necessary, since it's initialized at that point, but... symmetry

Different version:

<ul x-data="{ colors: ['Red', 'Orange', 'Yellow'] }"
    x-on:htmx:before-history-save.window="htmx.findAll($el, ':scope > :not(template)').forEach(node => htmx.remove(node))">
  <template x-for="color in colors">
    <li x-text="color"></li>
  </template>
</ul>

Or: x-on:htmx:before-history-save.window="$el.replaceChildren($el.firstElementChild)"

Here's a fixed version of the original:

<ul x-data="{ 
    colors: ['Red', 'Orange', 'Yellow'],
    init: () => this.innerHTML = $el.innerHTML,
    reset: () => $el.innerHTML = this.innerHTML
}" x-on:htmx:before-history-save.window="reset()">
  <template x-for="color in colors">
    <li x-text="color"></li>
  </template>
</ul>