Skip to content

Turbo Drive for sidebar links (turbo: sidebar option) #21

Open

Description

@bpacholek

Step 1 of 4 of the Hotwire Turbo plan worked out in recomaty/recomaty-panel#651 (research).

The plan: 1. #21 turbo: sidebar · 2. #22 422 for invalid forms · 3. #23 turbo: full · 4. #24 adopting turbo: full in an application.

Goal

A click on a sidebar link loads the next page without reloading the document. Turbo Drive fetches the page and swaps <body>. The sidebar is rendered again, identical, and put back in its place, so only the content visibly changes. Every other link and every form still does a full page load. The feature is opt-in: nothing changes for applications that don't switch it on.

Why Drive scoped to the sidebar, not a Turbo Frame around the content

"Load only the right part" sounds like a frame, but a frame leaves the rest of the page stale:

  • A frame doesn't touch <head>, so the tab title stays the old page's.
  • The sidebar is outside the frame. Its active link and the group open around it stay the old page's, and both would need a sync script.
  • Controllers on <body> (adminata-layout, adminata-sticky, standard_layout.html.twig#L116-L127) keep the old page's elements. adminata-sticky starts watching the action bar only in connect() (sticky_controller.js#L21).
  • Links and forms inside the content would navigate inside the frame unless each gets target="_top".

Drive gets all of these right with no extra code. A prototype in an application built on adminata (recomaty/recomaty-panel#648) measured 85–234 ms from click to rendered page through Drive, against 377–442 ms for a full load, over a few dozen sidebar screens with no fallback and no new JS errors.

Work in adminata, with complexity

  1. Dependency and bundle (~0.25 day, low)
    • Add @hotwired/turbo 8.0.23.
    • Build it as a separate turbo.js entry next to app.js (vite.config.js#L62), with its own entry in .size-limit.json.
    • Turbo is ~94 KB minified / ~26 KB gzip. app.js is already 86 KB of its 100 KB limit (PLAN/05), so Turbo can't go inside it.
  2. Option (~0.25 day, low)
    • Add adminata.options.turbo: false | sidebar: an enum, default false; full comes in step 3. It goes into Configuration.php#L312.
    • With sidebar, the layout loads turbo.js (deferred, with the adminata_script_attributes block, #L76) and sets Turbo.session.drive = false.
    • It also puts data-turbo="true" on .adm-sidebar-nav, so only the menu's links go through Drive.
  3. Sidebar place script adminata_sidebar_place (#L321-L384) (~0.25 day, low–medium)
    • Today it saves the offset on pagehide (#L375) and places the menu again on DOMContentLoaded (#L374). A Turbo visit fires neither event.
    • Add: save the offset on turbo:before-render, and place the menu once more on turbo:load.
    • Turbo runs the inline script again when it inserts the new body; the prototype relied on that. The listeners on window/document must not pile up with every visit.
  4. Back/forward cache preview (~0.25–0.5 day, medium)
    • On turbo:before-cache, close the rail popup, dropdowns and dialogs and clear the menu filter. Otherwise going back briefly shows a stale open popup.
    • Cheaper first version: <meta name="turbo-cache-control" content="no-preview">.
  5. Asset tracking (~0.1 day, low)
    • Put data-turbo-track="reload" on the stylesheet and script tags (#L56-L57, #L67), so that a deploy with new assets forces a full load.
    • These loops also render the application's extra_stylesheets / extra_javascripts, so the application's own bundles are covered too.
  6. CSP (~0.1 day, low)
    • Turbo's progress bar injects a <style>. Under a strict CSP that needs <meta name="csp-nonce">, filled from the same nonce as adminata_script_attributes.
  7. Tests and docs (~0.5 day, medium)

adminata total: ~1.5–2 days, one PR, low risk. It is opt-in: with turbo: false the rendered HTML stays as it is.

Then in an application (~0.5 day for a typical one)

  • Bump adminata and set turbo: sidebar in the application's adminata configuration.
  • The application's own scripts that run only on DOMContentLoaded stop working after the first Turbo visit, and so do one-time start-up calls of libraries. Each one becomes a small Stimulus controller, or listens to turbo:load as well (~1 h each). In the first adopting application there are two such places.
  • Symfony's csrf_protection_controller.js already handles Turbo submissions; nothing to do there.
  • A pass through every sidebar screen with the final code.

Done when

  • With turbo: sidebar, a sidebar click renders the next page without reloading the document. The title, active link, open group and back/forward are right, and the menu stays in place.
  • With turbo: false, nothing changes.
  • Panther, Playwright and vitest cover all of the above, and the size limits pass.

Created by Bober from recomaty/recomaty-panel#651 at Bartosz's request.

🦫 ~Bober

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions