hotwired / turbo

The speed of a single-page web application without having to write any JavaScript
https://turbo.hotwired.dev
MIT License
6.54k stars 415 forks source link

Occasional disconnect, no documented way to monitor or reconnect #1261

Open krschacht opened 1 month ago

krschacht commented 1 month ago

I can't figure out how to monitor for a websocket disconnection (presumably an event I can listen to) so I can notify the end user.

In my production app, my users occasionally end up in a situation where they miss a turbo stream update. I'm almost certain this is due to flaky internet connection so that the websocket gets dropped. I've seen it happen to me occasionally.

I've been digging through the turbo repo, turbo rails, and actioncable, and reviewing the public docs for all three and I can't find any mention of a disconnect event.

Do we need to simply add this to public docs? Or does one not exist and we need a PR to add one?

krschacht commented 1 month ago

I can't even figure out the relationship between Turbo and ActionCable. I know Turbo uses ActionCable, and I had to edit cable.yml as part of my original configuration. But notably, when I view source for my app there is no @rails/actioncable and so when I try to follow this guide for some potential debugging ideas:

import * as ActionCable from '@rails/actioncable'

ActionCable.logger.enabled = true

From: https://guides.rubyonrails.org/action_cable_overview.html#client-side-logging

That importmap reference doesn't exist.

krschacht commented 1 month ago

Poking around the internals I've managed to wire up a poll interval. In my application.js I simply changed the turbo-rails import and added a little code:

import { cable } from "@hotwired/turbo-rails"

window.isConnected = false
window.cable = cable
window.consumer = await cable.createConsumer()
setInterval(() => {
  if (consumer.connection.isOpen() != window.isConnected) {
    window.isConnected = consumer.connection.isOpen()
    console.log(`cable ${window.isConnected ? 'connected' : 'DISCONNECTED'}`)
  }
}, 500)
window.consumer.connection.open()

I don't quite understand what it's doing. I'm creating a new consumer, but this must be working over the existing cable connection. Clearly this isn't the consumer that Turbo is using, but I think my creating a new one which I have access to then I can check the status of it. I still haven't been able to find any event I can listen to, but I'm going to proceed with this hack for now.

leonvogt commented 1 month ago

Are you using ActionCable itself or something like the turbo_stream_from helper from the turbo-rails gem?
With the latter, you could monitor the connected attribute of the turbo-cable-stream-source element.
As soon as the connection gets lost, the connected attribute will get removed.

<turbo-cable-stream-source channel="Turbo::StreamsChannel" signed-stream-name="..." connected></turbo-cable-stream-source>

In my experience, the websocket connection gets regularly lost, even with a stable internet connection.
I have a stimulus controller which monitors the connected attributes and performs a turbo refresh -/ morph action, under certain conditions.

krschacht commented 1 month ago

@leonvogt Yes, I'm using turbo_stream_from from the turbo-rails gem. That's an interesting solution, thanks for sharing! I never noticed that tag in the rendered HTML. I need to go looking for that. I suspect the polling code (that I shared above) accomplishes a very similar thing, but your approach is a bit more elegant.

Can you share more about what conditions you preform a turbo refresh / morph and how you trigger that with javascript? That's exactly what I need to implement next.

Here is my motivation, just for more context. My app needs to support users remaining connected for potentially days in a row. The browser may be backgrounded & re-foregrounded, the computer may even go to sleep and come out of sleep. And I want the websocket/stream to be smart enough to manage itself during all of this. Currently, it does not. When I leave my computer unattended for one night and come back the next day, the front-end interactions are no longer working. I have to do a hard refresh of the page to get things working again — that's what I'm trying to solve.

leonvogt commented 1 month ago

Sure, my current Stimulus controller looks like this:

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="ws-status"
export default class extends Controller {
  static targets = ["streamElement", "onlineIndicator", "offlineIndicator"]
  static values = {
    autoStart: { type: Boolean, default: true },
    autoRefresh: { type: Boolean, default: true },
    interval: { type: Number, default: 1000 }
  }

  connect() {
    if (this.autoStartValue) {
      this.start()
    }
  }

  disconnect() {
    this.stop()
  }

  start() {
    if (!this.hasStreamElementTarget) return;
    if (!this.hasOnlineIndicatorTarget) return;

    this.interval = setInterval(() => {
      if (this.isOnline) {
        const wasOffline = this.onlineIndicatorTarget.classList.contains('d-none')

        this.onlineIndicatorTarget.classList.remove('d-none')
        this.offlineIndicatorTarget.classList.add('d-none')

        if (wasOffline && this.autoRefreshValue) {
          Turbo.session.refresh(location.href);
        }
      } else {
        this.onlineIndicatorTarget.classList.add('d-none')
        this.offlineIndicatorTarget.classList.remove('d-none')
      }
    }, this.intervalValue)
  }

  stop() {
    clearInterval(this.interval)
  }

  get isOnline() {
    return this.streamElementTargets.every(element => element.hasAttribute('connected'))
  }
}

And can be used like this:

<div class="online-status-indication" data-controller="ws-status">
  <%= turbo_stream_from "dashboard", Current.customer.root.id, data: { ws_status_target: "streamElement", turbo_permanent: true } %>
  <div data-ws-status-target="onlineIndicator">
    <small class="text-muted"><%= t("dashboard.status_online") %></small>
    <%= icon('fas', 'circle', class: 'text-success') %>
  </div>

  <div class="d-none" data-ws-status-target="offlineIndicator">
    <small class="text-muted"><%= t("dashboard.status_offline") %></small>
    <%= icon('fas', 'circle', class: 'text-danger') %>
    <%= link_to dashboard_path, class: "btn btn-outline-secondary btn-sm" do %>
      <%= icon('fas', 'sync', class: 'me-2') %>
      <%= t('dashboard.refresh') %>
    <% end %>
  </div>
</div>

Please note that this is a quite simple approach and I'm not sure if it fits your described use case.
I guess a cleaner solution would be to use a MutationObserver for monitoring the attribute, instead of the interval approach.