LiveView and Rolling Restarts

One thing I’ve wondered about since LiveView is announced is how well it can work in an environment where continuous deployment and rolling restarts are used. At work we don’t have fully automated continuous deployment but in practice we usually deploy as soon as changes are merged to master and a new container is built, often several times a day. In a larger team I could easily see many deployments per day. These events do not impact users today at all, whether you are using an SPA or a traditional server-rendered application.

In LiveView, if a user is in the middle of editing a form when new code deploys, they are going to lose all the contents of the form they’ve entered by default as their current connection disconnects and they connect to a new server which calls mount again. We could go to heroic lengths to prevent such tragedies via server-side strategies including hot reloading or stashing the state somewhere, but that introduces significant new complexity to the app. Also, at least as far as forms are concerned, it is unnecessary since we already have a copy of the state we care about (what the user has typed in) in the client.

Does anyone know if there any plans on the horizon to stash/restore form input state on disconnect/reconnect events on the client side? I expect at some point there will be a lifecycle API so that you could manually do this yourself with client-side code. But it seems like the requirements of basic form state would be pretty common across applications.

10 Likes

Is this true? I got the impression that morphdom was configured to not eliminate form contents. Maybe you’re running into a bug for this specific thing?

However, the general point stands. If there are items on the screen that you hold in the live view state, a deploy will eliminate that state and the page will look weird. Some of this can be handled by pushing state into the params, but it’d be nice if there was maybe a local storage API? If that worked across pages then it’d be rather complicated though.

1 Like

I think it does not replace input contents when it receives updates in a reply message, but it does something different on reconnect/mount. But yes maybe that is just a bug, that would be great if it can simply be solved at that level. I can recreate it using the User edit page in the example repo. I did do a mix deps.update phoenix_live_view to confirm its happening on master.

I think if forms are handled in a general way, other kinds of wierdness could probably be handled by either using query params or maybe some custom client code using life-cycle hooks (I understand they plan to implement those). If the client has a way to capture arbitrary state at the disconnect event, it could send a message at reconnect to help restore that state for example.

Another option would be to fallback to HTML controllers on disconnect. This would even work in the form example; you’d lose your live validations but it would be nice if you could prevent a disconnect from disabling the UI. Then all you have to do is implement a controller for the PUT/POST route. It already works that way if you disable Javascript and implement that one controller method, but on disconnect you get to watch the spinning ball without recourse.

2 Likes

Ah yes good point I was thinking of general page updates not reconnections, that is indeed an issue.

This is my least favorite option, because 1) it just straight up breaks forms that rely on phx_submit and 2) suddenly every page I need to be thinking about two entirely different modes of operation, connected and disconnected. In a sense I already do but with the current behaviour I can treat disconnected as “the page is busted please reload” since the only situations it happens are when the client has no internet or my server is down. If we go with the fallback option the page is expected to continue to be useful, and that complicates things a lot.

With forms specifically I think it’s probably going to be necessary to special case what morphdom does after a reconnection. If we try to handle forms based on generic state, the state of the form would need to be persisted client side on every key stroke / form action. Even if this works, rebuilding the form with new DOM elements and then updating their state based on cached client state still seems like it would cause a stutter, particularly if someone is actively typing into a text box for example.

With other page state though the problem remains, and it’s there where tracking some kind of client state would be necessary. This is actually more achievable today than the form contents issue I think because on the server side you could shove state into something horde. Not particularly scalable since in addition to having client connections gallop from one node to another you have to transfer all of their state, but it’d probably work at small scale.

Yes its not very scalable unless you introduce a dedicated backing store and if you think about the issues involved in doing it reliably I think you’ll realize its harder than supporting hot upgrades, because you still have to solve the state versioning/migration issue. This is why I think doing as much on the client as possible is the better course.

1 Like

All of these come with state migration challenges right? If you do hot upgrades you’ve got to migrate the entire live view process state. Both server side and client side states also need to be handled. The plus side of the server side and hot upgrade states is that you can be reasonably guaranteed that when your server boots at version K the available states are just K and K-1. Client side state can be basically anything if someone turns off their wifi for a week. Practically speaking though you could introduce limits that support K and K-1 states, while forcing a refresh for anyone else. Client side state is also the scenario where you’re most likely to push the minimal amount of state given the added complexity.

1 Like

This is true but often the client state may be simpler. The live component may have quite a lot its keeping track of for a menu component for example, but all the client cares about is that the drawer was opened or closed. If the client can just send that one state field that it cares about on reconnect, then the server can incorporate it into the new state and the user won’t perceive any difference.

Agreed. It’s also worth recognizing I think that in the case of a rolling deploy these shutdowns are supposedly graceful, which is important because the socket could, on terminate, could do a hand shake with the front end that the state is all synced up, which would ensure a smooth transition on reconnect.

For now, for simple form state (no text fields) I may just sync it up to the url params in base64 encoded form…

1 Like

Duping what I posted on the phoenix issue for posterity:

Form recovery is supported with js hooks, but is not yet automatic. Automatic recovery is on the roadmap, but we will have to have the client check to ensure all its stashed inputs match the latest rendered state from the server. So in some cases for multi-step or heavily dynamic forms, auto recovery won’t work, but for the basic cases we can have the client automatically recover. In the meantime and for advanced forms, you can annotate your form with phx-hook="SavedForm", then define a JS hook which stashes the form state and passes it back up via connect params, for example:

# <%= f = form_for @changeset, "#", phx_hook: "SavedForm", phx_change: :validate, phx_submit: :save %>

def mount(_session, socket) do
  changeset =
    case get_connect_params(socket) do
      %{"stashed_form" => encoded} ->
        %User{}
        |> Accounts.change_user(Plug.Conn.Query.decode(encoded)["user"])
        |> Map.put(:action, :insert)

      _ ->
        Accounts.change_user(%User{})
    end

  {:ok, assign(socket, changeset: changeset)}
end
let serializeForm = (form) => {
  let formData = new FormData(form)
  let params = new URLSearchParams()
  for(let [key, val] of formData.entries()){ params.append(key, val) }

  return params.toString()
}

let Params = {
  data: {},
  set(namespace, key, val){
    if(!this.data[namespace]){ this.data[namespace] = {}}
    this.data[namespace][key] = val
  },
  get(namespace){ return this.data[namespace] || {} }
}

Hooks.SavedForm = {
  mounted(){
    this.el.addEventListener("input", e => {
      Params.set(this.viewName, "stashed_form", serializeForm(this.el))
    })
  }
}

let socket = new LiveSocket("/live", {hooks: Hooks, params: (view) => Params.get(view)})
socket.connect()

Note that the above url encodes the form, which requires decoding it on the server. You could write more JS to serialize the form as json to avoid this step :slight_smile:

11 Likes