Packages
phoenix_live_view
1.0.15
1.2.7
1.2.6
1.2.5
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.2.0-rc.3
1.2.0-rc.2
1.2.0-rc.1
1.2.0-rc.0
1.1.32
1.1.31
1.1.30
1.1.29
1.1.28
1.1.27
1.1.26
1.1.25
1.1.24
1.1.23
1.1.22
1.1.21
1.1.20
1.1.19
1.1.18
1.1.17
1.1.16
1.1.15
1.1.14
1.1.13
1.1.12
1.1.11
1.1.10
1.1.9
1.1.8
1.1.7
1.1.6
retired
1.1.5
1.1.4
1.1.3
1.1.2
1.1.1
1.1.0
1.1.0-rc.4
1.1.0-rc.3
1.1.0-rc.2
1.1.0-rc.1
1.1.0-rc.0
1.0.18
1.0.17
1.0.16
1.0.15
1.0.14
1.0.13
1.0.12
1.0.11
1.0.10
1.0.9
1.0.8
retired
1.0.7
1.0.6
retired
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
1.0.0-rc.9
1.0.0-rc.8
1.0.0-rc.7
1.0.0-rc.6
1.0.0-rc.5
1.0.0-rc.4
1.0.0-rc.3
1.0.0-rc.2
1.0.0-rc.1
1.0.0-rc.0
0.20.17
0.20.16
0.20.15
0.20.14
0.20.13
0.20.12
0.20.11
0.20.10
0.20.9
0.20.8
0.20.7
0.20.6
0.20.5
0.20.4
0.20.3
0.20.2
0.20.1
0.20.0
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.18
0.18.17
0.18.16
0.18.15
0.18.14
0.18.13
0.18.12
0.18.11
0.18.10
0.18.9
0.18.8
0.18.7
0.18.6
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.14
0.17.13
0.17.12
0.17.11
0.17.10
0.17.9
0.17.8
0.17.7
0.17.6
0.17.5
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.8
0.14.7
0.14.6
0.14.5
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.1
0.12.0
0.11.1
0.11.0
0.10.0
0.9.0
0.8.1
0.8.0
0.7.1
0.7.0
0.6.0
0.6.0-dev
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.1
0.3.0
0.2.1
0.2.0
0.1.1
0.1.0
Rich, real-time user experiences with server-rendered HTML
Current section
Files
Jump to
Current section
Files
lib/phoenix_live_view/test/dom.ex
defmodule Phoenix.LiveViewTest.DOM do
@moduledoc false
@phx_static "data-phx-static"
@phx_component "data-phx-component"
@static :s
@components :c
@stream_id :stream
def ensure_loaded! do
if not Code.ensure_loaded?(Floki) do
raise """
Phoenix LiveView requires Floki as a test dependency.
Please add to your mix.exs:
{:floki, ">= 0.30.0", only: :test}
"""
end
end
@spec parse(binary) :: [
{:comment, binary}
| {:pi | binary, binary | list, list}
| {:doctype, binary, binary, binary}
]
def parse(html, error_reporter \\ nil) do
{:ok, parsed} = Floki.parse_document(html)
if is_function(error_reporter, 1) do
detect_duplicate_ids(parsed, error_reporter)
end
parsed
end
defp detect_duplicate_ids(tree, error_reporter),
do: detect_duplicate_ids(tree, tree, MapSet.new(), error_reporter)
defp detect_duplicate_ids(tree, [node | rest], ids, error_reporter) do
ids = detect_duplicate_ids(tree, node, ids, error_reporter)
detect_duplicate_ids(tree, rest, ids, error_reporter)
end
# ignore declarations
defp detect_duplicate_ids(_tree, {:pi, _type, _attrs}, seen_ids, _error_reporter), do: seen_ids
defp detect_duplicate_ids(tree, {_tag_name, _attrs, children} = node, ids, error_reporter) do
case Floki.attribute(node, "id") do
[id] ->
if MapSet.member?(ids, id) do
error_reporter.("""
Duplicate id found while testing LiveView: #{id}
#{inspect_html(all(tree, "[id=#{id}]"))}
LiveView requires that all elements have unique ids, duplicate IDs will cause
undefined behavior at runtime, as DOM patching will not be able to target the correct
elements.
""")
end
detect_duplicate_ids(tree, children, MapSet.put(ids, id), error_reporter)
_ ->
detect_duplicate_ids(tree, children, ids, error_reporter)
end
end
defp detect_duplicate_ids(_tree, _non_tag, seen_ids, _error_reporter), do: seen_ids
defp detect_duplicate_components(tree, cids, error_reporter) do
cids
|> Enum.frequencies()
|> Enum.each(fn {cid, count} ->
if count > 1 do
error_reporter.("""
Duplicate live component found while testing LiveView:
#{inspect_html(all(tree, "[#{@phx_component}=#{cid}]"))}
This most likely means that you are conditionally rendering the same
LiveComponent multiple times with the same ID in the same LiveView.
This is not supported and will lead to broken behavior on the client.
""")
end
end)
end
def all(html_tree, selector), do: Floki.find(html_tree, selector)
def maybe_one(html_tree, selector, type \\ :selector) do
case all(html_tree, selector) do
[node] ->
{:ok, node}
[] ->
{:error, :none,
"expected #{type} #{inspect(selector)} to return a single element, but got none " <>
"within: \n\n" <> inspect_html(html_tree)}
many ->
{:error, :many,
"expected #{type} #{inspect(selector)} to return a single element, " <>
"but got #{length(many)}: \n\n" <> inspect_html(many)}
end
end
def targets_from_node(tree, node) do
case node && all_attributes(node, "phx-target") do
nil -> [nil]
[] -> [nil]
[selector] -> targets_from_selector(tree, selector)
end
end
def targets_from_selector(tree, selector)
def targets_from_selector(_tree, nil), do: [nil]
def targets_from_selector(_tree, cid) when is_integer(cid), do: [cid]
def targets_from_selector(tree, selector) when is_binary(selector) do
case Integer.parse(selector) do
{cid, ""} ->
[cid]
_ ->
case all(tree, selector) do
[] ->
[nil]
elements ->
for element <- elements do
if cid = component_id(element) do
String.to_integer(cid)
end
end
end
end
end
def all_attributes(html_tree, name), do: Floki.attribute(html_tree, name)
def all_values({_, attributes, _}) do
for {attr, value} <- attributes, key = value_key(attr), do: {key, value}, into: %{}
end
def inspect_html(nodes) when is_list(nodes) do
for dom_node <- nodes, into: "", do: inspect_html(dom_node)
end
def inspect_html(dom_node),
do: " " <> String.replace(to_html(dom_node), "\n", "\n ") <> "\n"
defp value_key("phx-value-" <> key), do: key
defp value_key("value"), do: "value"
defp value_key(_), do: nil
def tag(node), do: elem(node, 0)
def attribute(node, key) do
with {tag, attrs, _children} when is_binary(tag) <- node,
{_, value} <- List.keyfind(attrs, key, 0) do
value
else
_ -> nil
end
end
def to_html(html_tree), do: Floki.raw_html(html_tree)
def to_text(html_tree), do: Floki.text(html_tree)
# TODO: rewrite to use Floki.get_by_id/2
# currently it does not raise when multiple elements are found
def by_id!(html_tree, id) do
case maybe_one(html_tree, "#" <> id) do
{:ok, node} -> node
{:error, _, message} -> raise message
end
end
def child_nodes({_, _, nodes}), do: nodes
def attrs({_, attrs, _}), do: attrs
def inner_html!(html_tree, id), do: html_tree |> by_id!(id) |> child_nodes()
def component_id(html_tree), do: Floki.attribute(html_tree, @phx_component) |> List.first()
@doc """
Find static information in the given HTML tree.
"""
def find_static_views(html_tree) do
html_tree
|> all("[#{@phx_static}]")
|> Enum.into(%{}, fn node ->
{attribute(node, "id"), attribute(node, @phx_static)}
end)
end
@doc """
Find live views in the given HTML tree.
"""
def find_live_views(html_tree) do
html_tree
|> all("[data-phx-session]")
|> Enum.reduce([], fn node, acc ->
id = attribute(node, "id")
static = attribute(node, "data-phx-static")
session = attribute(node, "data-phx-session")
main = attribute(node, "data-phx-main")
static = if static in [nil, ""], do: nil, else: static
found = {id, session, static}
if main not in [nil, "", "false"] do
acc ++ [found]
else
[found | acc]
end
end)
|> Enum.reverse()
end
@doc """
Deep merges two maps.
"""
def deep_merge(%{} = target, %{} = source),
do: Map.merge(target, source, fn _, t, s -> deep_merge(t, s) end)
def deep_merge(_target, source),
do: source
@doc """
Filters nodes according to `fun`.
"""
def filter(node, fun) do
node |> reverse_filter(fun) |> Enum.reverse()
end
@doc """
Filters nodes and returns them in reverse order.
"""
def reverse_filter(node, fun) do
node
|> Floki.traverse_and_update([], fn node, acc ->
if fun.(node), do: {node, [node | acc]}, else: {node, acc}
end)
|> elem(1)
end
@doc """
Removes stream children from the given HTML tree.
"""
def remove_stream_children(html_tree) do
walk(html_tree, fn {tag, attrs, children} = node ->
if attribute(node, "phx-update") == "stream" do
{tag, attrs, []}
else
{tag, attrs, children}
end
end)
end
# Diff merging
def merge_diff(rendered, diff) do
old = Map.get(rendered, @components, %{})
# must extract streams from diff before we pop components
streams = extract_streams(diff, [])
{new, diff} = Map.pop(diff, @components)
rendered = deep_merge_diff(rendered, diff)
# If we have any component, we need to get the components
# sent by the diff and remove any link between components
# statics. We cannot let those links reside in the diff
# as components can be removed at any time.
rendered =
cond do
new ->
{acc, _} =
Enum.reduce(new, {old, %{}}, fn {cid, cdiff}, {acc, cache} ->
{value, cache} = find_component(cid, cdiff, old, new, cache)
{Map.put(acc, cid, value), cache}
end)
Map.put(rendered, @components, acc)
old != %{} ->
Map.put(rendered, @components, old)
true ->
rendered
end
Map.put(rendered, :streams, streams)
end
defp find_component(cid, cdiff, old, new, cache) do
case cache do
%{^cid => cached} ->
{cached, cache}
%{} ->
{res, cache} =
case cdiff do
%{@static => cid} when is_integer(cid) and cid > 0 ->
{res, cache} = find_component(cid, new[cid], old, new, cache)
{deep_merge_diff(res, Map.delete(cdiff, @static)), cache}
%{@static => cid} when is_integer(cid) and cid < 0 ->
{deep_merge_diff(old[-cid], Map.delete(cdiff, @static)), cache}
%{} ->
{deep_merge_diff(Map.get(old, cid, %{}), cdiff), cache}
end
{res, Map.put(cache, cid, res)}
end
end
def drop_cids(rendered, cids) do
update_in(rendered[@components], &Map.drop(&1, cids))
end
defp deep_merge_diff(_target, %{@static => _} = source),
do: source
defp deep_merge_diff(%{} = target, %{} = source),
do: Map.merge(target, source, fn _, t, s -> deep_merge_diff(t, s) end)
defp deep_merge_diff(_target, source),
do: source
def extract_streams(%{} = source, streams) when not is_struct(source) do
Enum.reduce(source, streams, fn
{@stream_id, stream}, acc -> [stream | acc]
{_key, value}, acc -> extract_streams(value, acc)
end)
end
# streams can also be in the dynamic part of the diff
def extract_streams(source, streams) when is_list(source) do
Enum.reduce(source, streams, fn el, acc -> extract_streams(el, acc) end)
end
def extract_streams(_value, acc), do: acc
# Diff rendering
def render_diff(rendered) do
rendered
|> Phoenix.LiveView.Diff.to_iodata(&add_cid_attr/2)
|> IO.iodata_to_binary()
|> parse()
|> List.wrap()
end
defp add_cid_attr(cid, [head | tail]) do
head_with_cid =
Regex.replace(
~r/^(\s*(?:<!--.*?-->\s*)*)<([^\s\/>]+)/,
IO.iodata_to_binary(head),
"\\0 #{@phx_component}=\"#{to_string(cid)}\"",
global: false
)
[head_with_cid | tail]
end
# Patching
def patch_id(id, html_tree, inner_html, streams, error_reporter \\ nil) do
cids_before = component_ids(id, html_tree)
phx_update_tree =
walk(inner_html, fn node ->
apply_phx_update(attribute(node, "phx-update"), html_tree, node, streams)
end)
new_html =
walk(html_tree, fn {tag, attrs, children} = node ->
if attribute(node, "id") == id do
{tag, attrs, phx_update_tree}
else
{tag, attrs, children}
end
end)
cids_after = component_ids(id, new_html)
if is_function(error_reporter, 1) do
detect_duplicate_ids(new_html, error_reporter)
detect_duplicate_components(new_html, cids_after, error_reporter)
end
{new_html, cids_before -- cids_after}
end
def component_ids(id, html_tree) do
by_id!(html_tree, id)
|> Floki.children()
|> Enum.reduce([], &traverse_component_ids/2)
end
def replace_root_container(container_html, new_tag, attrs) do
reserved_attrs = ~w(id data-phx-session data-phx-static data-phx-main)
[{_container_tag, container_attrs_list, children}] = container_html
container_attrs = Enum.into(container_attrs_list, %{})
merged_attrs =
attrs
|> Enum.map(fn {attr, value} -> {String.downcase(to_string(attr)), value} end)
|> Enum.filter(fn {attr, _value} -> attr not in reserved_attrs end)
|> Enum.reduce(container_attrs_list, fn {attr, new_val}, acc ->
if Map.has_key?(container_attrs, attr) do
Enum.map(acc, fn
{^attr, _old_val} -> {attr, new_val}
{_, _} = other -> other
end)
else
acc ++ [{attr, new_val}]
end
end)
[{to_string(new_tag), merged_attrs, children}]
end
defp traverse_component_ids(current, acc) do
acc =
if id = attribute(current, @phx_component) do
[String.to_integer(id) | acc]
else
acc
end
cond do
attribute(current, @phx_static) ->
acc
children = Floki.children(current) ->
Enum.reduce(children, acc, &traverse_component_ids/2)
true ->
acc
end
end
defp apply_phx_update(type, html_tree, {tag, attrs, appended_children} = node, _streams)
when type in ["append", "prepend"] do
container_id = attribute(node, "id")
verify_phx_update_id!(type, container_id, node)
children_before = apply_phx_update_children(html_tree, container_id)
existing_ids = apply_phx_update_children_id(type, children_before)
new_ids = apply_phx_update_children_id(type, appended_children)
content_changed? =
new_ids != existing_ids
dup_ids =
if content_changed? && new_ids do
Enum.filter(new_ids, fn id -> id in existing_ids end)
else
[]
end
{updated_existing_children, updated_appended} =
Enum.reduce(dup_ids, {children_before, appended_children}, fn dup_id, {before, appended} ->
patched_before =
walk(before, fn {tag, _, _} = node ->
cond do
attribute(node, "id") == dup_id ->
new_node = by_id!(appended, dup_id)
{tag, attrs(new_node), child_nodes(new_node)}
true ->
node
end
end)
{patched_before, Floki.filter_out(appended, "##{dup_id}")}
end)
cond do
content_changed? && type == "append" ->
{tag, attrs, updated_existing_children ++ updated_appended}
content_changed? && type == "prepend" ->
{tag, attrs, updated_appended ++ updated_existing_children}
!content_changed? ->
{tag, attrs, updated_appended}
end
end
defp apply_phx_update("stream", html_tree, {tag, attrs, appended_children} = node, streams) do
container_id = attribute(node, "id")
verify_phx_update_id!("stream", container_id, node)
children_before = apply_phx_update_children(html_tree, container_id)
appended_children = appended_children
# to ensure correct DOM patching, all elements must have an ID
_ = apply_phx_update_children_id("stream", children_before)
_ = apply_phx_update_children_id("stream", appended_children)
streams =
Enum.map(streams, fn [ref, inserts, deleteIds | maybe_reset] ->
%{ref: ref, inserts: inserts, deleteIds: deleteIds, reset: maybe_reset == [true]}
end)
streamInserts =
Enum.reduce(streams, %{}, fn %{ref: ref, inserts: inserts}, acc ->
Enum.reduce(inserts, acc, fn [id, stream_at, limit], acc ->
Map.put(acc, id, %{ref: ref, stream_at: stream_at, limit: limit})
end)
end)
# for each stream, reset if necessary and apply deletes
# (this corresponds to the this.streams.forEach loop in dom_patch.js)
filtered_children_before =
Enum.reduce(streams, children_before, fn stream, acc -> apply_stream(acc, stream) end)
# now apply the DOM patching (this corresponds mainly to the appendChild in dom_patch.js)
new_children =
Enum.reduce(appended_children, filtered_children_before, fn node, acc ->
id = attribute(node, "id")
insert = streamInserts[id]
current_index = Enum.find_index(acc, fn node -> attribute(node, "id") == id end)
new_children =
cond do
is_nil(insert) and is_nil(current_index) ->
# the element is not part of the stream inserts, so we append it at the end
# (see dom_patch.js addChild)
acc ++ [node]
is_nil(insert) && current_index ->
# not a stream item, but already in the DOM -> update in place
List.replace_at(acc, current_index, node)
current_index ->
# update stream item in place
List.replace_at(acc, current_index, set_attr(node, "data-phx-stream", insert.ref))
true ->
# stream item to be inserted at specific position
List.insert_at(acc, insert.stream_at, set_attr(node, "data-phx-stream", insert.ref))
end
maybe_apply_stream_limit(new_children, insert)
end)
{tag, attrs, new_children}
end
defp apply_phx_update("ignore", html_tree, node, _streams) do
container_id = attribute(node, "id")
verify_phx_update_id!("ignore", container_id, node)
{new_tag, new_attrs, new_children} = node
{tag, attrs_before, children_before} =
case by_id(html_tree, container_id) do
{_tag, _attrs_before, _children_before} = triplet -> triplet
nil -> {new_tag, new_attrs, new_children}
end
merged_attrs =
Enum.reject(attrs_before, fn {name, _} -> String.starts_with?(name, "data-") end) ++
Enum.filter(new_attrs, fn {name, _} -> String.starts_with?(name, "data-") end)
{tag, merged_attrs, children_before}
end
defp apply_phx_update(type, _state, node, _streams) when type in [nil, "replace"] do
node
end
defp apply_phx_update(other, _state, _node, _streams) do
raise ArgumentError,
"invalid phx-update value #{inspect(other)}, " <>
"expected one of \"stream\", \"replace\", \"append\", \"prepend\", \"ignore\""
end
defp apply_stream(existing_children, stream) do
children =
if stream.reset do
Enum.reject(existing_children, fn node ->
attribute(node, "data-phx-stream") == stream.ref
end)
else
existing_children
end
Enum.filter(children, fn node ->
attribute(node, "id") not in stream.deleteIds
end)
end
defp maybe_apply_stream_limit(children, %{limit: limit}) when is_integer(limit) do
Enum.take(children, limit)
end
defp maybe_apply_stream_limit(children, _maybe_insert), do: children
defp verify_phx_update_id!(type, id, node) when id in ["", nil] do
raise ArgumentError,
"setting phx-update to #{inspect(type)} requires setting an ID on the container, " <>
"got: \n\n #{inspect_html(node)}"
end
defp verify_phx_update_id!(_type, _id, _node) do
:ok
end
defp apply_phx_update_children(html_tree, id) do
case by_id(html_tree, id) do
{_, _, children_before} -> children_before
nil -> []
end
end
defp apply_phx_update_children_id(type, children) do
for {tag, _, _} = child when is_binary(tag) <- children do
attribute(child, "id") ||
raise ArgumentError,
"setting phx-update to #{inspect(type)} requires setting an ID on each child. " <>
"No ID was found on:\n\n#{to_html(child)}"
end
end
## Helpers
defp walk(html_tree, fun) when is_function(fun, 1) do
Floki.traverse_and_update(html_tree, walk_fun(fun))
end
defp walk_fun(fun) when is_function(fun, 1) do
fn
text when is_binary(text) -> text
{:pi, _, _} = xml -> xml
{:comment, _children} = comment -> comment
{:doctype, _, _, _} = doctype -> doctype
{_tag, _attrs, _children} = node -> fun.(node)
end
end
defp by_id(html_tree, id) do
Floki.get_by_id(html_tree, id)
end
def parent_id(html_tree, child_id) do
try do
walk(html_tree, fn {tag, attrs, children} = node ->
parent_id = attribute(node, "id")
if parent_id && Enum.find(children, fn child -> attribute(child, "id") == child_id end) do
throw(parent_id)
else
{tag, attrs, children}
end
end)
nil
catch
:throw, parent_id -> parent_id
end
end
def set_attr({tag, attrs, children} = _el, name, val) do
new_attrs =
attrs
|> Enum.filter(fn {existing_name, _} -> existing_name != name end)
|> Kernel.++([{name, val}])
{tag, new_attrs, children}
end
defmacro sigil_X({:<<>>, _, [binary]}, []) when is_binary(binary) do
Macro.escape(parse_sorted!(binary))
end
defmacro sigil_x(term, []) do
quote do
unquote(__MODULE__).parse_sorted!(unquote(term))
end
end
def t2h(template) do
template
|> Phoenix.LiveViewTest.rendered_to_string()
|> parse_sorted!()
end
@doc """
Parses HTML into Floki format with sorted attributes.
"""
def parse_sorted!(value) do
value
|> Floki.parse_fragment!()
|> Enum.map(&normalize_attribute_order/1)
end
defp normalize_attribute_order({node_type, attributes, content}),
do: {node_type, Enum.sort(attributes), Enum.map(content, &normalize_attribute_order/1)}
defp normalize_attribute_order(values) when is_list(values),
do: Enum.map(values, &normalize_attribute_order/1)
defp normalize_attribute_order(value), do: value
end