Hello! There are moments in the lifecycle of a LiveView app that the client disconnects and briefly reconnects (e.g.: tab woke up from sleep, or app was redeployed causing a quick socket reconnection).
In those cases, there’s a very fast “flash” of the flash message (by default a red-colored flash message) that disappears before it could ever be read.
So I’m looking into ways to avoid this brief appearance of the disconnected state. Instead, I would like to wait for say 2 seconds and if we’re still offline then show the flash message.
This is the standard flash_group implementation in core components in a new app:
def flash_group(assigns) do
~H"""
<div id={@id}>
<.flash kind={:info} title={gettext("Success!")} flash={@flash} />
<.flash kind={:error} title={gettext("Error!")} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={show(".phx-client-error #client-error")}
phx-connected={hide("#client-error")}
hidden
>
<%= gettext("Attempting to reconnect") %>
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={show(".phx-server-error #server-error")}
phx-connected={hide("#server-error")}
hidden
>
<%= gettext("Hang in there while we get back on track") %>
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
</.flash>
</div>
"""
end
The show and hide helpers call JS.show and JS.hide.
Is there a way to delay a call to JS.show and perhaps cancel it if phx-connected fires in the meantime?
Do I need to resort to a Hook for greater client-side control of this behavior?
While trying this out I noticed the topbar library also has a delay mechanism built-in, and on a fresh Phoenix app it is configured to delay 300ms before showing the blue loading top bar. No surprise I was not the first one to think about this
I should have mentioned in the original post, I did try phx-debounce without success before, which I understand addresses a different use case.
The event + listener is clean and readable, thanks again for the suggestion!
Playing with this further I explored using CSS animation-delay. A one-word change in core_components.ex:
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 10300, # <-- animation duration + delay
transition:
{"transition-all transform ease-out duration-300 delay-[10s]", # <-- delay added here
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
Note I needed to account for the delay in the time option for JS.show otherwise the animation is cut short.
The above does delay showing the error message when I call liveSocket.disconnect() on the browser console, but unfortunately after calling liveSocket.connect() the animation is not interrupted, so bringing back the effect of a quick flash of the flash message (just this time after a long delay).
Maybe this is a dead-end, but I wanted to document here anyway. I seem to be able to “cancel” the JS.show calling liveSocket.transitions.reset() but with a side-effect that next time I disconnect the flash message appears immediately.
I found a simpler solution that seems to work as intended. It consists of simply adding animation-delay to the appropriate element permanently and unrelated to the transition classes used in show/2, no JavaScript involved.
Update the CoreComponents.flash/1 component to take extra classes:
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
+
+ attr :extra_classes, :string,
+ default: nil,
+ doc: "the extra CSS classes to add to the flash container"
+
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
I sure can. Would you like this delay to be the default experience?
Can we assume Tailwind (I guess so, since all components rely on it)?
I like the delay option. While I still customize the CSS in my projects, solving for that use case is orthogonal and would better apply to all core components, making them easier to modify.
Would you have a preference for "3s" vs 3000? Many APIs represent durations in ms, I need to check what’s the case for relatable code.
Note that tailwind won’t be able to “see” whatever we interpolate, so it will either need to be <.flash delay> as a boolean (which gets my vote), and hardcoded as something (3s seems a bit too long imo, but whatever is fine), or we’d need to configure tailwind to always generate the delay class(es)
Before we bring this to everyone, there are a few aspects to iron out.
Delay show vs delay hide
While my late suggestion in Delaying LiveView.JS commands (avoid quick flash of "Trying to reconnect") - #5 by rhcarvalho works in the sense that it delays the show, it does have one downside which is it also delays the hide. Unlike for show, the observable hide behavior is that the flash gets hidden before the transition runs, so for me that was a bearable tradeoff. But it is kind of a bug.
I remember this being a problem, and Delaying LiveView.JS commands (avoid quick flash of "Trying to reconnect") - #4 by rhcarvalho is there to confirm, as unfortunately adding the delay class to the JS.show transition doesn’t work because either the class gets removed too early or it requires setting a longer time which prevents cancelling in-flight animations and so on.
Where does delay belong
I went ahead to update flash/1 with the proposed delay attr, but it feels out of place. The flash component doesn’t concern itself with showing or hiding, instead that logic leaves in flash_group/1, which calls show/2 & hide/2 helpers, wrapping JS.show/2 and JS.hide/2 respectively.
If the delay will be off by default, then a delay of 300ms would match the default used to topbar in app.js. The class delay-300 comes standard in Tailwind Transition Delay - Tailwind CSS.
(sorry for the early accidental send while composing the message)
José and Chris, would you be open to consider changing JS.showPhoenix.LiveView.JS — Phoenix LiveView v0.20.17 (and potentially a matching change to JS.hide) to address delays in JavaScript?
I think it would be beneficial if this concept of delays was “global”, as in not just related to the flashes. For example, with phx-disable-with, it’d be great to have this also on a delayed timer. Only swap the contents if the item is disabled for more than a certain amount of time (but still disable the button). I think this would also help prevent unwanted flashes of content when an action processes very quickly.
There’s a related issue with using LiveView apps on mobile that these solutions don’t address.
Scenario:
LiveView-based web app is loaded in mobile browser (or embedded web view in a mobile app)
User backgrounds the browser or mobile app
After some time (depends on OS and a number of factors, but say at least a minute or so), user returns to browser or app
Assuming that the OS hasn’t unloaded the web page entirely, what you’ll usually see is a quick flash of “trying to reconnect”. The delay doesn’t help (it actually makes it worse because it also delays the hide); I think what’s happening is that the browser is killing the tab’s networking, which causes the “trying to reconnect” flash to render (while the tab isn’t visible to the user), and when you come back later, it’s already on screen.
I think the Page Visibility API can help here. LiveView already has bindings for blur and focus; it’s reasonable to consider including built-in bindings for Document.visibilityState (and changing the logic to only show the flash if the page is visible, and the socket is disconnected).
FWIW, my solution for now is much less complicated: simply make this particular flash look different than the other flashes (just a loading spinner, with “Connecting…” text), and neutral colours that don’t suggest any sort of error:
# A custom flash notice for communicating that the client has disconnected.
def flash(%{id: "client-error"} = assigns) do
~H"""
<div
id={@id}
role="alert"
class="fixed top-2 right-2 z-50 mt-1 mr-2 w-fit rounded-md bg-slate-300 p-3 text-slate-900 shadow-md"
{@rest}
>
<p class="flex items-center gap-1.5 text-sm leading-6">
<span class="font-semibold">
<.icon name="hero-arrow-path" class="mr-2 h-5 w-5 animate-spin" />
<%= gettext("Connecting...") %>
</span>
</p>
</div>
"""