Are there elegant ways to sub/unsub when user navigated between routers in same liveview?

I can only agree with you on this claim so many times lol. A further point in your favor here: due to consistency problems with PubSub, you are going to have to filter the messages anyway! This does not, however, contradict my points in any way.

Yes! And unless the number of topics happens to be exactly one, an uncommon case, we have invented the OP’s question from first principles.

Okay, imagine you have literally any other component on the page. A search bar, a sidebar with filters, literally anything at all. You click on another product and the state of every other control on the page gets reset. This is a common annoyance of web applications.

LiveView is useful specifically because it can handle this, in Elixir, with no client rendering. That is the value proposition!

What is frustrating to me here is that the OP has the right idea, and is doing the right thing, and all of you are telling them “you’re holding it wrong”. They are not!

As far as I know, your intuition is right, implement conditional deregistration and registration as part of handle_params/3 (possibly extracted into a defp helper.)

If you don’t already, store the current product ID in an assign. You can assign it to nil on mount/3, so that in handle_params/3 you can easily distinguish if you just started and there’s no state to clean up, or if you navigated between products. Assign the current product ID in the handle_params/3 callback.

Don’t forget to check the current product ID in your handle_info/2 callback, and discard messages unrelated to the current product.

4 Likes

I have not read this thread completely but I did a search for the word “assign” and I don’t see anyone talking about modelling this.

I have done this sub/unsub thing before as a user patches between pages. It was not particularly elegant but it was simple.

For example, you might have an assign called :product_topics and maybe other assigns for other kinds of pubsub topics. In handle params, or wherever you assign the current product, unsubscribe from all topics in :product_topics and then create a new product_topics assign and subscribe to all those topics.

Do not listen to people telling you “should” or “should not” patch/navigate/redirect between pages. There is no absolute correct way here. It all depends on the reality of the situation, on the volume of the data.

TL;DR use assigns to “save” currently subscribed pubsub topics and work with these in handle_params.

3 Likes

What you and the above reply are suggesting is to write a reconciler by hand. I gave a code example of this way up in reply 7, though I understand that it’s been kinda buried.

The problem, which I tried to explain at the time (but maybe not well enough?), is that this does not work in general in LiveView because there is no unmount.

As a simple example, say you have a live chat component that you want to embed into your LiveView. When the user opens it, it subscribes to the topic. LiveComponents cannot receive messages on their own but you can fix this with a router (see reply 3).

The problem is that when the surrounding LiveView decides to close the chat component, i.e. remove it from the page, it cannot unsubscribe from the resource because there is no unmount callback for it to use.

So the reconciler that all three of us have suggested here is incomplete. It is two thirds of a reconciler. It can mount and update but not unmount. This is not acceptable.

The reason you can get away with it in the simple case is luck: it just so happens that a root LiveView always replaces itself. This convenience does not generalize to a real application. Once you add more functionality to your app you will need components, and when that need comes the solution will break down.

One thing I’ve been wondering is how PhoenixSync handles this. I had a look at the code and the router is there, so you can indeed subscribe to a stream from a component. But where is the unsubscribe? I could be wrong, but I don’t see it. It kinda looks to me like the subscriptions are just held open forever when the components unmount.

LiveView needs a real solution to this problem. I will say again: if anyone actually has a solution, please post it. I do not.

It’s not as if there is no way to tell that the chat component was closed.

1 Like

There is no way (that I know of!) to tell that the chat component was unmounted. Knowing that it was closed implies that you must keep track of all possible codepaths which will lead to the component being removed from the page. The advantage of declarative frameworks like React or LiveView is that you can write code without thinking about such things and therefore avoid bugs in complex codebases.

This is hard to teach/explain because it’s not a problem that shows up in trivial examples. It’s a problem that shows up in real apps, once the size of the codebase increases a couple orders of magnitude (as real codebases do).

To extend the example, say the chat box has a close button and you sprinkle some logic in there to unsubscribe from the topic. Then your app grows and you end up putting the chat box behind a tab bar that mounts/unmounts it.

<div class="tabs">
  <div :if={@tab == "chat"}><.live_component module={Components.Chat} /></div>
  <div :if={@tab == "something"}>Something else</div>
</div>

defmodule TabComponent do
  def handle_event("set_tab", %{"tab" => tab}, socket) do
    {:noreply, assign(socket, :tab, tab)}
  end
end

How do you handle this? The tab bar has no concept of chat boxes. Do you add code to set_tab that runs the chat unsubscribe logic?

It’s much better for the Chat component to be able to declare, “if I am unmounted the topic comes with me”. That way, all of the code higher up in the tree does not need to understand what the Chat component does.

I hope that this makes sense, but if not then please let me know.

Well don’t put it there then. I don’t think hidden behind a tab is where in the DOM I would put a chat widget that I presumably want to maintain state for the length of the user’s visit.

It makes sense. I don’t think other people get so hung up on it though. See the constraints and work within them. It is more productive than seeing the constraints and doing little other than wishing they weren’t there. Considering how well versed you are in this topic, can’t you come up with a solution that works?

I think we all get your point that there is no unmount. Armed with that knowledge, how can you design things that work within the constraints of the system?

The purpose of this example is to communicate the problem to you. I understand that there are situations where you would keep the component in the DOM, but there are also situations where you would not, and you can substitute those.

Sometimes things have workarounds, but I’m just not aware of a way to work around this particular issue. Honestly I was kinda hoping someone would pop by and say “actually, you fool, you can do XYZ”.

I provided a code example to reconcile subscriptions nearly a week ago, but I provided it with what I feel are the appropriate caveats. Namely, that it will leak the subscription when the component unmounts. I feel it would be negligent of me not to mention this.

You speak of constraints, but it’s hard not to notice that there are many frameworks for which I could provide a working implementation.

As an aside, one idea I had was to use phx-remove (a client-side unmount event) to notify the component that it’s being unmounted, but this has obvious consistency issues. For one the component may already be gone, and for another it is actually very important in some cases that the old component be unmounted before the new one is mounted.

I believe any trickery with JS hooks would be similarly unworkable.

AFAIK all of these JS frameworks give you “unmount” lifecycle hooks on the client side, not on the server. They are all fundamentally different from LiveView’s operation model.

Yet DOM patching happens client side. You can’t know a component will be unmounted server side without application knowledge, e.g. by knowing a certain assign affects the presence of a certain part of the HTML template.

That’s because it’s on the client that the decision is made whether a node moved across the DOM tree, a node content got replaced and the container is reused (i.e. no phx-remove event), or a DOM node is actually removed.

On the client side LiveView provides at least two ways to hook into the lifecycle of arbitrary DOM nodes (that don’t need to match 1:1 to any server side idea of “component”), in particular related to unmount: phx-remove and client hooks destroyed callback.

Note that the server side idea of a component (be it a function component or LiveComponent) is detached from the lifecycle of client-side DOM nodes, and they are not mapped 1:1.


The OP doesn’t mention using LiveComponent, so bringing back to elegant ways of managing state on the server side lifecycle, what I’ve seen and done in cases like that is use explicit functions called from the appropriate LiveView callbacks:

For route changes in the same LV it’s handle_params. We could also have code in handle_event, for instance when form changes (e.g. filters) affect the state of the LV. Imagine a filter on the client would affect the list of desired PubSub subscriptions. I’ve given an example of handle_eventpush_patchhandle_params in another thread, which demonstrates how to react to arbitrary page events, update/store state in the URL and re-render keeping everything consistently in sync.

(The same idea can be used to manage PubSub subscriptions in response to arbitrary events keeping state in the URL in sync)

-–

We’re beyond the scope of the OP :slight_smile:

2 Likes

LiveView happens to be running on the server, but its operational model (from the perspective of the user) is roughly the same as React et al. LiveView is stateful on the server, and components release their resources (mostly through GC) on the server. That is why the lifecycle events (mount/1, update/2) also happen on the server.

As a simple thought experiment, ignoring backwards compatibility would you make the same point in favor of removing update/2 from LiveComponent?

I was vaguely aware of this from reading the code, but I’ve never seen it explicitly mentioned anywhere so thank you for the explanation.

And you make a very important point. If the information from the client is delayed, we run into the same consistency issue where unmount is run for a component after its replacement is mounted. The problems this can cause are subtle, but it could lead to e.g. the old component unsubscribing from the topic that the new component just subscribed to. It would be better not to have to worry about such things.

But do you not see any way around this? I think you know the code better than I do, but it seems to me like a simple MapSet of ids on the server would be enough to fix this. The server does actually have all of the information needed to know which components are live; it rendered them to begin with!

Components are obviously on-topic (everyone will have to use them at some point), but if we’re talking about implementation details then I agree. It seems like my point is now understood, so I’m satisfied with that. It was not really my intent to advocate for change.

Unfortunately this appears to be wrong. I think in order to make this glitch-free you need to perform reconciliation locally. Because LV can teleport components you never know if a component will be remounted far away in the render cycle, so while you can fix this in most cases you can never provide a guarantee.

Perhaps this is what you were getting at? If so I just didn’t understand. This is actually a very interesting insight, as I thought the teleportation behavior was a neat trick but it would seem it’s actually a bit problematic. I’ll have to keep that in mind.

Unfortunately that means the glitchy behavior is the best we can do. But on the bright side it’s still better than nothing and I think we can hack together a working implementation in userspace…

defmodule Root do
  def handle_event("invoke", %{"id" => id}, socket),
    do: {:noreply, invoke(socket, id)}

  def handle_info({:register, id, callback}, socket) do
    callbacks = Map.put(socket.assigns.callbacks, id, callback)
    {:noreply, assign(socket, :callbacks, callbacks)}
  end

  def handle_info({:unregister, id}), socket),
    do: {:noreply, invoke(socket, id)}

  defp invoke(socket, id) do
    {callback, callbacks} = Map.pop!(socket.assigns.callbacks, id)
    callback.()
    assign(socket, :callbacks, callbacks)
  end
end
defmodule Component do
  def update(%{"product_id" => new_pid}, socket) do
    if (socket.assigns[:product_id]) != new_pid do
      if cid = socket.assigns.cid, do: send self(), {:invoke, cid}
      cid = to_string(System.unique_integer())
      send self(), {:register, cid, fn -> unsubscribe(new_pid) end}
      subscribe(new_pid)
      {:ok, assign(socket, product_id: new_pid, cid: cid)}
    else
      {:ok, socket}
    end
  end

  def render(assigns) do
    ~H"""<div id={@id} phx-remove={JS.push_event("invoke", %{id: @cid})} />"""
  end
end

That is the most elegant general solution I can think of. It’s certainly not as idiomatic as an unmount callback could be, but given the above insights it does appear to be functionally the same!

1 Like