Skip to content

Commit fe9bf1c

Browse files
SteffenDEjosevalim
andauthored
introduce :key-ed :for comprehensions (#3827)
* introduce `:key`-ed `:for` comprehensions A keyed for comprehension works by transforming the comprehension's content into a LiveComponent, which will perform change-tracking and optimizes the payload over the wire: ```heex <ul> <li :for={%{id: @id, name: @name} <- @Items} :key={@id}> Count: <span>{@count}</span>, item: {@name} </li> </ul> ``` To support change tracking, keyed comprehensions require you to define the left-hand side of the `:for` comprehension, as well as the `:key`, using assign-syntax: `{@item <- @Items}` instead of `{item <- @Items}`. Because they use live components under the hood, keyed comprehensions come with the limitation of only being supported on HTML tags, so while you can use a regular `:for` comprehension on `<.function_components>`, and `<:slots>`, this is not supported keyed comprehensions and will raise an exception at compile time. * add diff test * add documentation * introduce :key-ed :for comprehensions without special syntax (#3837) * with inner_block * without inner_block * update docs * format * include module and line in key * store change tracked vars in vars * Update lib/phoenix_live_view/engine.ex * Update guides/server/assigns-eex.md Co-authored-by: José Valim <[email protected]> * simplify * include column in id * this is nice * don't classify tag again * no need for skip_require any more * ensure meta is a keyword list * don't duplicate if handling * remove moved comment * we don't need render_with * test for keyed comprehension handling * unit test * mark as root in meta * fix nested change tracking and add test --------- Co-authored-by: José Valim <[email protected]>
1 parent 7221c4b commit fe9bf1c

File tree

7 files changed

+527
-52
lines changed

7 files changed

+527
-52
lines changed

guides/server/assigns-eex.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,8 +267,15 @@ in `@posts` changes, all posts are sent again.
267267

268268
There are two common solutions to this problem.
269269

270-
The first one is to use `Phoenix.LiveComponent` for each item in the
271-
comprehension:
270+
The first one is to also provide a `:key` expression:
271+
272+
```heex
273+
<section :for={post <- @posts} :key={post.id}>
274+
<h1>{expand_title(post.title)}</h1>
275+
</section>
276+
```
277+
278+
This is functionally equivalent to doing:
272279

273280
```heex
274281
<section :for={post <- @posts}>
@@ -280,8 +287,9 @@ Since LiveComponents have their own assigns, LiveComponents would allow
280287
you to perform change tracking for each item. If the `@posts` variable
281288
changes, the client will simply send a list of component IDs (which are
282289
integers) and only the data for the posts that actually changed.
290+
You can read more about `:key` in the [documentation for `sigil_H/2`](Phoenix.Component.html#sigil_H/2-special-attributes).
283291

284-
Another solution is to use `Phoenix.LiveView.stream/4`, which gives you
292+
The second solution is to use `Phoenix.LiveView.stream/4`, which gives you
285293
precise control over how elements are added, removed, and updated. Streams
286294
are particularly useful when you don't need to keep the collection in memory,
287295
allowing you to reduce the data sent over the wire and the server memory

lib/phoenix_component.ex

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,35 @@ defmodule Phoenix.Component do
831831
Note that unlike Elixir's regular `for`, HEEx' `:for` does not support multiple
832832
generators in one expression. In such cases, you must use `EEx`'s blocks.
833833
834+
#### `:key`ed comprehensions
835+
836+
When using `:for`, you can optionally provide a `:key` expression to also perform
837+
change-tracking inside the comprehension:
838+
839+
```heex
840+
<ul>
841+
<li :for={%{id: id, name: name} <- @items} :key={id}>
842+
Count: <span>{@count}</span>,
843+
item: {name}
844+
</li>
845+
</ul>
846+
```
847+
848+
Internally, this works by turning the tag where the comprehension is defined on
849+
into the template of a `Phoenix.LiveComponent`. Because of this, the following limitations
850+
apply to comprehensions defined with `:key`:
851+
852+
1. A `:key` can only be defined on regular HTML tags, not on components or slots.
853+
2. The diff over the wire is optimized to only send changes for each item,
854+
but it will always include a list of component IDs (integers) specifying
855+
the overall order of items.
856+
3. Removing an entry involves separate round-trips with the client to confirm
857+
the component removal.
858+
859+
We recommend to use `:key`ed comprehensions only if you already determined that you need
860+
to optimize the diff over the write and [streams](`Phoenix.LiveView.stream/4`)
861+
are not an option.
862+
834863
### Function components
835864
836865
Function components are stateless components implemented as pure functions

lib/phoenix_live_view/engine.ex

Lines changed: 123 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,8 @@ defmodule Phoenix.LiveView.Engine do
297297
mind the collection itself is not "diffed" across renders.
298298
If one entry in the comprehension changes, the whole collection
299299
is sent again. Consider using `Phoenix.LiveComponent` and
300-
`Phoenix.LiveView.stream/4` to optimize those cases.
300+
`Phoenix.LiveView.stream/4` to optimize those cases, or see the
301+
[`:key` attribute when using :for`](Phoenix.Component.html#sigil_H/2-special-attributes).
301302
302303
The list of dynamics is always a list of iodatas or components,
303304
as we don't perform change tracking inside the comprehensions
@@ -422,6 +423,8 @@ defmodule Phoenix.LiveView.Engine do
422423
end
423424
end
424425

426+
root = Keyword.get(opts, :root, meta[:root])
427+
425428
{:ok,
426429
quote do
427430
dynamic = fn track_changes? ->
@@ -434,7 +437,7 @@ defmodule Phoenix.LiveView.Engine do
434437
static: unquote(static),
435438
dynamic: dynamic,
436439
fingerprint: unquote(fingerprint),
437-
root: unquote(opts[:root])
440+
root: unquote(root)
438441
}
439442
end}
440443
else
@@ -618,20 +621,34 @@ defmodule Phoenix.LiveView.Engine do
618621

619622
defp changed_assigns(assigns) do
620623
checks =
621-
for {key, _} <- assigns, not nested_and_parent_is_checked?(key, assigns) do
624+
for {{changed_var, key}, _} <- assigns, not nested_and_parent_is_checked?(key, assigns) do
625+
changed = Macro.var(changed_var, __MODULE__)
626+
622627
case key do
623628
[assign] ->
624629
quote do
625-
unquote(__MODULE__).changed_assign?(changed, unquote(assign))
630+
unquote(__MODULE__).changed_assign?(unquote(changed), unquote(assign))
626631
end
627632

628633
[assign | tail] ->
634+
assigns_var =
635+
case changed_var do
636+
:changed ->
637+
@assigns_var
638+
639+
:vars_changed ->
640+
# we pass a map %{var: var} for nested change tracking
641+
quote do
642+
%{unquote(assign) => unquote(Macro.var(assign, nil))}
643+
end
644+
end
645+
629646
quote do
630647
unquote(__MODULE__).nested_changed_assign?(
631648
unquote(tail),
632649
unquote(assign),
633-
unquote(@assigns_var),
634-
changed
650+
unquote(assigns_var),
651+
unquote(changed)
635652
)
636653
end
637654
end
@@ -700,8 +717,25 @@ defmodule Phoenix.LiveView.Engine do
700717
keys != %{},
701718
do: {key, to_component_keys(keys)}
702719

720+
has_vars_changed? =
721+
Enum.any?(keys, fn {_name, entries} ->
722+
is_list(entries) and Enum.any?(entries, &match?({:vars_changed, _}, &1))
723+
end)
724+
725+
vars_changed =
726+
if has_vars_changed? do
727+
Macro.var(:vars_changed, __MODULE__)
728+
else
729+
[]
730+
end
731+
703732
quote do
704-
unquote(__MODULE__).to_component_static(unquote(keys), unquote(@assigns_var), changed)
733+
unquote(__MODULE__).to_component_static(
734+
unquote(keys),
735+
unquote(@assigns_var),
736+
changed,
737+
unquote(vars_changed)
738+
)
705739
end
706740
else
707741
Macro.escape(%{})
@@ -733,14 +767,24 @@ defmodule Phoenix.LiveView.Engine do
733767
true ->
734768
{_, keys, _} = analyze_and_return_tainted_keys(dynamic, vars, %{}, caller)
735769

770+
has_vars_changed? = keys != :all and Enum.any?(keys, &match?({:vars_changed, _}, &1))
771+
772+
vars_changed =
773+
if has_vars_changed? do
774+
Macro.var(:vars_changed, __MODULE__)
775+
else
776+
[]
777+
end
778+
736779
quote do
737780
unquote(__MODULE__).to_component_dynamic(
738781
%{unquote_splicing(static)},
739782
unquote(dynamic),
740783
unquote(static_changed),
741784
unquote(to_component_keys(keys)),
742785
unquote(@assigns_var),
743-
changed
786+
changed,
787+
unquote(vars_changed)
744788
)
745789
end
746790
end
@@ -750,25 +794,25 @@ defmodule Phoenix.LiveView.Engine do
750794
defp to_component_keys(map), do: Map.keys(map)
751795

752796
@doc false
753-
def to_component_static(_keys, _assigns, nil) do
797+
def to_component_static(_keys, _assigns, nil, []) do
754798
nil
755799
end
756800

757-
def to_component_static(keys, assigns, changed) do
801+
def to_component_static(keys, assigns, changed, vars_changed) do
758802
for {assign, entries} <- keys,
759-
changed = component_changed(entries, assigns, changed),
803+
changed = component_changed(entries, assigns, changed, vars_changed),
760804
into: %{},
761805
do: {assign, changed}
762806
end
763807

764808
@doc false
765-
def to_component_dynamic(static, dynamic, _static_changed, _keys, _assigns, nil) do
809+
def to_component_dynamic(static, dynamic, _static_changed, _keys, _assigns, nil, []) do
766810
merge_dynamic_static_changed(dynamic, static, nil)
767811
end
768812

769-
def to_component_dynamic(static, dynamic, static_changed, keys, assigns, changed) do
813+
def to_component_dynamic(static, dynamic, static_changed, keys, assigns, changed, vars_changed) do
770814
component_changed =
771-
if component_changed(keys, assigns, changed) do
815+
if component_changed(keys, assigns, changed, vars_changed) do
772816
Enum.reduce(dynamic, static_changed, fn {k, _}, acc -> Map.put(acc, k, true) end)
773817
else
774818
static_changed
@@ -781,19 +825,23 @@ defmodule Phoenix.LiveView.Engine do
781825
dynamic |> Map.merge(static) |> Map.put(:__changed__, changed)
782826
end
783827

784-
defp component_changed(:all, _assigns, _changed), do: true
828+
defp component_changed(:all, _assigns, _changed, _vars_changed), do: true
785829

786-
defp component_changed([path], assigns, changed) do
830+
defp component_changed([path], assigns, changed, vars_changed) do
787831
case path do
788-
[key] -> changed_assign(changed, key)
789-
[key | tail] -> nested_changed_assign(tail, key, assigns, changed)
832+
{:changed, [key]} -> changed_assign(changed, key)
833+
{:changed, [key | tail]} -> nested_changed_assign(tail, key, assigns, changed)
834+
{:vars_changed, [key]} -> changed_assign(vars_changed, key)
835+
{:vars_changed, [key | tail]} -> nested_changed_assign(tail, key, assigns, vars_changed)
790836
end
791837
end
792838

793-
defp component_changed(entries, assigns, changed) do
839+
defp component_changed(entries, assigns, changed, vars_changed) do
794840
Enum.any?(entries, fn
795-
[key] -> changed_assign?(changed, key)
796-
[key | tail] -> nested_changed_assign?(tail, key, assigns, changed)
841+
{:changed, [key]} -> changed_assign?(changed, key)
842+
{:changed, [key | tail]} -> nested_changed_assign?(tail, key, assigns, changed)
843+
{:vars_changed, [key]} -> changed_assign?(vars_changed, key)
844+
{:vars_changed, [key | tail]} -> nested_changed_assign?(tail, key, assigns, vars_changed)
797845
end)
798846
end
799847

@@ -887,11 +935,29 @@ defmodule Phoenix.LiveView.Engine do
887935
{ast, keys, vars}
888936
end
889937

938+
# if we find a variable (or something more complex handled by the other clauses)
939+
# like foo[:bar][:baz] and foo is marked as :change_track in vars, we consider it
940+
# as an assign, but look into vars_changed instead of changed
941+
defp analyze_assign(
942+
{name, _, context} = expr,
943+
{type, map} = vars,
944+
assigns,
945+
_caller,
946+
nest
947+
)
948+
when is_atom(name) and is_atom(context) and is_map_key(map, name) and type != :tainted do
949+
if map[name] == :change_track do
950+
{expr, vars, Map.put(assigns, {:vars_changed, [name | nest]}, true)}
951+
else
952+
{expr, vars, assigns}
953+
end
954+
end
955+
890956
# @name
891957
defp analyze_assign({:@, meta, [{name, _, context}]}, vars, assigns, _caller, nest)
892958
when is_atom(name) and is_atom(context) do
893959
expr = {{:., meta, [@assigns_var, name]}, [no_parens: true] ++ meta, []}
894-
{expr, vars, Map.put(assigns, [name | nest], true)}
960+
{expr, vars, Map.put(assigns, {:changed, [name | nest]}, true)}
895961
end
896962

897963
# assigns.name
@@ -903,7 +969,7 @@ defmodule Phoenix.LiveView.Engine do
903969
nest
904970
)
905971
when is_atom(name) and args in [[], nil] do
906-
{expr, vars, Map.put(assigns, [name | nest], true)}
972+
{expr, vars, Map.put(assigns, {:changed, [name | nest]}, true)}
907973
end
908974

909975
# assigns[:name]
@@ -915,7 +981,7 @@ defmodule Phoenix.LiveView.Engine do
915981
nest
916982
)
917983
when is_atom(name) and is_access(access) do
918-
{expr, vars, Map.put(assigns, [name | nest], true)}
984+
{expr, vars, Map.put(assigns, {:changed, [name | nest]}, true)}
919985
end
920986

921987
# Maybe: assigns.foo[:bar]
@@ -984,20 +1050,39 @@ defmodule Phoenix.LiveView.Engine do
9841050
{expr, vars, assigns}
9851051
end
9861052

987-
# Vars always taint unless we are in restricted mode.
988-
defp analyze({name, meta, nil} = expr, {:restricted, map}, assigns, caller)
1053+
# Vars always taint unless we are in restricted mode
1054+
# or the variable is marked as `:change_track` for vars_changed.
1055+
defp analyze({name, meta, nil} = expr, {:restricted, map} = vars, assigns, caller)
9891056
when is_atom(name) do
990-
if Map.has_key?(map, name) do
991-
maybe_warn_taint(name, meta, caller)
992-
{expr, {:tainted, map}, assigns}
993-
else
994-
{expr, {:restricted, map}, assigns}
1057+
case map do
1058+
%{^name => :tainted} ->
1059+
maybe_warn_taint(name, meta, caller)
1060+
{expr, {:tainted, map}, assigns}
1061+
1062+
%{^name => :change_track} ->
1063+
{expr, vars, Map.put(assigns, {:vars_changed, [name]}, true)}
1064+
1065+
_ ->
1066+
{expr, {:restricted, map}, assigns}
9951067
end
9961068
end
9971069

998-
defp analyze({name, meta, nil} = expr, {_, map}, assigns, caller) when is_atom(name) do
999-
maybe_warn_taint(name, meta, caller)
1000-
{expr, {:tainted, Map.put(map, name, true)}, assigns}
1070+
defp analyze({name, meta, nil} = expr, {type, map}, assigns, caller)
1071+
when is_atom(name) do
1072+
cond do
1073+
Map.get(map, name) == :change_track ->
1074+
{expr, {type, map}, Map.put(assigns, {:vars_changed, [name]}, true)}
1075+
1076+
Keyword.get(meta, :change_track) ->
1077+
# this is a variable inside the left-hand side of a keyed for expression;
1078+
# we mark it as change_track in the vars map so that we treat it as change-tracked
1079+
# when we see it used again later (see the previous analyze clause above)
1080+
{expr, {type, Map.put(map, name, :change_track)}, assigns}
1081+
1082+
true ->
1083+
maybe_warn_taint(name, meta, caller)
1084+
{expr, {:tainted, Map.put(map, name, :tainted)}, assigns}
1085+
end
10011086
end
10021087

10031088
# Quoted vars are ignored as they come from engine code.
@@ -1334,8 +1419,11 @@ defmodule Phoenix.LiveView.Engine do
13341419
defp classify_taint(:with, [_ | _]), do: :live
13351420
defp classify_taint(:for, [_ | _]), do: :live
13361421

1337-
# Constructs from Phoenix and TagEngine
1422+
# Constructs from TagEngine
13381423
defp classify_taint(:inner_block, [_, [do: _]]), do: :live
1424+
defp classify_taint(:keyed_comprehension, [_, _, [do: _]]), do: :live
1425+
1426+
# Constructs from Phoenix.View
13391427
defp classify_taint(:render_layout, [_, _, _, [do: _]]), do: :live
13401428

13411429
# Special forms are forbidden and raise.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
defmodule Phoenix.LiveView.KeyedComprehension do
2+
@moduledoc false
3+
4+
use Phoenix.LiveComponent
5+
6+
@impl true
7+
def update(assigns, socket) do
8+
# we assign all entries from vars_changed to change-track them inside
9+
# the LiveComponent
10+
socket =
11+
Enum.reduce(assigns.vars_changed, socket, fn {key, value}, socket ->
12+
assign(socket, key, value)
13+
end)
14+
15+
socket
16+
|> assign(:render, assigns.render)
17+
|> assign(:keys, Map.keys(assigns.vars_changed))
18+
|> then(&{:ok, &1})
19+
end
20+
21+
@impl true
22+
def render(assigns) do
23+
vars_changed = Map.take(assigns.__changed__, assigns.keys)
24+
assigns.render.(vars_changed)
25+
end
26+
end

0 commit comments

Comments
 (0)