Phoenix-style LiveView

#1

I saw a while back that Phoenix has a Live View module for building rich interactions for fully server-rendered apps — that is, the interactions are passed to the server, the server renders updates for the UI, and the client reconciles a vDOM for it.

I wanted to see how easy or difficult this would be in Crystal, and the result is LiveView, a shard that provides an abstract class that lets you do pretty much the same thing in a Crystal app. I’ve managed to make it framework-agnostic, so you render an instance of your subclass into your HTML to make it work. Rendering LiveView.javascript_tag into your application’s layout template wires everything up.

I’ve got a demo app running here: https://crystal-live-view-example.herokuapp.com/ It shows simple things like a click counter, true/false toggle based on checkbox state, recurring UI updates with a ticking clock, and some more intermediate examples like autocomplete and deferred data loading. The code for that example app is here.

All this happens with only 6KB in JS. Even Phoenix’s LiveView uses 29KB. I’m curious to see what you think.

3 Likes

#2

I liked the idea some time ago at https://github.com/bcardiff/redomi and not far along I used to build https://github.com/bcardiff/crystal-ast-helper .

Of course there are pros and cons on the approach. Some might be

pro) the programmer might be able to know less of client side things
pro) tooling like the one available in ASP.NET Webforms can be built to support RAD
cons) the app state and progress can be more fragile. The server might need to track lots of things even temporarily. The more coupled things are the less resiliant might be (exceptions in the even loop of the server vs an ajax request that didn’t work but the user can keep using the page)

1 Like

#3

This looks awesome to me, though I don’t really understand how to use it from the readme.
What does this do?: LiveView.script_tag, how would I incorporate other views?

However that may be, I like what I’m seeing.

0 Likes

#4

LiveView.javascript_tag is what you put on the layout template to load the JS assets required to wire everything up. It opens a websocket connection to the server to carry the events and UI updates for views that you’ve rendered.

To render your individual views (you can define them like the examples here), you render them into your ECR templates (I imagine Slang templates would be similar). Let’s say you’ve got a view that shows the current time:

require "live_view"
class CurrentTime < LiveView
  render "The current time is <time>#{Time.now}</time>"

  def mount(socket)
    # Set up a recurring UI update every second to ensure we always render the current time
    every(1.second) { update socket }
  end
end
<!doctype html>
<html>
  <head>
    <!-- snip -->
  </head>

  <body>
    <!--
      This might be inside a child route or something, but I'm
      putting it here to have fewer examples :-D 
    -->
    <%= CurrentTime.new %>

    <%= LiveView.javascript_tag %>
  </body>
</html>

You can have as many views as you like in a single HTML payload, so they’re more like UI components than a Rails-style view. The view generates an identifier that the client will use to subscribe to it. A client can subscribe to an indefinite number of views.

Currently that identifier is just a UUID. I’d like to figure out how to incorporate specifying the identifier so you can do things like update all views of a given type for a given entity. For example, if your app is for selling concert tickets, you may want to have live updates for every user looking at the page for the same concert in case they sell out before they click the “Buy” button.

1 Like

#5

Those projects look really interesting. I think we have different goals in mind, though.

I agree 100%, there are a lot of ways this can fall down. It’s common where websockets are in play to have a temporary disconnect due to a spotty internet connection, so resilient reconnects need to be supported.

Other longer-term disconnection scenarios are even more common:

  • user closes their laptop
  • user puts their phone on standby
  • browser puts the tab to sleep to save device battery

We don’t know when they’ll be back, so we have to put an upper limit on how long we’ll hold that UI state in memory in the app. :-) One workaround might be to refresh the page if they come back and their connected views have been GCed.

It also relies on the idea that the websocket connects to the same process that the original request connected to, so it breaks when used with the fork + reuse_port pattern. Some load balancers (including nginx) support passing a given client to the same upstream, so there are at least some decent workarounds for this.

There are a lot of ways to improve it. I’m gonna be keeping an eye on how Phoenix LiveView does some things to see how they handle some of the failure scenarios.

0 Likes

#6

@jgaskins What do you think about the LiveEEx part of LiveView? Is it really even necessary?

BTW, I was about to start a similar effort to this myself, so I’m glad you started it!

1 Like

#7

It might be for Phoenix.LiveView. They calculate the DOM diff on the server side to send the absolute smallest payload over the wire as possible. To accomplish that, LiveEEx is probably needed as the template engine since you need to change the format of the template output, so they use the file extension that generates that instead of the one that generates HTML.

Whether it was worth the effort for Phoenix to do it that way, I’m not sure. It’s certainly a cool optimization but it’s a lot of effort to create and maintain, so this implementation sends raw HTML and builds the Preact render tree for it on the client.

0 Likes

#8

@jgaskins, I just tried the example. I must say, this is like something out of my wildest crystal shard dreams, seriously. I’ve been wanting to play with some interactive web apps written in Crystal, not JavaScript. This is it, this fits my needs perfectly. Thanks, now, unto the hacking ─ I’ve next to no experience with web apps, so the result’ll probably be bad because of me, but I’ll share it when I’ve got something. :grin:

1 Like

#9

That’s high praise! Thank you!

I tried to make it super easy to work with, but I also realize I have an unfair amount of insight into it. :smile: So if you have any trouble with it, feel free to log an issue and we’ll try to figure out a good way forward.

1 Like