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 · 18 comments
Open

Request for feedback: Format rendering override public API #3631

bcardarella opened this issue Jan 11, 2025 · 18 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.

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

4 participants