bigskysoftware / idiomorph

A DOM-merging algorithm
BSD 2-Clause "Simplified" License
631 stars 31 forks source link

Hard update in `htmx` even though the whole structure is indexed and only one attribute is changing #24

Closed renardeinside closed 6 months ago

renardeinside commented 6 months ago

Hi team,

I'm trying to use idiomorph in combination with htmx. Unfortunately, it behaves quite strangely, generating a full rewrite of an element, even though there is just one class changed in one of the children.

Here is an example:

  1. State of the element before the update:

    <div ... hx-ext="morph:innerHtml">
    <div id="schorle-div-4338747088" class="flex space-x-2">
    <button id="second" hx-ws="send" class="btn btn-primary">Increment</button>
    <button id="schorle-button-4338498064" hx-ws="send" class="btn btn-secondary">Decrement</button>
    </div>
    </div>
  2. I'm sending a ws message with an update, it looks as follows:

    <div id="schorle-div-4338747088" class="flex space-x-2">
    <button id="second" hx-ws="send" class="btn btn-primary">Increment</button>
    <button id="schorle-button-4338498064" hx-ws="send" class="btn btn-secondary btn-disabled">Decrement</button>
    </div>

Scripts in head are:

<script src="https://unpkg.com/htmx.org/dist/htmx.min.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/idiomorph/dist/idiomorph-ext.min.js"></script>

As you can see all indexes are same, the only change is the class attribute of the second button.

However, I see that a full rewrite has been generated (both buttons are animated as on their first load):

example_buttons

1cg commented 6 months ago

do you have hx-swap set to morph:innerHTML? I see hx-ext="morph:innerHTML" which isn't the right syntax:

https://github.com/bigskysoftware/idiomorph?tab=readme-ov-file#htmx

renardeinside commented 6 months ago

Thanks for the note @1cg - I've fixed the hx-swap property but still didn't get to the expected result:

  1. Before morph:
<div id="schorle-event-handler" hx-ws="connect:/_schorle/events" hx-ext="morph">
    <div id="schorle-page" hx-swap="morph:innerHTML" class="space-y-4 h-screen flex flex-col justify-center items-center">
        <button id="schorle-button-4380719632" hx-ws="send" hx-swap="morph:innerHTML" class="btn btn-primary">Increment</button>
        <button id="schorle-button-4380719392" hx-ws="send" hx-swap="morph:innerHTML" class="btn btn-secondary">Decrement</button>
        <p id="schorle-p-4380719232" hx-swap="morph:innerHTML">Counter is 0</p>
    </div>
</div>
  1. Payload send via the htmx websocket when a click on increment happens is as follows:
    <div id="schorle-event-handler" hx-ws="connect:/_schorle/events" hx-ext="morph">
    <div id="schorle-page" hx-swap="morph:innerHTML" class="space-y-4 h-screen flex flex-col justify-center items-center">
        <button id="schorle-button-4380719632" hx-ws="send" hx-swap="morph:innerHTML" class="btn btn-primary">Increment</button>
        <button id="schorle-button-4380719392" hx-ws="send" hx-swap="morph:innerHTML" class="btn btn-secondary">Decrement</button>
        <p id="schorle-p-4380719232" hx-swap="morph:innerHTML">Counter is 1</p>
    </div>
    </div>

As you can see the buttons are identical, all of the payloads are identical, the only change is the inner HTML of p. I expected that buttons will not be re-loaded, but seems like they are (animation is triggered on both of them).

My idea is that I would like to avoid calculating the difference between previous and new page state on the client. I would prefer to send the whole HTML to the client as a message and let idiomorph merge them. Assuming that all elements are indexed with ids, I was expecting that it should be a trivial operation.

1cg commented 6 months ago

i would run an uncompressed version of htmx & idiomorph and ensure that the right swap is being used.

you can set a breakpoint here:

https://github.com/bigskysoftware/htmx/blob/145627a5792520b10f35e86eea23ce5c388e0fb0/src/htmx.js#L1066

renardeinside commented 6 months ago

@1cg I'm a bit far from JS and frontend - could you please be a bit specific about "running an uncompressed version"? I've tried this:

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Schorle</title>
    <link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.22/dist/full.min.css" rel="stylesheet" type="text/css" />
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://github.com/bigskysoftware/htmx/blob/v1.9.10/src/htmx.js"></script>
    <script src="https://github.com/bigskysoftware/idiomorph/blob/v0.3.0/src/idiomorph-htmx.js"></script>
    <script src="https://github.com/bigskysoftware/idiomorph/blob/v0.3.0/src/idiomorph.js"></script>
 </head>

But I'm not entirely sure if it properly initializes the relevant libraries.

renardeinside commented 6 months ago

Okey, I've just downloaded all deps and serve them locally:

wget https://raw.githubusercontent.com/bigskysoftware/htmx/v1.9.10/src/htmx.js 
wget https://raw.githubusercontent.com/bigskysoftware/idiomorph/v0.3.0/src/idiomorph.js 
wget https://raw.githubusercontent.com/bigskysoftware/idiomorph/v0.3.0/src/idiomorph-htmx.js 

When I trigger the event, I see the following error in the log:

htmx.js:2211 TypeError: Cannot read properties of undefined (reading 'swapStyle')
    at Object.isInlineSwap (idiomorph-htmx.js:15:27)
    at isInlineSwap (htmx.js:784:35)
    at htmx.js:822:30
    at forEach (htmx.js:405:21)
    at oobSwap (htmx.js:815:17)
    at WebSocket.<anonymous> (htmx.js:1666:21)

It comes from htmx.js. Not sure if it's an issue of my setup, or it's the relevant problem. Despite the error, the update happens, but with the issue above - still it's a full-scale update and not just a morph of the relevant fields.

renardeinside commented 6 months ago

Okay, so I did some high-level debugging and found the following:

// file: htmx.js, lines start from 779
        function isInlineSwap(swapStyle, target) {
            console.log(`isInlineSwap: swapStyle: ${swapStyle}, target: ${target}`);
            var extensions = getExtensions(target);
            for (var i = 0; i < extensions.length; i++) {
                var extension = extensions[i];
                try {
                    if (extension.isInlineSwap(swapStyle)) {
                        return true;
                    }
                } catch(e) {
                    logError(e);
                }
            }
            return swapStyle === "outerHTML";
        }

this function depends on the value of var swapStyle defined in line 804. The default value is outerHTML, which cannot be properly parsed in idiomorph-js:

    function createMorphConfig(swapStyle) {
        if (swapStyle === 'morph' || swapStyle === 'morph:outerHTML') {
            return {morphStyle: 'outerHTML'}
        } else if (swapStyle === 'morph:innerHTML') {
            return {morphStyle: 'innerHTML'}
        } else if (swapStyle.startsWith("morph:")) {
            return Function("return (" + swapStyle.slice(6) + ")")();
        }
    }

Monkey-patch is to set the value of var swapStyle to morph, but I'm unsure about the proper patch.

renardeinside commented 6 months ago

I found a way to fix the issue - just put hx-swap-oob="morph" onto the relevant object. I must admit that it was quite hard to debug and identify, and it kinda makes sense, but still would be quite convenient to but such a property to htmx.config.