-
Notifications
You must be signed in to change notification settings - Fork 951
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
Comments
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! |
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 |
In my spike, I'm changing the 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 |
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:
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. |
@josevalim yes, I'm just piece-mealing as I go. So pointers in the right direction are always appreciated. |
I'm interested in your vision of how this would be used from an application point of view.
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):
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? |
I don't see how starting from scratch is a viable recommendation. |
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. :) |
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. |
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. |
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:
So while your argument is that To be clear, I don't think my view above is 100% true, but I also don't think that your view that
Per the above, the reason this comparison is flawed is precisely because Another possible interpretation is that 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. |
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. |
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. |
Just to wrap this up as I step away from the computer for the rest of the day, one of your points were:
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 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. |
@josevalim I think we're arguing for the same things here. For example, here is what Alpha's project structure looks like: In the context of this application, the "Live" module doesn't permit a 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 |
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). |
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.
Yes, I know. The 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? |
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. |
I have a working proof of concept now: Jan-17-2025.09-40-56.mp4This 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. 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 The ContentNegotiator currently, as an on_mount hook, will inject the custom 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 Changes to LiveViewWithin my fork of LiveView there are only two changes necessary:
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
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 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: 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 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 authWhen 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. |
Nice work on getting the proof of concept up and running! This allows us to stay more focused in the discussion.
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.
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 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.
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. |
This is for front-ends that wouldn't be rendering the HTML from Phoenix. I presumed all of the
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.
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.
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. |
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.
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. |
I didn't realize that was on the table. If it is then let me go investigate and come back with an answer.
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. |
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.
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). |
PS: I think the area you actually want to encapsulate is the You can consider the fingerprints to be part of the rendering engine (the traverse function) while components are part of the diff engine. |
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 |
Agreed. That check should be moved to inside the "Differ" code. |
@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.
The function you would need to plug is still |
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? |
Yeah, PR my branch and then if my branch is merged, you just change the base. |
@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. |
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. |
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 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 https://www.loom.com/share/37be1d32e8114fe9b1ec3cff3f398305?sid=978a74b5-3372-4c24-8f00-19895a847c05 |
@superchris does this open a 2nd WS connection or is it over the same socket connection? |
@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. |
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. |
@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 :) |
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. |
I do have some results to share on my end. I think
|
@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 |
You can already |
@josevalim I don't think I can replace the Technically from LVN's perspective, I don't break this contract as the phoenix_live_view/lib/phoenix_live_view/engine.ex Lines 119 to 168 in cde2804
|
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. |
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 The I could just start with a simpel extraction of the Diff module into 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. |
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 |
Oh ok, that makes more sense now. Thank you. |
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.
The text was updated successfully, but these errors were encountered: