Skip to content

Editor content permanently disappears after turbo:before-cache #1200

Description

@wbotelhos

Summary

lexxy-editor's #handleTurboBeforeCache resets editorContentElement to null on turbo:before-cache, assuming a new connectedCallback will follow (as it would during a real Turbo Drive page morph/restore). But turbo:before-cache can fire in situations where no such reconnect ever happens, permanently leaving the editor with only its toolbar visible — the contenteditable body is gone and never comes back until a full page reload.

Reproduction

  1. Have Turbo.session.drive = false set (Turbo Drive disabled for the page/layout).
  2. Render a <lexxy-editor> on the page (e.g. via rich_text_area).
  3. Add any plain in-page anchor link, e.g. <a href="#comments">...</a>, pointing to an id elsewhere on the same page.
  4. Load the page, confirm the editor renders normally.
  5. Click the anchor link (no full navigation happens — same URL, just a fragment change).
  6. The browser fires a native popstate event for this same-page fragment navigation.
  7. Turbo's HistoryDelegate.onPopState handles this popstate: since event.state.turbo is absent, it goes through historyPoppedWithEmptyState, which calls view.cacheSnapshot() → dispatches turbo:before-cacheeven though Turbo Drive is disabled and no actual Turbo visit/render is happening.
  8. lexxy-editor's listener for turbo:before-cache (registered in #resetBeforeTurboCaches) calls #reset(), which does:
    this.editorContentElement?.remove();
    this.editorContentElement = null;
  9. Because this wasn't a real page morph, connectedCallback never runs again for this element. editorContentElement stays null forever. Only the toolbar (a separate custom element, lexxy-toolbar) remains visible; the contenteditable body is gone.
  10. Confirmed via devtools: document.querySelector('lexxy-editor').editorContentElement returns null after step 5, and manually calling element.disconnectedCallback(); element.connectedCallback(); recovers it immediately (proving the toolbar/editor internals are otherwise intact — this is purely the #reset() never being followed by a re-mount).

Why this happens with Drive disabled

Turbo's elementIsNavigatable correctly refuses to treat the link as a Drive-navigatable link when Turbo.session.drive = false and the link has no data-turbo="true" — so Turbo does NOT intercept the click or perform a visit. But the history popstate listener (HistoryDelegate.onPopStatehistoryPoppedWithEmptyStateview.cacheSnapshot()) is independent of that check and fires for any popstate, including the one the browser generates natively for same-document anchor navigation. This looks like an edge case in Turbo itself, but from lexxy's side, the fix would be to make the turbo:before-cache handling resilient to "no reconnect follows" rather than assuming one always will.

Suggested fix

In #handleTurboBeforeCache, rather than unconditionally calling #reset() (which tears down editorContentElement in place), consider:

  • Only resetting if the element is about to be actually removed from the DOM (e.g. defer the reset to disconnectedCallback, which already exists and already calls a version of this cleanup), or
  • After resetting, schedule a fallback check (e.g. on the next requestAnimationFrame or via a short-lived listener) that re-triggers connectedCallback if the element is still connected and never got reconnected — since #reset() firing without a subsequent unmount is by definition a state the element should recover from on its own instead of relying on the caller.

Environment

  • @37signals/lexxy: 0.9.23 (also checked 0.9.24's diff — no related change)
  • @hotwired/turbo-rails: ^8.0.23 (@hotwired/turbo 8.0.23)
  • Rails app, Turbo Drive explicitly disabled via Turbo.session.drive = false
  • Reproduced in Chrome (real, non-headless)

Workaround in use

We patched around it in our own JS, watching for editorContentElement being null after DOMContentLoaded and popstate and forcing a disconnectedCallback() + connectedCallback() cycle to recover the mount.

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