Skip to content

(WIP) Adds support to allow Htmx compatibility #1569

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 12 commits into from

Conversation

gone
Copy link
Contributor

@gone gone commented Dec 29, 2021

I've been playing with HTMX and djdt and I wanted to get them working better together. HTMX is like turbolinks, in that it loads html from the server and then inserts it into the page without reloading the page as a whole. Unlike turbolinks, it has a lot of support for html fragments, to allow loading chunks of the page in place. There are three modes of use with HTMX - the first page load, an htmx fragment load, and a boosted <a> tag.

Here's what I'm thinking for the three differnt modes of operation:

  1. First page load
    Nothing needs to change here. This works exactly like any existing page load.

  2. Fragments
    The goal here is to append the toolbar content to the end of the request and allow htmx use the hx-oob (https://htmx.org/attributes/hx-swap-oob/) attribute to just swap out the content. The attribute can be added with the setting value ROOT_TAG_EXTRA_ATTRS

This needs a little more consideration. The issues I've found so far:
- The fragments coming from the server don't have a consistent wrapper. It could be as simple as <p>hey<p> So to update the debug info we can't have a consistent string to look for to decide where to insert the toolbar content.
- The javascript on the page needs to be disabled to prevent multiple click handlers
- The javascript on the page needs to be re-inited after the content is swapped in
- The javascript and css that normally come with the response shouldn't be included

  1. Boosted a tag
    This works like turbolinks, and I've been able to get this working pretty well using the same strategy used for turbolinks (Debug toolbar doesn't work with turbolinks after first page load #1179). The fly in the ointment is that unlike the fragment case, this case should NOT include the hx-oob attribute as it's not piggybacking on another request. So I need a way to conditionally include the ROOT_TAG_EXTRA_ATTRS values. Additionally since in this case we've already loaded the css/js we should also exclude those.

An example use of these changes is here: gone/django-hydra@031d4ce#diff-bed13003085067d79e49d240de44a575e7c9c57073f67710eeb0a59fc32bf21aR31

@gone gone changed the title Adds support to allow Htmx compatability Adds support to allow Htmx compatibility Dec 29, 2021
@adamchainz
Copy link
Contributor

I don't really like the idea of adding the toolbar HTML to fragment responses. The toolbar HTML will massively outweigh the fragment size.

The "History" panel can already be used to inspect fragments responses, no?

@tim-schilling
Copy link
Member

The "History" panel can already be used to inspect fragments responses, no?

Yes, it should be captured there.

@gone
Copy link
Contributor Author

gone commented Jan 14, 2022

I don't really like the idea of adding the toolbar HTML to fragment responses. The toolbar HTML will massively outweigh the fragment size.

I'm open to another approach, but I really like DX of the oob approach. Can you help me understand what cases the html size would be a consideration in?

The "History" panel can already be used to inspect fragments responses, no?

Yes and no - Although it can capture the response, It can't "in the moment". You'll have to refresh the page and then use the history to move back to it, making it much less useful.

I've put up an example of this at https://sampleapp-w4bl.onrender.com/ - it's on the free tier so it's slow.

@adamchainz
Copy link
Contributor

Can you help me understand what cases the html size would be a consideration in?

🤷‍♂️ I don't have concrete evidence, but it feels wrong. Certain htmx patterns may fetch many small fragment responses, e.g. for autocompletes. Attaching all the toolbar HTML for every response will massively increase the work done by the browser.

You'll have to refresh the page and then use the history to move back to it, making it much less useful.

The history panel has a "refresh" button which allows you to view new requests without reloading the page. IMO a better approach would be to enhance the history panel functionality, perhaps by making it auto-refresh when opened, or even adding an "auto refresh" mode.

(I tried the refresh button on your demo app and it's broken there, perhaps due to this PR?)

@p-baum
Copy link

p-baum commented Jan 14, 2022

I don't really like the idea of adding the toolbar HTML to fragment responses. The toolbar HTML will massively outweigh the fragment size.

I dont see this as a valid reason. The debug toolbar is only used during development and debugging. Why is size important in this context? OOB makes sense to me.

The "History" panel can already be used to inspect fragments responses, no?

No. Its not working for me with fragments. In fact its throwing errors:

Uncaught (in promise) TypeError: inner.previousElementSibling is null
    init http://localhost:8000/static/debug_toolbar/js/toolbar.js:45
    promise callback*init/< http://localhost:8000/static/debug_toolbar/js/toolbar.js:44
    on http://localhost:8000/static/debug_toolbar/js/utils.js:6
    on http://localhost:8000/static/debug_toolbar/js/utils.js:3
    init http://localhost:8000/static/debug_toolbar/js/toolbar.js:14
    <anonymous> http://localhost:8000/static/debug_toolbar/js/toolbar.js:307
toolbar.js:45:29
    init http://localhost:8000/static/debug_toolbar/js/toolbar.js:45
    (Async: promise callback)
    init http://localhost:8000/static/debug_toolbar/js/toolbar.js:44
    on http://localhost:8000/static/debug_toolbar/js/utils.js:6
    (Async: EventListener.handleEvent)
    on http://localhost:8000/static/debug_toolbar/js/utils.js:3
    init http://localhost:8000/static/debug_toolbar/js/toolbar.js:14
    <anonymous> http://localhost:8000/static/debug_toolbar/js/toolbar.js:307
    InnerModuleEvaluation self-hosted:2388
    evaluation self-hosted:2349

@p-baum
Copy link

p-baum commented Jan 14, 2022

Certain htmx patterns may fetch many small fragment responses, e.g. for autocompletes. Attaching all the toolbar HTML for every response will massively increase the work done by the browser

Simply update the toolbar after every response. Sure with autocomplete this will slow the browser down. But remember. Were debugging here. Why does it need to be fast?

@gone
Copy link
Contributor Author

gone commented Jan 14, 2022

I think the auto-refresh is a really good idea and it's applicable for a lot of different usecases like vanilla ajax requests. Let me get a PoC up on that to see how it feels

@p-baum
Copy link

p-baum commented Jan 14, 2022

We could always defend against browser overload by debouncing or something IF it becomes a problem.

@gone gone changed the title Adds support to allow Htmx compatibility (WIP) Adds support to allow Htmx compatibility Jan 16, 2022
@gone gone changed the base branch from main to develop January 16, 2022 16:51
@gone gone changed the base branch from develop to main January 16, 2022 16:52
@gone
Copy link
Contributor Author

gone commented Jan 16, 2022

Ok Initial PoC for auto refresh mode.

I would love any feedback/suggestions but the core changes to get this working:

I would love any feedback about the approach!

Some open questions I have from working through this:

Why are we signing the request for the switch request? We don't sign loading the panel so I'm wondering if this can be removed or if it serves a purpose

I am calling toolbar.store twice: https://github.com/jazzband/django-debug-toolbar/pull/1569/files#diff-4c34c65dc0e8564d6d1b67b93a2099a4a8f7a5264cded1f09de5eb724bb6523dR27 and in the main render call. Can we remove the second one? The comment there says it's being doing for the purpose of the history panel.

This is not in a merge ready state and I have some clean up to do. Some of the changes from the prior approach I need to discard as they no longer matter, and some of them are still important for the boosted case.

Making a todo list for myself:

  • Fix git rebase snafoo
  • Come up with Better names for the headers
  • remove functionality we longer need
  • Properly wrap the js prototype annotation into the DJDT namespace, and initialize in the template
  • Property wrap the the prototype initialization around a setting value
  • Test
  • documentation
  • BUG: https://github.com/jazzband/django-debug-toolbar/pull/1569/files#diff-9544c759ef64d0c6ba09293b8398aa6a00311339b47afec022bc66af161e6312L25 breaks the historical panel behavior. I'm planning on popping the value client side to get the old behavior, but I could also update the view to take an optional form key to make the decision server side. LMK if there's a preference
  • BUG: When switching between pages (htmx boosted mode) a panel that was already loaded in a prior page doesn't have it's scripts reinitialized when loading in the new page

gone added 3 commits January 16, 2022 15:56
adding view that will return the toolbar for a given store id
Adding header containing toolbar url
Refactoring history javascript to allow switching state by store id
Copy link
Member

@tim-schilling tim-schilling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the work on this so far. It would be great if you could pull out the auto-refresh code into it's own PR. I feel like the scope of that should be smaller than the changes to support HTMX.

A general concern is that the changes to support auto-refreshing of the toolbar's history panel is that I couldn't find the spot that actually does the auto-refreshing. Can you give me a little bit of a rundown of how your changes are supporting that?

Why are we signing the request for the switch request? We don't sign loading the panel so I'm wondering if this can be removed or if it serves a purpose

Admittedly, it may not be necessary, but the purpose of it is to verify that the client isn't changing the store id of the toolbar that is being requested.

I am calling toolbar.store twice: https://github.com/jazzband/django-debug-toolbar/pull/1569/files#diff-4c34c65dc0e8564d6d1b67b93a2099a4a8f7a5264cded1f09de5eb724bb6523dR27 and in the main render call. Can we remove the second one? The comment there says it's being doing for the purpose of the history panel.

We can't rely on all of the panels always being enabled. If the only call to toolbar.store() was made in the HistoryPanel, we run the risk of it never being called if the HistoryPanel is not included.

@@ -91,6 +106,14 @@ function ajax(url, init) {
});
}

function pluckData(array, key) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function can likely be replaced with:

return array.map(function(obj) {
    return obj.dataset[key]
})

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was my first attempt, but annoyingly Set objects don't have a map function.

Comment on lines +17 to +20
abort() {
controller.abort();
resetAbortController();
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like the only part of the code that uses the controller and the signal. Outside of allowing another third party panel to abort a request, what purpose does this serve?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling abort will unregister the click handlers - the intent here is that init will configure the JS click handlers and this will undo it to put us back in the state we were immediately before init.

The Purpose of that is that for boosted requests / Turbolinks the new page will contain it's own toolbar html, and I was finding conflicts with multiple click handlers registered.

sig = SignedDataForm(
initial=HistoryStoreForm(initial={"store_id": store_id}).initial
).initial.get("signed")
response["dj-history-sidebar-url"] = reverse("djdt:history_sidebar")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this isn't likely to change between requests, this could be moved out of the per-request cycle and into the template as a JS variable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

if not self.toolbar.should_render_panels():
self.toolbar.store()
store_id = self.toolbar.store_id
response["DJ-TOOLBAR-BASE-URL"] = (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is being used.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not, I will remove

Copy link
Contributor Author

@gone gone left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you could pull out the auto-refresh code into it's own PR

I agree - I think the line is going to be fragment responses will probably be handled by the "auto-refresh" stuff and the htmx specific changes will be around boosted links. Let me make a test case with a vanilla hxr/fetch update.

Can you give me a little bit of a rundown of how your changes are supporting that?

That's happening here: https://github.com/jazzband/django-debug-toolbar/pull/1569/files#diff-ec4138914976be8d39d9780b045095caba36dfae38e8f8e671279b0d41e3aad6R323
An ajax request is made by the client, and when it comes back from the server that JS function will inspect the incoming request, and if it's a good candidate for updating will make a follow up js request to grab the toolbar data and switch it in.

if not self.toolbar.should_render_panels():
self.toolbar.store()
store_id = self.toolbar.store_id
response["DJ-TOOLBAR-BASE-URL"] = (
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not, I will remove

sig = SignedDataForm(
initial=HistoryStoreForm(initial={"store_id": store_id}).initial
).initial.get("signed")
response["dj-history-sidebar-url"] = reverse("djdt:history_sidebar")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines +17 to +20
abort() {
controller.abort();
resetAbortController();
},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling abort will unregister the click handlers - the intent here is that init will configure the JS click handlers and this will undo it to put us back in the state we were immediately before init.

The Purpose of that is that for boosted requests / Turbolinks the new page will contain it's own toolbar html, and I was finding conflicts with multiple click handlers registered.

@@ -91,6 +106,14 @@ function ajax(url, init) {
});
}

function pluckData(array, key) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was my first attempt, but annoyingly Set objects don't have a map function.

@gone
Copy link
Contributor Author

gone commented Jan 17, 2022

Closing this in favor of #1577 which I think is a more fundamental case. I'll reopen this once that's got a clear way forward and this can focus on just htmx stuff (I think just the boosted link case)

@gone gone closed this Jan 17, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants