Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Request for feedback: Format rendering override public API #3631

Open
bcardarella opened this issue Jan 11, 2025 · 46 comments
Open

Request for feedback: Format rendering override public API #3631

bcardarella opened this issue Jan 11, 2025 · 46 comments

Comments

@bcardarella
Copy link
Contributor

I'm exploring the idea of producing JSON documents instead of markup and the LV diffs would be JSON Patches. I would like to get feedback on the best place to look at experimenting with this locally before I make a more concrete ask or PR with public API.

From some investigation this https://github.com/phoenixframework/phoenix_live_view/blob/main/lib/phoenix_live_view/static.ex#L286-L290 appears to be the correct place where I could override the template rendering. The idea at the moment would be to register a rendering override for a given format. If no override is detected, fallback to the existing default. This would allow API to remain intact, it doesn't change html rendering.

I'm soliciting feedback if this is the most straight forward approach or not.

@chrismccord
Copy link
Member

I'm not sure how this would work and would probably require major deviations in our diffing engine. Unless you're just wanting to keep the server bits json unaware and have the client treat it as raw statcis + dynamics that it zips together, then JSON parses, and computes a diff? If you're wanting streaming JSON diffs, where the server sends diff patches containing the key space and such, I don't think we can marry things up. If you're wanting some kind of .json.heex that just produces the same thing as HTML it could be made to work but there's escaping rules and a bunch of things probably that I'm not thinkingg about so I'm not sure if it makes sense. Can you explain a bit more what you're going for on the server and client?

Also Have you taken a look at https://github.com/hansihe/live_data or https://hex.pm/packages/live_state yet? How do those compare to what you're after? Thanks!

@bcardarella
Copy link
Contributor Author

The difference between the other two projects is unifying the effort under LiveView as the state management backend. This allows for a single state and event handling framework for clients, if you already have an existing LiveView application then adding JSON rendering for specific clients is just a matter of adding the renderer.

As far as the how, in this case JSON diffing won't use Phoenix.LiveView.Diff.render/3.

@bcardarella
Copy link
Contributor Author

bcardarella commented Jan 11, 2025

In my spike, I'm changing the to_rendered_content_tag/4 function to:

defp to_rendered_content_tag(socket, tag, view, attrs) do
  case Map.fetch(socket.private, :renderer) do
    {:ok, func} ->
      func.(socket, tag, view, attrs) 
    :error -> 
      rendered = Phoenix.LiveView.Renderer.to_rendered(socket, view)
      {_, diff, _} = Diff.render(socket, rendered, Diff.new_components())
      content_tag(tag, attrs, Diff.to_iodata(diff))
  end
end

@josevalim
Copy link
Member

Hi @bcardarella!

Your spike only changes the dead render. I am afraid that, in order to change the live render, it is quite more complex as the diff engine keeps its own state (so it knows what to send to the client) and interacts with several parts of the lifecycle, especially component management. So I can think of two high-level options:

  1. Work on the interaction between Channel and Diff and define a subset of the API. I expect it to be several functions.

  2. Still have diff do all of the work in managing events and components, but change how the rendered structures are traversed, starting here. This may make more sense, because JSON rendering most likely won't render Phoenix.LiveView.Rendered, which is tailored to HTML. There is one very large benefit in doing this, as we could be able to decouple LiveView from HEEx (HEEx could be its own project!) but it is very hard to draw a contract in practice because of all of the optimizations that we do and that layer is familiar of components, streams, and much more.

In both cases, figuring out what is part of Diff and what is part of the new API would certainly be lots of work. Diff is pretty much the heart of LiveView.

@bcardarella
Copy link
Contributor Author

bcardarella commented Jan 11, 2025

@josevalim yes, I'm just piece-mealing as I go. So pointers in the right direction are always appreciated.

@SteffenDE
Copy link
Collaborator

I'm interested in your vision of how this would be used from an application point of view.

This allows for a single state and event handling framework for clients, if you already have an existing LiveView application then adding JSON rendering for specific clients is just a matter of adding the renderer.

Are we talking about web applications where some pages are LiveViews, but others are managed by a frontend framework like React? Or would it be more of a full fletched SPA with LiveView as the state layer? How do you imagine things like live navigation to work? So if you can clarify a little bit how the lifecycle of such an application would look like, that would be very helpful. As an example (completely made up):

  1. A web client requests myfancyliveviewjson.example, which is a static SPA with the "LiveView JSON" client
  2. The browser loads the JavaScript bundle, which opens up a Phoenix Channel to backend.myfancyliveviewjson.example/socket
  3. The browser joins the "Phoenix.LiveViewJSON.Channel" sending the initial route "https://myfancyliveviewjson.example/", which is matched with a route definition, etc. -> initial JSON is sent, client hydrates its local state
  4. Events are pushed through a pushEvent like API, causing the server to update state, JSON diffs are sent -> Client updates state as well.
  5. Routes are duplicated in the SPA's router and the backend, so when a nav link is clicked, the client loads the new view and automatically leaves the old and joins the new channel, etc.

Again, this is just one of many ways I could imagine this to work and probably not the one you are thinking about.

Depending on how many LiveView features are actually useful for such an application, I am wondering if starting from scratch with Phoenix Channels and a custom client would be easier?

@bcardarella
Copy link
Contributor Author

I don't see how starting from scratch is a viable recommendation.

@josevalim
Copy link
Member

You asked for pointers in the right direction and, given we lack all context, the best we can do is to list all possibilities, including rolling your own. We don’t have enough context to rule any of them in or out. :)

@bcardarella
Copy link
Contributor Author

bcardarella commented Jan 12, 2025

Perhaps a better way to describe this: imagine if Phoenix's Controllers were originally written to ever only handle and respond with HTML. That's the state of evolution that I see LiveView currently being at. The more real-world comparison was earlier versions of Rails. My recollection of pre-1.0 or Rails and for a period of time after that was Rails only responded to and with HTML content. But the needs moved beyond that and gave Rails additional utility that maintained its relevance but also allowed it to be used in more versatile ways.

Ultimately, LiveView is transporting data from server to client. That data represents a state. Whether that state is being represented in the format of HTML, JSON, XML, SwiftUI, etc... should be an implementation detail. The possibilities of what LiveView could be as both a transformative approach to API design but bringing all of the performance, lifecycle, state management, and developer productivity benefits beyond just HTML. We've proven this out, to a degree, with LiveView Native. I'm happy to demonstrate for you all what we've been able to accomplish on that front.

But even I have to admit that LVN isn't going to solve all the needs of native application development. We're simply not going to win over a ton of people outside of Elixir because to get LVN you need to accept Elixir up and down the stack, which is a huge buy-in cost.

One area that I believe that Elixir has the easiest foot in the door for companies with existing tech stacks: building API backends. We've seen this at DockYard with requests to build API backends for React, SwiftUI, etc... front-ends is very common. We try to sell and convert them over to LiveView but almost every single time those efforts have failed. If we implement a REST or GraphQL backend what lock-in do they have with Elixir at that point? None. If they want to change out to another stack with REST or GraphQL they can easily migrate over. If instead there is a compelling reason to stay with Elixir not only will that increase the retention goals of the language but also I believe they'd start use of Elixir beyond their initial needs.

If that API is through LiveView's programming model not only do they get a unique and, IMO, better API backend experience for both their users (lifecycle management, performance) but also through developer productivity. Now they're just one step away from just adding HEEx template to start using LiveView for template rendering as well.

The issues as I see it with LiveState and LiveData is that neither of those two libraries offer a path beyond just what they offer. There's no value add beyond just the immediacy. They may fulfill the ask of this issue in that they provide JSON patching or a wireformat for data binding in the client, but then what? With LiveView as the platform for the underlying programming model we could see the use of LiveView start to metastasize within organizations. If they are using it as their backend API already, writing a few templates to represent all of the state and event handling they've already written to support their JSON rendering is so low overhead.

That's my TED talk.

@bcardarella
Copy link
Contributor Author

bcardarella commented Jan 12, 2025

Just to follow up on my analogy of Phoenix Controllers, delegating the rendering of other formats to libraries outside of LiveView I see as being the same as if Phoenix Controller stayed with just HTML and the recommendation to get JSON, XML, etc... responses were punted to other libraries outside of the Phoenix's ecosystem.

@josevalim
Copy link
Member

josevalim commented Jan 12, 2025

Perhaps a better way to describe this: imagine if Phoenix's Controllers were originally written to ever only handle and respond with HTML. That's the state of evolution that I see LiveView currently being at.

While I agree with the sentiment, it doesn't necessarily hold after an in-depth look. For example, going back to Rails (and Phoenix), the controller actually does not care about the format, it fully delegates the rendering to another layer, which is the view/template. In Phoenix, this delegation is as simple as: we are going to call a function in a module and that's it.

Therefore, in the stateless world, our transport layer is handled by Plug/Controller, which actually does not care about the format. Then, once you go into specific formats, we have layered a bunch of format specific functionality:

  • JSON is often handled at the data-level via protocols, HTML has its own template rendering engine (defined in Phoenix.HTML)
  • HTML uploads are done via multipart, JSON uploads are done by request streaming to the endpoint
  • HTML uses sessions (with need for stuff like CSRF), JSON typically uses API tokens or custom headers (with no need for CSRF)

So while your argument is that Phoenix.LiveView should deal with HTML + JSON, there is a possible interpretation that Phoenix.Channel is your plug/controller and Phoenix.LiveView is a specific view/format implementation for HTML (i.e. Phoenix.LiveView is the Phoenix.HTML of the stateful world).

To be clear, I don't think my view above is 100% true, but I also don't think that your view that Phoenix.LiveView should be the enabler of all different formats fully holds. The answer will be somewhere in the middle. Without sitting down and discussing the features that you need and why, it is impossible to know. Here is how I would evaluate some of the LiveView features across formats today:

  • While HTML and JSON uploads for regular HTTP is often distinct, the upload functionality in LiveView is likely format agnostic

  • Is dead rendering, the one this discussion started with, even useful for JSON? We use it in HTML because of SEO, it can likely be skipped for JSON altogether (or benefit from a completely different approach). A lot of LiveView mounting complexity is to deal with this, which could be drastically simplified otherwise

  • Async and streams may be useful for both HTML and JSON, async more likely than streaming

  • Do LiveComponents even have a use case in a JSON format?

  • Live navigation likely has zero use-cases for JSON

Phoenix Controller stayed with just HTML and the recommendation to get JSON, XML, etc... responses were punted to other libraries outside of the Phoenix's ecosystem.

Per the above, the reason this comparison is flawed is precisely because Phoenix.Controller does not care about HTML, at all. That's why it works with anything. In the same way that Phoenix.Channel does not care about HTML. In other words, you are asking for the thing that cares about HTML, which is Phoenix.LiveView, to care about JSON, while you should be looking into Phoenix.Channel (the Plug/Phoenix.Controller of stateful) to build on top of.

Another possible interpretation is that Phoenix.LiveView is for markup languages, hence it should support HTML and LVN, but not necessarily JSON. In this scenario, the best outcome forward is for us to actually move some of the functionality in LiveView, such as uploads and async, back into Phoenix.Channel, so the implementers of "LiveMarkup" (aka LiveView) and "LiveJSON" (aka LiveState/LiveData) can share more code.

TL;DR: I agree with the sentiment that we should enable different formats but we don't have evidence that the best way to get there is by turning LiveView inside out. The actual answer will require in-depth looks into both Phoenix.Channel and Phoenix.LiveView. FWIW, I was part of the team who worked on Rails to decouple the controller layer, template lookup and template rendering, and I have written a book about it.

@bcardarella
Copy link
Contributor Author

bcardarella commented Jan 12, 2025

I understand what you're saying from the perspective of the "View" but if we're going to stick to that analogy of MVC purism then LiveView itself goes way beyond that. It's managing data and events as well. So I'm not looking at this through the lens of what fits into which design pattern bucket but from the perspective of the public API that people are interacting with. And that is purely the LiveView. Yes, everything is still flowing through a Controller and yes you could arge that the LiveView is just the way to represent that but reality is that there are no guides, documentation, or recommendations that anyone should be interacting with and building functionality into anything but their LiveView. If the argument here is to stick to MVC patterns as the guide then I would argue that LiveView already draws way outside these lines.

However, the counter-point that we've seen in the SPA world is that separating these concerns into MVC isn't the only way to do it. In fact, the majority of this functionality is being built into a single component, which is exactly what LiveView is doing.

@josevalim
Copy link
Member

josevalim commented Jan 12, 2025

I am not arguing from a MVC purist point of view. This discussion is for library authors who will be building the toolkits used by developers. From a stateless library author point of view, I have Plug and a collection of functionality that I can stick together to build libraries for HTML apps and APIs. The exact pieces I assemble will differ between formats. From a stateful library author point of view, you could have Phoenix.Channel and a collection of additional modules, such as Async, Uploads, etc, for building libraries for HTML and JSON apps.

For stateless requests, the dev entry point is shared across HTML and JSON, but they already diverge in the router and the actual rendering tends to be very different (templates vs protocols). Stateful could also be very similar, where the dev entry point is the socket functionality, provided by Phoenix.Channel on both server and client, but then it diverges.

Once again, looking at Phoenix.LiveView and saying "this should all be used for JSON" is similar to looking at everything Plug and Phoenix have which are specific to HTML and saying "this should be all used for JSON". None of them will hold in general. Someone has to go in and tell exactly what is used for what, where, and why.

@josevalim
Copy link
Member

josevalim commented Jan 12, 2025

Just to wrap this up as I step away from the computer for the rest of the day, one of your points were:

With LiveView as the platform for the underlying programming model we could see the use of LiveView start to metastasize within organizations. If they are using it as their backend API already, writing a few templates to represent all of the state and event handling they've already written to support their JSON rendering is so low overhead.

The general direction is correct but there is nothing requiring us to use the exact same library to get there. Take a look at the generated stateless HTML and JSON in Phoenix apps today (without comments):

# Uses Phoenix.Template
defmodule DemoWeb.UserHTML do
  use DemoWeb, :html

  embed_templates "*.html"
end
# Uses nothing
defmodule DemoWeb.UserJSON do
  def index(%{users: users}) do
    %{users: ...}
  end
end

We could totally have this as the stateful version:

# It could just be DemoWeb.UserHTML, I'm adding "Live" for clarity
# Use Phoenix.LiveView
defmodule DemoWeb.UserLiveHTML do
  use DemoWeb, :live_html

  def render(assigns) do
    ~H"""
    ...
    """
  end
end
# Use Phoenix.XYZ
defmodule DemoWeb.UserLiveJSON do
  use DemoWeb, :live_json

  def render(%{users: users}) do
    %{users: ...}
  end
end

There's nothing forcing us to use the same libraries for them. In the same way they do not use the same ones for stateless rendering. Only the entry point is the same, which could totally be the socket.

Finally, I'd also add that the majority of front-end developers I reached out lately said that TypeScript integration is a strong requirement for those, which is yet another major departure from LiveView, as HTML templates are just strings.

@bcardarella
Copy link
Contributor Author

bcardarella commented Jan 12, 2025

@josevalim I think we're arguing for the same things here. For example, here is what Alpha's project structure looks like:

Screenshot 2025-01-12 at 12 38 34 PM

In the context of this application, the "Live" module doesn't permit a render/1 directly and opts to delgate renedering to the format-specific render components. This pattern is one I lifted from how Controllers in Phoenix are organizing, in part, around their own format-specific template rendering. The major difference is the underlying architecture, as you pointed out, violates some of your concerns. That's what I'm hoping to correct.

In other words, if I'm understanding what you're saying is that if we were to back out on where you feel the correct point of abstraction should happen is within Phoenix.LiveView.Controller.live_render/3. Is this correct?

@josevalim
Copy link
Member

josevalim commented Jan 12, 2025

Phoenix.LiveView.Controller.live_render/3 is used for the dead render of a Live API. It exists for SEO and page loading concerns. I would say that a JSON API is either dead (regular JSON) or Live (over websockets). You don't need this dual state, so I don't think it is the correct entry point. I'd say the router or the endpoint's socket:

# In your endpoint
socket "/my-live-api", PhoenixLiveAPI

But I can't say for sure, as I still don't understand what you are going for from a practical point of view (PS: I'm gone for the day).

@bcardarella
Copy link
Contributor Author

bcardarella commented Jan 12, 2025

PS: I'm gone for the day

No need to reply immediately, I don't want to lose my state of mind so I'm going to provide my replies right now.

Phoenix.LiveView.Controller.live_render/3 is used for the dead render

Yes, I know. The Phoenix.LiveView.Channel is then used for the live render.

I'm not sure what the best way to present this is right now. I've given my motivation but that doesn't seem to be resonating very well. I am nearly done with an example application that shows this in action but I'm concerned the focus will be on the implementation than the actual benefit of this approach. Does the LV core team have a recommendation on how best I can go about proving the benefit here?

@josevalim
Copy link
Member

I am speaking for myself but I don't think we disagree with the benefits of this direction, so there is nothing to prove there. The disagreement is on if LiveView should be the one absorbing these responsibilities (and, if so, by exposing which APIs?) or if it is better to build on top of shared abstractions, using Phoenix.Channel and the socket entry points as the Plug/Controller layer of the stateful world.

@bcardarella
Copy link
Contributor Author

bcardarella commented Jan 17, 2025

I have a working proof of concept now:

Jan-17-2025.09-40-56.mp4

This is a React application who's state is being managed by a LiveView endpoint. That endpoint emits JSON in the dead render then emits JSON Patches on LV state updates.

Image

The dead render has a "root" payload that includes all of the necessary connection information for a LiveView connection. So SPAs and native apps would have to first HTTP fetch on the endpoint with ?_format=json. This part is being handled by LiveView Native's ContentNegotiator.

The ContentNegotiator currently, as an on_mount hook, will inject the custom render_with function for LVN's own format-specific render components, I've extended it locally to pipe through the following two functions:

defp build_renderer(socket) do
  format = get_format(socket.assigns)

  with {:ok, plugin} <- LiveViewNative.fetch_plugin(format),
    {:ok, renderer} <- Map.fetch(plugin, :renderer) do
      func = Function.capture(renderer, :to_rendered_content, 4)
      put_private(socket, :renderer, func)
  else
    _ -> socket
  end
end

defp build_differ(socket) do
  format = get_format(socket.assigns)

  with {:ok, plugin} <- LiveViewNative.fetch_plugin(format),
    {:ok, differ} <- Map.fetch(plugin, :differ) do
      func = Function.capture(differ, :to_diff, 3)
      put_private(socket, :differ, func)
  else
    _ -> socket
  end
end

Within LVN plugins if a custom renderer and differ are declared these will be injected into the socket's private space.

Changes to LiveView

Within my fork of LiveView there are only two changes necessary:

phoenix_live_view/static.ex:

defp to_rendered_content_tag(socket, tag, view, attrs) do
  case Map.fetch(socket.private, :renderer) do
    {:ok, func} ->
      func.(socket, tag, view, attrs) 
    :error -> 
      rendered = Phoenix.LiveView.Renderer.to_rendered(socket, view)
      {_, diff, _} = Diff.render(socket, rendered, Diff.new_components())
      content_tag(tag, attrs, Diff.to_iodata(diff))
  end
end

phoenix_live_view/channel.ex these LOCs are replaced with:

case Map.fetch(socket.private, :differ) do
  {:ok, func} ->
    func.(socket, socket.view, state.components)
  :error ->
    rendered = Phoenix.LiveView.Renderer.to_rendered(socket, socket.view)
    Diff.render(socket, rendered, state.components)
end

My JS client code is pretty fugly as I just wanted to prove this out but the tldr is I override the liveSocket.connect function to issue a Fetch on the ?_format=json path, parse the resulting payload, create the new channel connection with the connect_params and implement a custom applyDiff function to merge the JSON Patch diffs into a local state object.

FWIW the current changes to LiveView would be minimal. This would also free up LiveView Native to override the container tag which has been problematic for us. Currently the container tag is set on the LiveView and is a compile-time value. If we had a way to override the renderer then we could implement our own version of:

https://github.com/phoenixframework/phoenix_live_view/blob/main/lib/phoenix_live_view/static.ex#L292-L296

Why not just use Phoenix Channels?

While Channels certainly could be used for the same purpose IMO they are viewed and treated as too much of a primitive in Phoenix. LiveView as an abstraction is more approachable and would require far less wiring up. This also would allow the LVN project to create clients (JS + native) that would just plug-in for each. Furthermore, I believe that pushing people towards a unified developer experience of LiveView is better, opposed to jumping between Controllers, LiveView, and Channels all to get done what could be under a single developer experience. As I stated earlier in this thread, LiveView itself could be a replacement for REST and GraphQL APIs. But made more powerful. Imagine now using assign_async and streams for data being patched back to the clients. Issuing redirects that the clients respect.

What about LiveComponents?

I don't know. I could possibly see a reason to want to pack a sub-set payload as a JSON LiveComponent but for now I feel that is work that couldn't happen until this first need is accepted or rejected.

What about auth

When compared to exsting APIs being used by React, SwiftUI, etc... they're all just doing HTTP requests anyway. So the current workflow of LiveView requring to disconnect then reconnect is at-worse as-good as the current APIs being used. After that you get all of the benefits and performance of data over the wire.

@josevalim
Copy link
Member

josevalim commented Jan 17, 2025

Nice work on getting the proof of concept up and running! This allows us to stay more focused in the discussion.

The dead render has a "root" payload that includes all of the necessary connection information for a LiveView connection. So SPAs and native apps would have to first HTTP fetch on the endpoint with ?_format=json

Can you please explain the rationale for doing the initial HTTP fetch? The reason why we do this for HTML is because we need a dead render for SEO and because the session is pretty much the only way to pass information across for HTML. However, for a JSON API, you should just connect directly over the WebSocket and pass the information you want.

Unless there is evidence the initial fetch is necessary, it feels to me we are doing this simply to comply with LiveView's model, even though it is not necessary for JSON.

phoenix_live_view/channel.ex these LOCs are replaced with:

These changes are a good starting point but quite more work would be required if that's how we want to slice it. Mostly because state.components is private state from the Diff engine, which we would now leak to LVN and other implementations. There are also 4 or 5 other places where we call Diff in channel.ex which need to be taken into account. In other words, you cannot make only part of the Diff module (and its state) replaceable, you need to isolate and replace all interactions.

One thing is certain, we don't want to simply wrap around the current calls between channel.ex and diff.ex, because I am sure you don't want to reimplement the whole component management system for JSON (if you ever get to need it). So at a minimum we need to refactor the component management aspect to outside of Diff or, alternatively, taking the rendering out of Diff and wrap that instead.

Furthermore, I believe that pushing people towards a unified developer experience of LiveView is better, opposed to jumping between Controllers, LiveView, and Channels all to get done what could be under a single developer experience.

To be clear, that's not what I was proposing. I am not saying we should ask developers to use channels, I was saying that you should build your abstraction on top of Channels, instead of LiveView. And this abstraction would feel like LiveView in its APIs and approach to state management, pretty much how LiveView/LiveState/LiveData are implemented. And if there is any functionality you want to share between LiveView and LiveJSON, we could move that functionality to Phoenix.Channel.

@bcardarella
Copy link
Contributor Author

bcardarella commented Jan 17, 2025

Can you please explain the rationale for doing the initial HTTP fetch?

This is for front-ends that wouldn't be rendering the HTML from Phoenix. I presumed all of the connect_params info was necessary to make the LV connection. Is that not the case?

These changes are a good starting point but quite more work would be required if that's how we want to slice it.

Yeah I know, these were just the specific choke points in LV that allowed me to get what I needed, not suggetsions on what a PR would look like.

because I am sure you don't want to reimplement the whole component management system for JSON

I'm torn on this as I don't have a good use case for components within a JSON payload, that being said just because I don't (today) see a need doesn't mean someone else wouldn't or I wouldn't in the future.

To be clear, that's not what I was proposing. I am not saying we should ask developers to use channels, I was saying that you should build your abstraction on top of Channels, instead of LiveView. And this abstraction would feel like LiveView in its APIs and approach to state management.

The difference here is that within LiveView it's a single point for state management and event handling, which really has nothing to do with how the output is being rendered. We've been approached multiple times by companies that want to migrate away from React with a (.NET, Rails, Node, etc...) backend to LiveView, but the cost to re-write the backend and frontend is just too high and the sticker shock ultimately turns them away. What I envision is an iterative approach. Because auth, session, and state would be managed by a LiveView on the backend you could re-write their API server as a LiveView JSON backend. Now when they want to iterate away from React and to LiveView HTML most of the work is done, they just have to add HEEx templates.

This is the value proposition we've seen resonate with LiveView Native: if you have an existing LiveView application all of the state management and handling work is already done. Just add templates for your native targets.

@josevalim
Copy link
Member

josevalim commented Jan 17, 2025

This is for front-ends that wouldn't be rendering the HTML. I presumed all of the connect_params info was necessary to make the LV connection. Is that not the case?

Maybe that's required for LiveView, but that's not the point right now. The question we want to answer, correct me if I am wrong, is: what is the amount of changes we need to apply to LiveView to make it suit LiveJSON? As you said well, the proposal above is just a sketch, so more work is necessary.

Therefore, if LiveView needs a dead render to work, and LiveJSON doesn't, then that's something else we need to make customizable, which will factor into the trade-offs and the whole complexity.

The difference here is that within LiveView it's a single point for state management and event handling, which really has nothing to do with how the output is being rendered. We've been approached multiple times by companies that want to migrate away from React with a (.NET, Rails, Node, etc...) backend to LiveView, but the cost to re-write the backend and frontend is just too high and the sticker shock ultimately turns them away.

We had long discussions about this in this issue in the past week and it is clear we are talking past each other. I am NOT talking about the idea on a conceptual level. I am NOT talking about the value proposition. That's not for me to judge. I am talking purely on the mechanical, implementation-level, side of things. In particular, I still haven't seen enough evidence, either in favor or against, that changing LiveView is better than reimplementing parts of LiveView on top of channels, whch would allow you to make all trade-offs tailored to LiveJSON. I am only asking you to keep that in mind. And this will be one evaluation criteria, at least to me, to go forward with these changes.

For the next step, I'd propose sending a pull request that refactors the Channel <-> Diff engine interaction, splitting the rendering bits from the state/component management bits. This would make it easier to replace the rendered parts in the future. Also note that things like events, replies, and streams are also managed within the Diff module, which you probably want to have in LiveJSON without reimplementing from scratch, so those need to be considering when refactoring the interactions too.

@bcardarella
Copy link
Contributor Author

Therefore, if LiveView needs a dead render to work, and LiveJSON doesn't, then that's something else we need to make customizable, which will factor into the trade-offs and the whole complexity.

I didn't realize that was on the table. If it is then let me go investigate and come back with an answer.

For the next step, I'd propose sending a pull request that refactors the Channel <-> Diff engine interaction

To be clear on the ask here, this request is to just provide a more flexible API for potential expansion. In other words, I'm not touching the tests just a pure refactor.

@josevalim
Copy link
Member

josevalim commented Jan 17, 2025

I didn't realize that was on the table. If it is then let me go investigate and come back with an answer.

I am afraid that's not the point either. :D I assume we want to design the best solution possible, correct? And the best solution possible most likely does not do unnecessary HTTP requests? Therefore, in order to have the best solution, you need to either change LiveView or roll your own LiveJSON on top of channels.

Basically, we should avoid being put in a corner, where we do several changes to accommodate LiveJSON today, only to find out 3 months from now that doing an unnecessary HTTP request is unacceptable, and then you either have to start from scratch or we need to add even more hooks and customizations point to LiveView, making it harder to maintain. I want to understand the overall amount of changes necessary to make the best version of LiveJSON possible.

To be clear on the ask here, this request is to just provide a more flexible API for potential expansion. In other words, I'm not touching the tests just a pure refactor.

Yes, it is a pure refactor, that splits the endpoints you would actually want to customize with a custom behaviour into a separate module (or a few functions).

@josevalim
Copy link
Member

PS: I think the area you actually want to encapsulate is the Diff.traverse function. That's the function that actually does the rendering. But as you can see, it also deals with components and streams, and a lot of internal state, so refactoring that into a clean contract will be hard.

You can consider the fingerprints to be part of the rendering engine (the traverse function) while components are part of the diff engine.

@bcardarella
Copy link
Contributor Author

The reason why I punt in the rendered function is to avoid this check: https://github.com/phoenixframework/phoenix_live_view/blob/main/lib/phoenix_live_view/renderer.ex#L74

The Phoenix.LiveView.Rendered struct is change tracking in a way that makes sense for markup but not for other object types.

@josevalim
Copy link
Member

Agreed. That check should be moved to inside the "Differ" code.

@josevalim
Copy link
Member

josevalim commented Jan 17, 2025

@bcardarella here is a pull request that does some of the work necessary: #3640

The pull request splits some of the state and already decouples some of the parts.

Diff.render now receives 4 arguments:

  • the socket, which is mostly used for telemetry purposes
  • the rendered struct, this is the outcome of render/1 and what must be processed by a custom traverse (custom traverse is what you called differ previously)
  • the fingerprints, this is the state of the custom traverse. Today the name fingerprints is quite specific to our traverse implementation but the name is easy to change
  • the components state, which is private state to the Diff module and it should not be exposed to custom traversals

The function you would need to plug is still traverse, although now it receives fewer arguments. However, you will notice that traverse is still intrinsically linked to rendering, components, and streams handling, and I haven't explored ways to decouple those, if at all possible.

@bcardarella
Copy link
Contributor Author

thank you, I'll work from this. I'm planning to do so this weekend. If I develop a PR do you want me to PR your branch?

@josevalim
Copy link
Member

Yeah, PR my branch and then if my branch is merged, you just change the base.

@bcardarella
Copy link
Contributor Author

@josevalim because I can see a reason for streams and rendering for JSON but also clearly components for other formats I think it should be OK to include them in this effort. I'll report back my findings when I have something to share.

@josevalim
Copy link
Member

Yes, that's what I meant. In order for them to be available for LiveJSON and others, they should not be part of the API being replaced (and the changes you demo-ed do replace them!). Decoupling those is where the difficulty lies.

@superchris
Copy link

Chiming in pretty late, but I'd like to offer up an alternative approach that doesn't require a change to LiveView but (I think) would achieve the same goal of allowing clients to receive json patch state updates from a LiveView. I have a working proof of concept repo here. I've used a custom element as it's easy and convenient, but this would be no more difficult to do from React (or JS framework du jour).

In a nutshell, my approach is to leverage LiveState, which already sends state updates as json patches and can work with any JS client (web components, React, Preact, etc). To keep in sync with a LiveView, I attach an :after_render hook in the LiveView and pubsub the assigns to a topic which includes the socket id:

defmodule LvHookExperimentWeb.LiveStateHook do
  alias Phoenix.PubSub
  import Phoenix.LiveView
  import Phoenix.Component

  def on_mount(:default, params, session, %{id: socket_id} = socket) do
    IO.inspect(socket, label: "socket")
    PubSub.subscribe(LvHookExperiment.PubSub, "live_state:events:#{socket_id}")
    {:cont, socket |> attach_hook(:live_state, :after_render, &sync_live_state/1)}
  end

  def sync_live_state(%{assigns: assigns, id: socket_id} = socket) do
    PubSub.broadcast(LvHookExperiment.PubSub, "live_state:state:#{socket_id}", {:after_render, assigns})
    socket
  end
end

On the front end, I set the socket id as attribute to a custom element, which then attaches to LiveState channel whose only job is to receive the assigns from the LiveView over the pubsub channel:

defmodule LvHookExperimentWeb.LiveViewSyncChannel do
  @moduledoc false
  alias Phoenix.PubSub

  use LiveState.Channel, web_module: LvHookExperimentWeb

  @impl true
  def init(_channel, %{"socket_id" => lv_socket_id}, socket) do
    PubSub.subscribe(LvHookExperiment.PubSub, "live_state:state:#{lv_socket_id}")
    {:ok, %{people: [], lv_socket_id: lv_socket_id}, socket |> assign(:lv_socket_id, lv_socket_id)}
  end

  @impl true
  def handle_message({:after_render, assigns}, state) do
    {:noreply, assigns}
  end

  @impl true
  def handle_event(event, payload, state, %{assigns: %{lv_socket_id: lv_socket_id}} = socket) do
    PubSub.broadcast(LvHookExperiment.PubSub, "live_state:events:#{lv_socket_id}", {:live_state, event, payload})
    {:noreply, state}
  end
end

Sending events back into LV from the front end client right now is a little clunky, as it comes in as handle_info but I think this is a problem that could be solved. In any event, for a quick POC I was pleased with the end result:

https://www.loom.com/share/37be1d32e8114fe9b1ec3cff3f398305?sid=978a74b5-3372-4c24-8f00-19895a847c05

@bcardarella
Copy link
Contributor Author

@superchris does this open a 2nd WS connection or is it over the same socket connection?

@superchris
Copy link

@bcardarella I'm assuming it would be a second connection, perhaps there would be a way to share it but I haven't investigated that.

@bcardarella
Copy link
Contributor Author

Would that then present an issue in a distributed system if the LV you're connected to is routed to a different node in the cluster than the node the LiveState process is connected to? At the very least you'd need to know which node to broadcast to I would think.

@superchris
Copy link

@bcardarella I'm thinking the pubsub bit would work fine in a distributed environment, but the real answer is to experiment and see. It's just a POC at this point, so I'm sure there are issues to work out if it turned into A Real Thing :)

@josevalim
Copy link
Member

If you use a separate Socket instance, it will be a separate connection, but it might be relatively easy to allow multiple channels over the same WebSocket/LongPoll connection as LV, so I wouldn’t say it is a major concern long term.

@bcardarella
Copy link
Contributor Author

I do have some results to share on my end. I think Phoenix.LiveView.Diff should largely converted into Phoenix.LiveView.Template.Diff or Phoenix.LiveView.Diff.Template and a new Phoenive.LiveView.Diff should be a behaviour. There are currently 12 public functions (two render/3s) and I could either just mint that as the callbacks required or continue to look for opportunities to refactor throughout the Channel module.

Static only uses: Diff.render/3 and Diff.to_iodata/1
Test module uses those two and Diff.component_to_rendered/3
Socket uses Diff.new_fingerprints/0 <= I'm presuming this one doesn't need to be in the individual implementations of the behaviour
Channel uses everything else

@josevalim
Copy link
Member

@bcardarella that makes sense. In general terms, the Rendered structure today, which you want to replace, is either converted to a patch or rendered in full as iodata, so these functions definitely would need to be part of the pluggable API. At the same time, I am also 100% sure that render is not the callback, since it has shared behaviour, such as handling of replies, events, and components, that you would want shared across all formats. To me that's the biggest challenge in making parts of the Diff module pluggable.

@chrismccord
Copy link
Member

You can already use Phoenix.LiveView.Socket to share a LV connection with your own channels https://github.com/phoenixframework/phoenix_live_view/blob/main/lib/phoenix_live_view/socket.ex#L23

@bcardarella
Copy link
Contributor Author

@josevalim I don't think I can replace the Phoenix.LiveView.Rendered because this is in the public API: https://github.com/phoenixframework/phoenix_live_view/blob/main/lib/phoenix_live_view.ex#L243

Technically from LVN's perspective, I don't break this contract as the render_with function that is delegated to doesn't have this restriction. If it weren't for the to_iodata implementations I probably could have re-used it for any format:

defmodule Phoenix.LiveView.Rendered do
@moduledoc """
The struct returned by .heex templates.
See a description about its fields and use cases
in `Phoenix.LiveView.Engine` docs.
"""
defstruct [:static, :dynamic, :fingerprint, :root, caller: :not_available]
@type t :: %__MODULE__{
static: [String.t()],
dynamic: (boolean() ->
[
nil
| iodata()
| Phoenix.LiveView.Rendered.t()
| Phoenix.LiveView.Comprehension.t()
| Phoenix.LiveView.Component.t()
]),
fingerprint: integer(),
root: nil | true | false,
caller:
:not_available
| {module(), function :: {atom(), non_neg_integer()}, file :: String.t(),
line :: pos_integer()}
}
defimpl Phoenix.HTML.Safe do
def to_iodata(%Phoenix.LiveView.Rendered{static: static, dynamic: dynamic}) do
to_iodata(static, dynamic.(false), [])
end
def to_iodata(%_{} = struct) do
Phoenix.HTML.Safe.to_iodata(struct)
end
def to_iodata(other) do
other
end
defp to_iodata([static_head | static_tail], [dynamic_head | dynamic_tail], acc) do
to_iodata(static_tail, dynamic_tail, [to_iodata(dynamic_head), static_head | acc])
end
defp to_iodata([static_head], [], acc) do
Enum.reverse([static_head | acc])
end
end
end

@josevalim
Copy link
Member

josevalim commented Jan 20, 2025

It is ok for us to relax the contract of a callback output, as it means LiveView can handle more formats, without imposing breaking changes to user.

@bcardarella
Copy link
Contributor Author

I'm spinning wheels on this a bit I must admit. I've come at it from a few angles I think I've got a mental gap between what @josevalim is asking for and how I'm understanding it.

The primary mental blocker is that in both the Static and Channel modules where the Diff.render function is called it also takes the result of calling view.render/1 (effectively the Phoenix.LiveView.Rendered struct). The Static module wraps in the container. Both of those are outside the context of the Diff behavior so even if the Diff module is refactored I'm still not sure how this solves the underlying issue. As the render/1 returning the Rendered struct means conforming to that struct itself, which the JSON doesn't really need to.

The Static module has the view rendering, diffing, then wrapping in the container
The Channel module has the rendering, then diffiing.

I could just start with a simpel extraction of the Diff module into Phoenix.LiveView.HTMLDiff (to conform with the other HTML-esque modules) and expose the original Diff module as a behaviour but it's not clear to me it this is a good use of everyone's time or not.

If it would be better to get onto a call at some point this week so I can get more context I can be available.

@josevalim
Copy link
Member

josevalim commented Jan 25, 2025

I could just start with a simpel extraction of the Diff module into Phoenix.LiveView.HTMLDiff (to conform with the other HTML-esque modules) and expose the original Diff module as a behaviour but it's not clear to me it this is a good use of everyone's time or not.

The main assumption is that a behaviour around the Diff module is not useful because the Diff module does several other things, such as streaming, replies and component management, that you want to share across all formats. So without separating those things from the Diff module first, any abstraction will be inaccurate. In my mind, that's the whole challenge. Once you break the Diff apart, I assume solving Static and Channel will be much easier, because they will use a smaller version of the Diff module.

@bcardarella
Copy link
Contributor Author

Oh ok, that makes more sense now. Thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants