bigskysoftware / htmx

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

Make `cleanUpElement` public or make `htmx.process` configurable to always clean up a given element and its children #2386

Open jantobola opened 6 months ago

jantobola commented 6 months ago

There is an issue when using web components (e.g., Shoelace) with form elements. The problem is that web components, in general, take some time before they are fully registered/loaded. Shoelace registers 'submit' event listeners, which handle validations, but because it happens after web components are defined (customElements.whenDefined), HTMX registers its submit listener first, and when the user clicks the submit button, the form is sent to a server, even before shoelace validations are fired up. So, the order of registered 'submit' event listeners is the problem here.

Consider this example:

<body hx-boost="true">

  <form action="/articles" method="post">
    <sl-input name="content" required></sl-input>
    <sl-button type="submit" variant="primary">Submit</sl-button>
  </form>

  <script type="text/javascript" type="module">
    Promise.allSettled([
      customElements.whenDefined("sl-input"),
      customElements.whenDefined("sl-button")
    ]).then(() => {
      console.log("web components loaded")
    }) 
  </script>
</body>

This will completely ignore the required attribute on sl-input. You may argue that this can be easily handled by using hx-disable on the form and then call htmx.process manually after web components are loaded. Like this:

<body hx-boost="true">

  <form action="/articles" method="post" hx-disable="true">
    <sl-input name="content" required></sl-input>
    <sl-button type="submit" variant="primary">Submit</sl-button>
  </form>

  <script type="text/javascript" type="module">
      Promise.allSettled([
        customElements.whenDefined("sl-input"),
        customElements.whenDefined("sl-button")
      ]).then(() => {
        console.log("web components loaded")
        const form = htmx.find("form")
        form.removeAttribute("hx-disable")
        htmx.process(form)
      }) 
    </script>
</body>

And this will work! ... until you start playing with how it behaves with a history cache.

A snapshot of the body is stored in local storage without hx-disable on the form element (the attribute is already removed by the script), and when this page is restored, it is processed by HTMX in a standard way, and the htmx 'submit' event is again registered first. It will run htmx.process on the form without hx-disabled when web components are defined, but this won't help now since htmx.process does not clean up already processed elements, so the order of listeners stays the same. You can check this in devtools console by executing the following command: getEventListeners(document.getElementsByTagName('form')[0]).submit

If the cleanUpElement function was a public API, it could work like this:

<body hx-boost="true">

  <form action="/articles" method="post">
    <sl-input name="content" required></sl-input>
    <sl-button type="submit" variant="primary">Submit</sl-button>
  </form>

  <script type="text/javascript" type="module">
      Promise.allSettled([
        customElements.whenDefined("sl-input"),
        customElements.whenDefined("sl-button")
      ]).then(() => {
        console.log("web components loaded")
        const form = htmx.find("form")
        htmx.cleanUpElement(form)
        htmx.process(form)
      }) 
    </script>
</body>

This has actually worked for me in every scenario so far because it re-registers all listeners, which means they are now registered after Shoelace's listeners.

Perhaps, instead of exposing a new API function, it would be better to extend the process function to take some options argument so a user can specify if he wants to clean up a given element first. Just an idea...

I think this is the best way to approach this issue without adding extra complexity to it. It is clearly stated in Shoelace's documentation:

Shoelace uses event listeners to intercept the form’s formdata and submit events. This allows it to inject data and trigger validation as necessary. If you’re also attaching an event listener to the form, you must attach it after Shoelace form controls are connected to the DOM, otherwise your logic will run before Shoelace has a chance to inject form data and validate form controls.

Maybe it could be solved differently, but anything that forces a programmer to think twice about handling such a simple case would be a potential shoot-in-the-foot scenario, especially when it works in normal flow but crashes when a user navigates to the page through a back button with a default history cache settings. This approach works with boost and does not require extra hx- attributes.

I think this is a 1:1 alternative with a current API:

<script type="text/javascript" type="module">
  Promise.allSettled([
    customElements.whenDefined("sl-input"),
    customElements.whenDefined("sl-button")
  ]).then(() => {
    console.log("web components loaded")
    const form = htmx.find("form")
    form.setAttribute("hx-disable", "true")
    // now it works as a clean up
    htmx.process(form)
    form.removeAttribute("hx-disable")
    // now it registers everything again
    htmx.process(form)
  })
</script>

This is a bit cumbersome; a single function call could solve it. Any thoughts?

croxton commented 5 months ago

You can use this extension to ensure that the htmx history cache contains a snapshot of the original markup state before it was manipulated by js:

https://gist.github.com/croxton/e2c33bd22591f9a5bd8c9d23a56c9edc