defmodule ReactPhx do @moduledoc """ See READ.md for installation instructions and examples. """ use Phoenix.Component import Phoenix.HTML alias Phoenix.LiveView alias ReactPhx.SSR alias ReactPhx.Slots require Logger @ssr_default Application.compile_env(:react_phx, :ssr, false) @doc """ Render a React component. """ def react(assigns) do # Validate component name looks like a component, not a data value. # Catches the common mistake of `<.react name={@name} ...>` where @name is a data prop. component_name = Map.get(assigns, :name) if is_binary(component_name) and component_name != "" and String.first(component_name) == String.downcase(String.first(component_name)) do Logger.warning( "react_phx: name=\"#{component_name}\" looks like a data value, not a component name. " <> "Component names should be PascalCase (e.g., \"Counter\"). " <> "If you meant to pass a prop called 'name', rename it (e.g., user_name={@name})." ) end 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) # we manually compute __changed__ for the computed props and slots so it's not sent without reason {props, props_changed?} = extract(assigns, :props) props = Map.new(props, fn {k, v} -> {k, ReactPhx.Encoder.encode(v)} end) {slots, slots_changed?} = extract(assigns, :slots) {streams, streams_changed?} = extract(assigns, :streams) component_name = Map.get(assigns, :name) streams_diff = if streams_changed? do stream_patches(streams) else [] end # Generate a stable ID on first render; reuse on subsequent renders. # Map.put_new_lazy ensures id/1 is only called when :id is not already set. assigns = Map.put_new_lazy(assigns, :id, fn -> id(component_name) end) assigns = assigns |> Map.put_new(:class, nil) |> Map.put(:__component_name, component_name) |> Map.put(:props, props) |> Map.put(:slots, if(slots_changed?, do: Slots.rendered_slot_map(slots), else: %{})) |> Map.put(:streams_diff, streams_diff) assigns = Map.put(assigns, :ssr_render, if(render_ssr?, do: ssr_render(assigns), else: nil)) computed_changed = %{ props: props_changed?, slots: slots_changed?, streams_diff: streams_changed?, ssr_render: render_ssr? } 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-streams-diff={if(@streams_diff != [], do: json(@streams_diff))} data-ssr={is_map(@ssr_render)} data-props-version={System.unique_integer([:monotonic, :positive])} phx-update="ignore" phx-hook="ReactHook" class={@class} ><%= raw(@ssr_render[:html]) %>
<%= if assigns[:uploads] do %>
<%= for {_key, upload} <- uploads_list(assigns[:uploads]) do %> <.live_file_input upload={upload} /> <% end %>
<% end %> """ 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 uploads __changed__ __given__)a, do: :special defp normalize_key(_key, %Phoenix.LiveView.LiveStream{}), do: :streams defp normalize_key(_key, [%{__slot__: _} | _]), do: :slots 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) SSR.render(name, assigns.props, assigns.slots) rescue SSR.NotConfigured -> nil end end # --------------------------------------------------------------------------- # Stream patch generation # --------------------------------------------------------------------------- # Depends on Phoenix.LiveView.LiveStream internals (>= 1.0.0). # The struct fields (inserts, deletes, reset?) are NOT public API and may # change across LiveView versions. We wrap processing in try/rescue so that # a structural mismatch degrades gracefully rather than crashing the render. # --------------------------------------------------------------------------- @doc false def stream_patches(streams) when is_map(streams) do Enum.flat_map(streams, fn {name, %Phoenix.LiveView.LiveStream{} = stream} -> stream_to_patches(name, stream) end) end @doc false def stream_to_patches(name, %Phoenix.LiveView.LiveStream{} = stream) do try do path = "/#{name}" if stream.reset? do # When reset is true, replace the entire stream with current inserts. items = stream.inserts |> deduplicate_inserts() |> Enum.map(fn {dom_id, _at, item, _limit, _update_only} -> item |> encode_stream_item() |> Map.put("__dom_id", dom_id) end) [%{op: "replace", path: path, value: items}] else insert_ops = stream.inserts |> deduplicate_inserts() |> Enum.flat_map(fn {dom_id, at, item, limit, _update_only} -> encoded = item |> encode_stream_item() |> Map.put("__dom_id", dom_id) upsert = %{op: "upsert", path: path, match: dom_id, value: encoded} upsert = if at != -1, do: Map.put(upsert, :at, at), else: upsert if limit do [upsert, %{op: "limit", path: path, value: limit}] else [upsert] end end) delete_ops = Enum.map(stream.deletes, fn dom_id -> %{op: "delete_by_id", path: path, match: dom_id} end) insert_ops ++ delete_ops end rescue _ -> Logger.warning( "react_phx: failed to process stream #{inspect(name)}. " <> "LiveStream internals may have changed. Skipping stream patches." ) [] end end # Inserts are stored in reverse order and may contain duplicates. # We deduplicate keeping only the most recent (first occurrence in reversed list) # then reverse to restore insertion order. defp deduplicate_inserts(inserts) do {deduped, _seen} = Enum.reduce(inserts, {[], MapSet.new()}, fn {id, _, _, _, _} = insert, {acc, seen} -> if MapSet.member?(seen, id) do {acc, seen} else {[insert | acc], MapSet.put(seen, id)} end end) deduped end defp encode_stream_item(item) when is_struct(item) do item |> ReactPhx.Encoder.encode() |> ensure_string_keys() end defp encode_stream_item(item) when is_map(item) do item |> Map.new(fn {k, v} -> {to_string(k), ReactPhx.Encoder.encode(v)} end) end defp encode_stream_item(item), do: ReactPhx.Encoder.encode(item) defp ensure_string_keys(map) when is_map(map) do Map.new(map, fn {k, v} -> {to_string(k), v} end) end defp ensure_string_keys(other), do: other # Normalize uploads into a keyword-like list of {name, config} tuples. # Accepts a map (e.g. %{avatar: %UploadConfig{}, photos: %UploadConfig{}}) # or a single UploadConfig struct. defp uploads_list(%{} = uploads) do Map.to_list(uploads) end defp json(data), do: Jason.encode!(data, escape: :html_safe) defp id(name) do # Deterministic ID based on component name alone. # If you render multiple instances of the same component, you MUST pass an # explicit `:id` attribute to each one — this matches standard Phoenix # component behavior. "react-#{name}" end end