Skip to content
This repository was archived by the owner on Feb 10, 2026. It is now read-only.
4 changes: 4 additions & 0 deletions lib/live_view_native/template/engine.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ defmodule LiveViewNative.Template.Engine do
@impl true
defdelegate annotate_caller(file, line), to: Phoenix.LiveView.HTMLEngine

@doc false
@impl true
defdelegate annotate_slot(name, tag_meta, close_meta, caller), to: Phoenix.LiveView.HTMLEngine

@doc false
@impl true
def classify_type(":inner_block"), do: {:error, "the slot name :inner_block is reserved"}
Expand Down
71 changes: 71 additions & 0 deletions lib/live_view_native/test/view_tree.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ defmodule LiveViewNativeTest.ViewTree do
@phx_component "data-phx-component"
@static :s
@components :c
@template :p
@keyed :k
@keyed_count :kc
@stream_id :stream

def ensure_loaded! do
Expand Down Expand Up @@ -284,6 +287,38 @@ defmodule LiveViewNativeTest.ViewTree do
update_in(rendered[@components], &Map.drop(&1, cids))
end

# We resolve any templates when merging, because subsequent patches can
# contain more templates that are not compatible with previous diffs.
# This prevents template position collisions from breaking rendering.
defp deep_merge_diff(target, %{@template => template} = source),
do: deep_merge_diff(target, resolve_templates(Map.delete(source, @template), template))

defp deep_merge_diff(target, %{@keyed => source_keyed} = source) when is_map(target) do
target_keyed = target[@keyed]

merged_keyed =
case source_keyed[@keyed_count] do
0 ->
%{@keyed_count => 0}

count ->
for pos <- 0..(count - 1), into: %{@keyed_count => count} do
value =
case source_keyed[pos] do
nil -> target_keyed[pos]
value when is_number(value) -> target_keyed[value]
value when is_map(value) -> deep_merge_diff(target_keyed[pos], value)
[old_pos, value] -> deep_merge_diff(target_keyed[old_pos], value)
end

{pos, value}
end
end

merged = deep_merge_diff(Map.delete(target, @keyed), Map.delete(source, @keyed))
Map.put(merged, @keyed, merged_keyed)
end

defp deep_merge_diff(_target, %{@static => _} = source),
do: source

Expand All @@ -293,6 +328,42 @@ defmodule LiveViewNativeTest.ViewTree do
defp deep_merge_diff(_target, source),
do: source

# Template resolution helpers - convert template position references to literal static parts
defp resolve_templates(%{@template => template} = rendered, nil) do
resolve_templates(Map.delete(rendered, @template), template)
end

defp resolve_templates(%{@static => static} = rendered, template) when is_integer(static) do
resolve_templates(Map.put(rendered, @static, Map.fetch!(template, static)), template)
end

defp resolve_templates(%{@keyed => keyed} = rendered, template) do
keyed =
case keyed[@keyed_count] do
0 ->
keyed

count ->
for pos <- 0..(count - 1), reduce: keyed do
acc ->
case keyed[pos] do
nil -> acc
value -> Map.put(acc, pos, resolve_templates(value, template))
end
end
end

rendered
|> Map.put(@keyed, keyed)
|> Map.delete(@template)
end

defp resolve_templates(rendered, template) when is_map(rendered) and not is_struct(rendered) do
Map.new(rendered, fn {k, v} -> {k, resolve_templates(v, template)} end)
end

defp resolve_templates(other, _template), do: other

def extract_streams(%{} = source, streams) when not is_struct(source) do
Enum.reduce(source, streams, fn
{@stream_id, stream}, acc -> [stream | acc]
Expand Down
2 changes: 1 addition & 1 deletion lib/live_view_native_test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ defmodule LiveViewNativeTest do
end

defp rendered_to_diff_string(rendered, socket) do
{_, diff, _} = Diff.render(socket, rendered, Diff.new_components())
{diff, _, _} = Diff.render(socket, rendered, Diff.new_fingerprints(), Diff.new_components())
diff |> Diff.to_iodata() |> IO.iodata_to_binary()
end

Expand Down
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,13 @@ defmodule LiveViewNative.MixProject do
[
{:phoenix, "~> 1.7.21"},
{:phoenix_view, "~> 2.0"},
{:phoenix_live_view, "~> 1.0.18"},
{:phoenix_live_view, "~> 1.1.0", override: true},
Copy link
Contributor

Choose a reason for hiding this comment

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

why the override?

{:phoenix_live_reload, "~> 1.4", only: :test},
{:phoenix_template, "~> 1.0.4"},
{:phoenix_html, "~> 3.3 or ~> 4.0 or ~> 4.1"},
{:decimal, "~> 2.3", only: :test},
{:floki, ">= 0.30.0", only: :test},
{:lazy_html, ">= 0.1.0", only: :test},
{:gettext, "~> 0.24", only: :test},
{:plug, "~> 1.15"},
{:jason, "~> 1.2"},
Expand Down
12 changes: 8 additions & 4 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
%{
"castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"},
"cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"cowboy": {:hex, :cowboy, "2.14.1", "031d338393e5a128a7de9613b4a0558aabc31b07082004abecb27cac790f5cd6", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e5310d5afd478ba90b1fed4fcdbc0230082b4510009505c586725c30b44e356f"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"},
"cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"},
"expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
"file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"live_view_native_test_endpoint": {:git, "https://github.com/liveview-native/live_view_native_test_endpoint.git", "be09319cc2def0e93a2aba79d8db7ba989560afa", [branch: "main"]},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup_eex": {:hex, :makeup_eex, "1.0.0", "436d4c00204c250b17a775d64e197798aaf374627e6a4f2d3fd3074a8db61db4", [:mix], [{:makeup, "~> 1.2.1 or ~> 1.3", [hex: :makeup, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_html, "~> 0.1.0 or ~> 1.0", [hex: :makeup_html, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "3bb699bc519e4f509f1bf8a2e0ba0e08429edf3580053cd31a4f9c1bc5da86c8"},
Expand All @@ -20,9 +24,9 @@
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
"phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"},
"phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.0.18", "943431edd0ef8295ffe4949f0897e2cb25c47d3d7ebba2b008d7c68598b887f1", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "724934fd0a68ecc57281cee863674454b06163fed7f5b8005b5e201ba4b23316"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.14", "cae84abc4cd00dde4bb200b8516db556704c585c267aff9cd4955ff83cceb86c", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b827980e2bc00fddd8674e3b567519a4e855b5de04bf8607140414f1101e2627"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
Expand Down
109 changes: 109 additions & 0 deletions test/live_view_native/test/diff_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
defmodule LiveViewNative.DiffTest do
use ExUnit.Case, async: true

alias LiveViewNativeTest.ViewTree

defstruct [:foo]

describe "merge_diff" do
test "merges unless static" do
assert ViewTree.merge_diff(%{0 => "bar", s: "foo"}, %{0 => "baz"}) ==
%{0 => "baz", s: "foo", streams: []}

assert ViewTree.merge_diff(%{s: "foo", d: []}, %{s: "bar"}) ==
%{s: "bar", streams: []}
end

test "resolves moved comprehensions" do
base = %{
k: %{
0 => %{0 => "A"},
1 => %{0 => "B"},
2 => %{0 => "C", 1 => %{0 => "var1", :s => ["", ""]}},
kc: 3
}
}

diff = %{
k: %{
0 => 1,
1 => [2, %{1 => %{0 => "var2"}}],
kc: 2
}
}

result = %{
k: %{
0 => %{0 => "B"},
1 => %{0 => "C", 1 => %{0 => "var2", :s => ["", ""]}},
kc: 2
},
streams: []
}

assert ViewTree.merge_diff(base, diff) == result
end

test "no warning when keyed count is 0" do
base = %{
k: %{
0 => %{0 => "A"},
1 => %{0 => "B"},
2 => %{0 => "C", 1 => %{0 => "var1", :s => ["", ""]}},
:kc => 3
}
}

diff = %{
k: %{kc: 0}
}

result = %{
k: %{kc: 0},
streams: []
}

assert ViewTree.merge_diff(base, diff) == result
end

test "ignores structs when resolving templates" do
assert ViewTree.merge_diff(%{0 => %{}}, %{
0 => %{:s => 1, 0 => %__MODULE__{foo: :bar}},
:p => %{1 => ["foo", "bar"]}
}) == %{0 => %{0 => %__MODULE__{foo: :bar}, :s => ["foo", "bar"]}, :streams => []}
end

test "copies streams" do
base = %{
k: %{
0 => %{0 => "A"},
1 => %{0 => "B"},
2 => %{0 => "C", 1 => %{0 => "var1", :s => ["", ""]}},
kc: 3
},
stream: "foo"
}

diff = %{
k: %{
0 => 1,
1 => [2, %{1 => %{0 => "var2"}}],
kc: 2
},
stream: "bar"
}

result = %{
k: %{
0 => %{0 => "B"},
1 => %{0 => "C", 1 => %{0 => "var2", :s => ["", ""]}},
kc: 2
},
stream: "bar",
streams: ["bar"]
}

assert ViewTree.merge_diff(base, diff) == result
end
end
end
Loading