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
- Have
Turbo.session.drive = false set (Turbo Drive disabled for the page/layout).
- Render a
<lexxy-editor> on the page (e.g. via rich_text_area).
- Add any plain in-page anchor link, e.g.
<a href="#comments">...</a>, pointing to an id elsewhere on the same page.
- Load the page, confirm the editor renders normally.
- Click the anchor link (no full navigation happens — same URL, just a fragment change).
- The browser fires a native
popstate event for this same-page fragment navigation.
- Turbo's
HistoryDelegate.onPopState handles this popstate: since event.state.turbo is absent, it goes through historyPoppedWithEmptyState, which calls view.cacheSnapshot() → dispatches turbo:before-cache — even though Turbo Drive is disabled and no actual Turbo visit/render is happening.
lexxy-editor's listener for turbo:before-cache (registered in #resetBeforeTurboCaches) calls #reset(), which does:
this.editorContentElement?.remove();
this.editorContentElement = null;
- 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.
- 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.onPopState → historyPoppedWithEmptyState → view.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.
Summary
lexxy-editor's#handleTurboBeforeCacheresetseditorContentElementtonullonturbo:before-cache, assuming a newconnectedCallbackwill follow (as it would during a real Turbo Drive page morph/restore). Butturbo:before-cachecan 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
Turbo.session.drive = falseset (Turbo Drive disabled for the page/layout).<lexxy-editor>on the page (e.g. viarich_text_area).<a href="#comments">...</a>, pointing to an id elsewhere on the same page.popstateevent for this same-page fragment navigation.HistoryDelegate.onPopStatehandles thispopstate: sinceevent.state.turbois absent, it goes throughhistoryPoppedWithEmptyState, which callsview.cacheSnapshot()→ dispatchesturbo:before-cache— even though Turbo Drive is disabled and no actual Turbo visit/render is happening.lexxy-editor's listener forturbo:before-cache(registered in#resetBeforeTurboCaches) calls#reset(), which does:connectedCallbacknever runs again for this element.editorContentElementstaysnullforever. Only the toolbar (a separate custom element,lexxy-toolbar) remains visible; the contenteditable body is gone.document.querySelector('lexxy-editor').editorContentElementreturnsnullafter step 5, and manually callingelement.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
elementIsNavigatablecorrectly refuses to treat the link as a Drive-navigatable link whenTurbo.session.drive = falseand the link has nodata-turbo="true"— so Turbo does NOT intercept the click or perform a visit. But the historypopstatelistener (HistoryDelegate.onPopState→historyPoppedWithEmptyState→view.cacheSnapshot()) is independent of that check and fires for anypopstate, 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 theturbo:before-cachehandling resilient to "no reconnect follows" rather than assuming one always will.Suggested fix
In
#handleTurboBeforeCache, rather than unconditionally calling#reset()(which tears downeditorContentElementin place), consider:disconnectedCallback, which already exists and already calls a version of this cleanup), orrequestAnimationFrameor via a short-lived listener) that re-triggersconnectedCallbackif 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/turbo8.0.23)Turbo.session.drive = falseWorkaround in use
We patched around it in our own JS, watching for
editorContentElementbeingnullafterDOMContentLoadedandpopstateand forcing adisconnectedCallback()+connectedCallback()cycle to recover the mount.