defmodule LiveSolidJS do @moduledoc """ See READ.md for installation instructions and examples. """ use Phoenix.Component import Phoenix.HTML alias Phoenix.LiveView alias Phoenix.LiveView.LiveStream alias LiveSolidJS.SSR alias LiveSolidJS.Slots alias LiveSolidJS.Encoder require Logger @ssr_default Application.compile_env(:live_solidjs, :ssr, true) @doc """ Render a SolidJS component. """ def solid(assigns) do init = assigns.__changed__ == nil dead = assigns[:socket] == nil or not LiveView.connected?(assigns[:socket]) render_ssr? = init and dead and Map.get(assigns, :ssr, @ssr_default) use_streams = Enum.any?(assigns, fn {_k, v} -> match?(%LiveStream{}, v) end) require Logger Logger.debug("solid/1: init=#{inspect(init)}, dead=#{inspect(dead)}, connected?=#{inspect(assigns[:socket] && LiveView.connected?(assigns[:socket]))}") # we manually compute __changed__ for the computed props and slots so it's not sent without reason {props, props_changed?} = extract(assigns, :props) {streams, _streams_changed?} = extract(assigns, :streams) {slots, slots_changed?} = extract(assigns, :slots) component_name = Map.get(assigns, :name) # Calculate streams diff for Phoenix Streams support streams_diff = if use_streams, do: calculate_streams_diff(streams, init or dead), else: [] # On initial render: include stream keys initialized as empty arrays in props # On subsequent renders: exclude stream keys from props entirely (streams are sent via streams_diff only) props_to_send = if init or dead do # Initial render: include stream keys as empty arrays Enum.reduce(streams, props, fn {key, _}, acc -> Map.put_new(acc, key, []) end) else # Subsequent renders: exclude stream keys completely stream_keys = Map.keys(streams) Map.drop(props, stream_keys) end Logger.debug("solid/1: #{if init or dead, do: "INITIAL", else: "SUBSEQUENT"} render - props_to_send keys: #{inspect(Map.keys(props_to_send))}") assigns = assigns |> Map.put_new(:class, nil) |> Map.put(:__component_name, component_name) |> Map.put(:props, Encoder.encode(props_to_send)) |> Map.put(:props_changed?, props_changed?) |> Map.put(:streams_diff, Enum.map(streams_diff, &prepare_diff/1)) |> Map.put(:slots, if(slots_changed?, do: Slots.rendered_slot_map(slots), else: %{})) assigns = Map.put(assigns, :ssr_render, if(render_ssr?, do: ssr_render(assigns), else: nil)) computed_changed = %{ props: props_changed?, slots: slots_changed?, ssr_render: render_ssr?, streams_diff: use_streams } assigns = update_in(assigns.__changed__, fn nil -> nil changed -> for {k, true} <- computed_changed, into: changed, do: {k, true} end) # It's important to not add extra `\n` in the inner div or it will break hydration ~H"""
Slots.base_encode_64 |> json}"} data-ssr={is_map(@ssr_render)} phx-hook="SolidHook" class={@class} >
<%= if is_map(@ssr_render), do: raw(@ssr_render[:html]) %>
""" end defp extract(assigns, type) do Enum.reduce(assigns, {%{}, false}, fn {key, value}, {acc, changed} -> case normalize_key(key, value) do ^type -> {Map.put(acc, key, value), changed || key_changed(assigns, key)} _ -> {acc, changed} end end) end defp normalize_key(key, _val) when key in ~w(id class ssr name socket __changed__ __given__)a, do: :special defp normalize_key(_key, [%{__slot__: _}]), do: :slots defp normalize_key(_key, %LiveStream{}), do: :streams defp normalize_key(key, val) when is_atom(key), do: key |> to_string() |> normalize_key(val) defp normalize_key(_key, _val), do: :props defp key_changed(%{__changed__: nil}, _key), do: true defp key_changed(%{__changed__: changed}, key), do: changed[key] != nil defp ssr_render(assigns) do try do name = Map.get(assigns, :name) encoded_props = Encoder.encode(assigns.props) SSR.render(name, encoded_props, assigns.slots) rescue SSR.NotConfigured -> nil end end defp json(data), do: Jason.encode!(data, escape: :html_safe) defp id(name) do # a small trick to avoid collisions of IDs but keep them consistent across dead and live render # id(name) is called only once during the whole LiveView lifecycle because it's not using any assigns number = Process.get(:live_solidjs_counter, 1) Process.put(:live_solidjs_counter, number + 1) "#{name}-#{number}" end # Generates JSON patch operations for LiveStream changes # Handles insertions and deletions for Phoenix LiveView streams defp calculate_streams_diff(streams, initial) defp calculate_streams_diff(streams, true) do # for initial render, we want to reset all streams, and then apply the diffs init = Enum.map(streams, fn {k, _} -> %{op: "replace", path: "/#{k}", value: []} end) diffs = Enum.flat_map(streams, fn {k, stream} -> generate_stream_patches(k, stream) end) init ++ diffs end defp calculate_streams_diff(streams, false) do streams |> Enum.flat_map(fn {k, stream} -> generate_stream_patches(k, stream) end) |> then(fn diff -> [%{op: "test", path: "", value: :rand.uniform(10_000_000)} | diff] end) end # Generates JSON patch operations for LiveStream changes defp generate_stream_patches(stream_name, %LiveStream{} = stream) do patches = [] # Handle reset operation first patches = if stream.reset?, do: [%{op: "replace", path: "/#{stream_name}", value: []} | patches], else: patches # Handle deletions second patches = Enum.reduce(stream.deletes, patches, fn dom_id, patches -> [%{op: "remove", path: "/#{stream_name}/$$#{dom_id}"} | patches] end) # Handle insertions third, but reversed stream.inserts |> Enum.reverse() |> Enum.reduce(patches, fn entry, patches -> {dom_id, at, item, limit, update_only} = case entry do {dom_id, at, item, limit} -> {dom_id, at, item, limit, false} {dom_id, at, item, limit, update_only} -> {dom_id, at, item, limit, update_only} end item = Map.put(Encoder.encode(item), :__dom_id, dom_id) patches = if update_only, do: [%{op: "replace", path: "/#{stream_name}/$$#{dom_id}", value: item} | patches], else: [ %{ op: "upsert", path: "/#{stream_name}/#{if at == -1, do: "-", else: at}", value: item } | patches ] if limit, do: [%{op: "limit", path: "/#{stream_name}", value: limit} | patches], else: patches end) |> Enum.reverse() end # Compress the diff for network transfer defp prepare_diff(%{op: op, path: p, value: value}), do: [op, p, value] defp prepare_diff(%{op: op, path: p}), do: [op, p] end