Packages

E2E reactivity for SolidJS and LiveView

Current section

Files

Jump to
live_solidjs lib live_solidjs.ex
Raw

lib/live_solidjs.ex

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"""
<div
id={assigns[:id] || id(@__component_name)}
data-name={@__component_name}
data-props={"#{json(@props)}"}
data-streams-diff={"#{json(@streams_diff)}"}
data-slots={"#{@slots |> Slots.base_encode_64 |> json}"}
data-ssr={is_map(@ssr_render)}
phx-hook="SolidHook"
class={@class}
><div id={"#{assigns[:id] || id(@__component_name)}-content"} phx-update="ignore"><%= if is_map(@ssr_render), do: raw(@ssr_render[:html]) %></div></div>
"""
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