Skip to content

[LLV] Figure out how to assign IDs to LLVs - #702

Merged
FranekKubis merged 18 commits into
mainfrom
fk/llv-id-generation
Jul 31, 2026
Merged

[LLV] Figure out how to assign IDs to LLVs#702
FranekKubis merged 18 commits into
mainfrom
fk/llv-id-generation

Conversation

@FranekKubis

@FranekKubis FranekKubis commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Closes: #679

Summary:

  • LLV ID handling: Retained the default behavior. If a single instance of a LocalLiveView is present, id defaults to "llv-LLVModuleName". If multiple instances of the same LocalLiveView exist, the user must explicitly provide a unique id for each instance.

  • Mirror identification: The server uses mirror_id to uniquely identify mirrors connected to a specific LLV instance. The mirror_id is a combination of socket.id and the component's id.

@FranekKubis
FranekKubis marked this pull request as ready for review July 29, 2026 08:52
@FranekKubis
FranekKubis requested a review from mat-hek July 29, 2026 09:38
@FranekKubis
FranekKubis force-pushed the fk/llv-id-generation branch from b16730d to 0d8ea0d Compare July 29, 2026 11:15
@FranekKubis
FranekKubis force-pushed the fk/llv-id-generation branch from 0d8ea0d to 13e41db Compare July 29, 2026 11:25

@mat-hek mat-hek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only looked at component.ex for now

Comment thread local-live-view/lib/server/component.ex Outdated
Comment thread local-live-view/lib/server/component.ex Outdated
Comment thread local-live-view/lib/server/component.ex Outdated
Comment thread local-live-view/lib/server/component.ex Outdated
Comment thread local-live-view/lib/server/component.ex Outdated
Comment thread local-live-view/lib/server/component.ex Outdated
Comment thread local-live-view/lib/server/component.ex Outdated
@FranekKubis
FranekKubis requested a review from mat-hek July 30, 2026 08:05
"""
end

@doc false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@doc false

Comment thread local-live-view/lib/server/component.ex Outdated
Comment thread local-live-view/lib/server/component.ex Outdated
if mirror_exists?(view),
do: LocalLiveView.MirrorToken.sign(assigns.endpoint, view, assigns.id)
comp_assigns =
Map.drop(assigns, [:__changed__, :view, :flash, :id, :mirror_id, :mirror_token])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we drop id? In the docs we say we forward it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We were dropping it before, so I assumed the id is not needed in comp_assigns.
Also we forward it as a DOM element id.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not dropping it on main: https://github.com/software-mansion/popcorn/blob/main/local-live-view/lib/server/component.ex#L68 Even if it's a DOM element id, it's not accessible in assigns, and we claim so here: https://github.com/software-mansion/popcorn/blob/fk/llv-id-generation/local-live-view/lib/server/component.ex#L24 . I guess we should pass it to assigns, as that's what live component does

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment on lines +85 to +91
socket =
assign(socket,
view: view,
id: assigns.id,
mirror_token: mirror_token,
mirror_id: mirror_id
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we never assign other assigns, so @comp_assigns in render_markup are empty

Comment thread local-live-view/lib/server/component.ex Outdated
render_markup(assigns)
end

defmodule Live do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
defmodule Live do
defmodule Mirrored do

Comment thread local-live-view/lib/server/component.ex Outdated
default_id(view_or_id)
end

socket_id <> dom_id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe separate them?

Suggested change
socket_id <> dom_id
socket_id <> "-" <> dom_id

@mat-hek mat-hek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, Claude told me that this broke burrito example. Haven't checked this, did you? BTW, it would be worth running all examples' CIs when LLV changes, as these are our main tests.

const maybeSetupMirrorChannel = (el: HTMLElement) => this.maybeSetupMirrorChannel(el);
this.socket.hooks.LocalLiveView = {
mounted() {
maybeSetupMirrorChannel(this.el);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls connectMirror under the hood, which sends action to popcorn, but popcorn may be not ready yet (see 2 lines below). Maybe it should be a part of mountView?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved it to if pop.ready scope.

@mat-hek mat-hek Jul 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it will work - if popcorn is not ready in time, it never runs. mountView is different, because we run 'startup scan' once popcorn is ready: https://github.com/software-mansion/popcorn/blob/main/local-live-view/assets/local_live_view/index.ts#L390.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So moved it to mountView function

Comment thread local-live-view/lib/server/component.ex Outdated

def mirror_id(socket_id, view_or_id) when is_binary(socket_id) and is_binary(view_or_id) do
dom_id =
if String.starts_with?(view_or_id, "llv-") do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work when someone passes a custom id to LLV? We don't require the id to start with llv-

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - I removed that part.

Comment thread local-live-view/lib/server/component.ex Outdated

if mirror_exists?(view) do
~H"""
<.live_component module={__MODULE__.Live} {assigns} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will break when someone tries to render mirrored LLV outside of a LiveView, as live components cannot be rendered in regular views AFAIK

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am aware of that - I planned to leave it as a known limitation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If so, we should raise a clear error in that case. Would the limitation apply only to mirrored live views?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

@FranekKubis
FranekKubis requested a review from mat-hek July 30, 2026 12:52
if (pop.ready) mountView(this.el);
},
updated() {
maybeSetupMirrorChannel(this.el);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we setup the channel here? Isn't it enough to setup it on mount? If so, can we remove the connect_mirror event and just put the id in pop.create?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the connectMirror event and move the mirror connection functionality to mount.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the call to maybeSetupMirrorChannel here is redundant, isn't it?

Comment thread local-live-view/lib/server/component.ex Outdated
Comment on lines +172 to +173
# Default ID derived from view name:
mirror_id = LocalLiveView.Component.mirror_id(socket, "Cart")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This no longer works, does it?

Comment thread local-live-view/lib/server/component.ex Outdated
mirror_id = LocalLiveView.Component.mirror_id(socket, "Cart")
"""
@spec mirror_id(%Phoenix.LiveView.Socket{id: String.t()}, String.t()) :: String.t()
def mirror_id(%Phoenix.LiveView.Socket{id: socket_id}, view_or_id) do

@mat-hek mat-hek Jul 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as per above

Suggested change
def mirror_id(%Phoenix.LiveView.Socket{id: socket_id}, view_or_id) do
def mirror_id(%Phoenix.LiveView.Socket{id: socket_id}, id) do

Comment thread local-live-view/lib/server/component.ex Outdated
# Default ID derived from view name:
mirror_id = LocalLiveView.Component.mirror_id(socket, "Cart")
"""
@spec mirror_id(%Phoenix.LiveView.Socket{id: String.t()}, String.t()) :: String.t()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@spec mirror_id(%Phoenix.LiveView.Socket{id: String.t()}, String.t()) :: String.t()
@spec mirror_id(Phoenix.LiveView.Socket.t(), String.t()) :: String.t()

Comment thread local-live-view/lib/server/component.ex Outdated
Comment on lines +81 to +86
if Phoenix.LiveView.connected?(socket) do
endpoint = socket.endpoint || LLVComponent.resolve_default_endpoint()
LocalLiveView.MirrorToken.sign(endpoint, view, mirror_id)
else
nil
end

@mat-hek mat-hek Jul 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a reason to sign the ID on each update, as it never changes. I think we can do

Suggested change
if Phoenix.LiveView.connected?(socket) do
endpoint = socket.endpoint || LLVComponent.resolve_default_endpoint()
LocalLiveView.MirrorToken.sign(endpoint, view, mirror_id)
else
nil
end
cond do
token = socket.assigns[:mirror_token] ->
token
Phoenix.LiveView.connected?(socket) ->
endpoint = socket.endpoint || LLVComponent.resolve_default_endpoint()
LocalLiveView.MirrorToken.sign(endpoint, view, mirror_id)
true ->
nil
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or even better generate the ID only if it's not generated yet

@FranekKubis
FranekKubis requested a review from mat-hek July 31, 2026 10:19

@mat-hek mat-hek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, minor tweaks left. Note that there were quite some bugs, not covered by the tests. For now I'd at least run examples' CIs when LLV code changes.

@@ -322,14 +318,14 @@ export class LLVEngine {
private maybeSetupMirrorChannel(el: HTMLElement): void {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this could accept llvId, popView and popMirrorToken, so that the extraction stays in mountView

if (pop.ready) mountView(this.el);
},
updated() {
maybeSetupMirrorChannel(this.el);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the call to maybeSetupMirrorChannel here is redundant, isn't it?

Comment thread local-live-view/lib/server/component.ex Outdated
within a parent LiveView process.

Accepts the parent `socket` (or directly its `socket.id`), and either:
* a view module name / string (calculates the default DOM ID)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to update

Comment on lines +320 to +321
if (!mirrorId || (mirrorId && this.channels[mirrorId])) return;
if (!el.dataset.popMirrorToken) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

Suggested change
if (!mirrorId || (mirrorId && this.channels[mirrorId])) return;
if (!el.dataset.popMirrorToken) return;
if (!mirrorId || this.channels[mirrorId] || !el.dataset.popMirrorToken) return;


if mirror_exists?(view) do
~H"""
<.live_component module={__MODULE__.Mirrored} {assigns} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still don't throw a readable error when not in LV

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am inable to catch this exception on this level because the error here will be raised by the page controller mechanism inside phoenix.
Right now it is failing like this:

 1) test GET / renders the LocalLiveView mount points (LocalThermostatWeb.PageControllerTest)
     test/local_thermostat_web/controllers/page_controller_test.exs:4
     ** (ArgumentError) cannot convert component LocalLiveView.Component.Mirrored with id "llv-ClockLive" to HTML.

     A component must always be returned directly as part of a LiveView template.

     For example, this is not allowed:

         <%= content_tag :div do %>
           <.live_component module={SomeComponent} id="myid" />
         <% end %>

     That's because the component is inside `content_tag`. However, this works:

         <div>
           <.live_component module={SomeComponent} id="myid" />
         </div>

     Components are also allowed inside Elixir's special forms, such as
     `if`, `for`, `case`, and friends.

         <%= for item <- items do %>
           <.live_component module={SomeComponent} id={item} />
         <% end %>

     However, using other module functions such as `Enum`, will not work:

         <%= Enum.map(items, fn item -> %>
           <.live_component module={SomeComponent} id={item} />
         <% end %>
     
     code: conn = get(conn, ~p"/")
     stacktrace:
       (phoenix_live_view 1.1.28) lib/phoenix_live_view/engine.ex:20: Phoenix.HTML.Safe.Phoenix.LiveView.Component.to_iodata/1
       (phoenix_live_view 1.1.28) lib/phoenix_live_view/engine.ex:146: Phoenix.HTML.Safe.Phoenix.LiveView.Rendered.recur_iodata/3
       (phoenix 1.8.5) lib/phoenix/controller.ex:1015: anonymous fn/5 in Phoenix.Controller.template_render_to_iodata/4
       (telemetry 1.4.1) /Users/franek/Desktop/Repositories/popcorn/examples/local-lv-thermostat/deps/telemetry/src/telemetry.erl:359: :telemetry.span/3
       (phoenix 1.8.5) lib/phoenix/controller.ex:981: Phoenix.Controller.render_and_send/4
       (local_thermostat 0.1.0) lib/local_thermostat_web/controllers/page_controller.ex:1: LocalThermostatWeb.PageController.action/2
       (local_thermostat 0.1.0) lib/local_thermostat_web/controllers/page_controller.ex:1: LocalThermostatWeb.PageController.phoenix_controller_pipeline/2
       (phoenix 1.8.5) lib/phoenix/router.ex:416: Phoenix.Router.__call__/5
       (local_thermostat 0.1.0) lib/local_thermostat_web/endpoint.ex:1: LocalThermostatWeb.Endpoint.plug_builder_call/2
       (local_thermostat 0.1.0) lib/local_thermostat_web/endpoint.ex:1: LocalThermostatWeb.Endpoint.call/2
       (phoenix 1.8.5) lib/phoenix/test/conn_test.ex:225: Phoenix.ConnTest.dispatch/5
       test/local_thermostat_web/controllers/page_controller_test.exs:5: (test)

Comment on lines +21 to +31
case connected?(socket) do
true ->
mirror_id =
LocalLiveView.Component.mirror_id(llv_rendering_socket_id, "llv-BurritoLive")

Phoenix.PubSub.subscribe(Burrito.PubSub, "llv_mirror:BurritoLive:#{mirror_id}")
LocalLiveView.Channel.get_mirror_assigns(mirror_id)

false ->
%{}
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not if? :P

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it can be if

Comment on lines +130 to +137
const Socket = this.socketClass();
const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content");

const llvSocket = new Socket("/llv_socket", {
params: { _csrf_token: csrfToken },
});
llvSocket.connect();
this.llvMirrorSocket = llvSocket;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now always start the socket, no matter if there are mirrors or not. I think we can lazily start it in maybeSetupMirrorChannel

@FranekKubis
FranekKubis requested a review from mat-hek July 31, 2026 13:25

@mat-hek mat-hek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

Comment on lines +328 to +330
if (typeof mirrorId === "string") {
this.channels[mirrorId] = channel;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems always true

Suggested change
if (typeof mirrorId === "string") {
this.channels[mirrorId] = channel;
}
this.channels[mirrorId] = channel;

@FranekKubis
FranekKubis merged commit 92c1c33 into main Jul 31, 2026
8 checks passed
@FranekKubis
FranekKubis deleted the fk/llv-id-generation branch July 31, 2026 14:27
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

Successfully merging this pull request may close these issues.

[LLV] Figure out how to assign IDs to LLVs

2 participants