Sidebar or Navigation Bar with Hotwire Turbo

I’m working on a navbar, I’m wondering what is the best way to improve the performance of this navbar, as the items of this navbar are calculated in the backend.

I thought about using a turbo-frame ‘content’, to wrap the content and not reload the navbar, but by doing so I’d lose the url changes ( as the turbo-frame does not update the browser url), I’d have to add the turbo-frame ‘content’ to a lot of my pages, and possible nest another turbo-frame in the future.

I also could lazy load the navbar and cache the items, I tried it, but I could see the navbar ‘blinking’ whenever it lazy loads.

I also could use the traditional approach, just cache the navbar and reload it whenever the page changes.

what are you thoughts… TY

DHH talks a bit about their approach to menus in Hey in the most recent episode of Full Stack Radio. They discuss it around 48:50.

David said that they use a Turbo-frame that they reveal on the DOM. When the frame appears it lazy-loads the menu on the first render and then caches the menu. Since the menus don’t change much, the cache doesn’t expire very often, removing the need to constantly lazy-load it.

EDIT: Thinking some more about what DHH says in the podcast, I actually have realized something is kind of unclear about the lazy-loading behavior of frames. In Hey they use the <details> element to take advantage of some built-in pop-up behavior that exists in HTML 5. Consequently, the frame only triggers lazy-loading when it appears. The frame is already on the page, but only loads when the <details> dropdown is opened. Would this work the same way with a Stimulus controller that toggles display mode on a div? I’m not familiar enough with <details> to be able to tell if that’s what’s being watched.

EDIT 2: I just tested this out for myself, and it looked like Turbo-frames are still lazy-loaded, even if display is set to none. I wonder what is special about <details> that makes it work how it does in Hey.

2 Likes

I think the biggest benefit from lazy loading is the cache improvements. If you have a complicated menu, you can load it once with all the potentially many database calls, and then serve up a cached copy every other time, until something changes in the menu.

I have a menu in one app that show many links based on a person’s permissions. I plan on calculating it once now, caching it, and only having to re-render the menu when someone’s permissions change, which is not common.

1 Like

About the navigation, I think on Basecamp they actually have it as a persistent component, so it doesn’t reload across visits. The navigation will be persisted as long as the response contains it, I believe.

About the lazy-load only when the component is clicked that @jacobdaddario mentioned, I inspected the code in Hey and looks like they have a hidden link outside of the turbo-frame that points to the frame using a data-turbo-frame="frame_id" attribute. When clicking on the link, Turbo will visit the link href and render the matching frame on the response inside the frame. The neat trick is listening to the details toggle event and trigger a click on the link within the stimulus controller.

I had screenshots, but new users can only share 1 image, so I tweeted them.

4 Likes

@tonysm I just discovered the data-turbo-frame= loading behavior as well! Have you had any experience updating the address bar URL to match the fetched HTML?

Taking the example from your tweet, is there a non-JS way to update the URL to : /test-lazy-fragment when clicking that link and loading the response HTML into the "myLazyLoad" frame?

I’m currently wiring up a list -> details view (consisting of a sidebar and a main content area) and I’d love to change the URL from the index view /notes to /note/:id when the user clicks a note in the sidebar and it’s rendered into the details view (main content area).

Fascinating. So it appears that there isn’t anything special about that details element, instead maybe Hey is using something that isn’t part of the Turbo API yet?

EDIT: WOW. I didn’t even notice that there isn’t a src attribute on the frames used to lazy-load the menus. My test didn’t work because I wasn’t even testing the same thing. I wonder what the deal is with that data attribute on the link. Hopefully someone from the Basecamp team can drop in and comment how that works.

It’s funny that it doesn’t actually use the lazy-loading API associated with Turbo-frames. It’s unintuitive, but makes sense given what they’re trying to achieve. I wonder what the argument here is for Turbo-frames instead of a Turbo-stream.

EDIT 2: Wait, it definitely does have a src attribute. I’m very confused as to why this doesn’t lazy-load until the hidden link tag is programmatically clicked.

There is a lot in there that’s just for the display of the menu and connecting things to their custom stimulus controllers (search, show the menu, etc…), it boils down to just:

<a data-turbo-frame="navigation" href="/nav">Show Nav</a>
<turbo-frame id="navigation"></turbo-frame>

On initial load the turbo-frame won’t have a src attribute until you click the link. Outside of the data-turbo-frame functionality on the link, if you just add a src attribute (for example with JS):

document.getElementById("navigation").src = "/nav";

with a valid URL to the turbo-frame, that will trigger the turbo-frame to load the content as long as the resulting page has a matching turbo-frame to be extracted.

4 Likes

I think this nails it. I still can’t totally figure out why clicking the anchor tag adds a src attribute to the frame, but at minimum this definitely explains why the lazy loading works the way that it does.

If the frame doesn’t have a src attribute to start with, it’s just an empty frame, not a lazy loaded frame. It’s the same mechanism as in the first example in the manual.

1 Like

The key is remembering Basecamp’s philosophy. The JS is merely decorating the HTML they already have. As @chrisgrande said; the crux of it is that humble line of HTML, hidden in the <summary>:

<a class="undecorated push_half--right" data-turbo-frame="my_stuff" data-popup-menu-target="link" href="/me" hidden="">My stuff</a>

Remember this: it’s a plain old link. Or at least, it looks like one, and if JS was disabled, it should work like one and simply navigate to /me.

That’s not what actually happens, of course. But the awesome thing about what happens next is how much is leveraging standard browser behaviour and just decorating it to enhance it.

The element pulling the extra strings is the popup menu controller. This listens for the same toggle event that the <summary> sends to open the <details> dropdown, and on hearing that, it simply clicks the link.

Again, if there was a problem with turbo frames, this would simply navigate you to /me.

However, the turbo-frame element has other ideas. It has previously registered an interest in all turbo:click events, and intercepts this one. The link is the target of the event, and it sees the link’s data-turbo-frame matches its own id, and that is the hook by which is decides it must be on lazy-loading duty, appropriates the click event, and pulls the href of the link to use as its own src going forward, i.e. it fetches /me.

In the meantime the animation of the <details open> is running and if you are very lucky it completes at the exact moment the frame’s XHR round-trip completes.

That’s how it works. The popup menu controller is just a Stimulus controller, and the <turbo-frame> element is a custom element from the Web Components API, and they’re just riffing off a plain old <a href> in an otherwise fairly standard <details>/<summary> construction. Ultimately, the whole mechanism is delegating a ton of work back to standard browser behaviours, and can therefore gracefully degrade when JS is disabled or glitching.

4 Likes

Hey, so I was facing a similar problem. I have a list rooms on the side and when the user clicks on any room, they should see the latest messages on the right side. I didn’t use this trick at all. Instead, I kept the rooms list as a data-turbo-permanent and that takes care of not “replacing” the list when visiting the the room page. As long as the room page contains an element with a matching ID, it should keep it untouched on screen. Did the same for the main navbar.

1 Like

Ahh good to know. That, unfortunately won’t work in my case, since the show view (room page in your example) doesn’t include the sidebar HTML (and corresponding I’d).

This situation seems to come up a lot. Ongoing discussion in this issue: https://github.com/hotwired/turbo/issues/50

1 Like

So when a user visits a detail view directly (full browser refresh, no turbo visit), they get a different HTML document (without the list on the left-side, for instance)? Yeah, it would be cool to allow something like “replace that frame and change URL” as discussed on the issue.

This is the basic approach how Hey implements details/summary - pop menu behaviour:


When(if) this PR gets merged then you will be able to add `loading: “lazy”’ on the frame, which will fire request when frame becomes visible on the page (using IntersectionObservers).

4 Likes

Btw, I should also mention that on the details page, I do have the element there, but if it’s a turbo visit, I just pass down to the view an empty list of rooms. We just need the a matching permanent element on the response, not its content.

In fact, in my case, I use the same route and the room ID is optional, so I check if a room_id was given and it’s a turbo visit. If that’s the case, I pass down an empty list of rooms. Otherwise, query all rooms and pass them down (because it’s probably a full page visit, and I always want the sidebar to be there).

If anyone has a better suggestion about how to implement something like this, I would be very interested!

Yeah exactly. Currently they would just get the show view, without the sidebar. But, the more I think about this, from the perspective of what the HTML would be without JS/Hotwire, the more it seems like that show view should include the sidebar. I think that would be more consistent with the intended design of Turbo Drive (with persistent elements) updating the URL, but Turbo Frames not.

I was resisting this approach cause doing another SQL query to render the notes list in sidebar, when we really just needed a single note seemed wasteful/“ and inelegant. But, I just realized it doesn’t make sense to render the show view without the sidebar to navigate around.

My only question with the Drive + persistent element approach is: will Turbo Drive return the whole show + sidebar HTML document or can it be scoped to just the the show part, like with Turbo Frames?

The current lazy-loading PR is one I would be saddened to see merged into Turbo. The founding principle of Hotwire is progressive enhancement for the HTML we already have; this is why I find it attractive. The implementation is loosely-coupled event-driven HTML-centric compositional JS with great ergonomics for extension and variation and opportunities for fallback to default behaviour, and I want the same for any new capability.

All of which is to say, the point of bundling Turbo and Stimulus together is to achieve your application-specific page automation requirements, including the lazy loading of frames when particular on-page conditions are met.

To be Honest, I haven’t dug deeper into each of these options, I’m still just boiling these different ideas in my head.

The data-turbo-permanent seemed to work pretty good, I think that this might be one of the feasible solution, ty

I just opened a pull request in the turbo repo that enables updating URLs on frame navigation (links and form submissions) and Turbo Stream responses: Optionally update URLs on Frame navigation and stream responses by bfitch · Pull Request #167 · hotwired/turbo · GitHub

Please take a look and comment here or there if you think it might help with the uses cases above. Thanks!