Current section
Files
Jump to
Current section
Files
lib/live_vue.ex
defmodule LiveVue do
@moduledoc """
LiveVue provides seamless integration between Phoenix LiveView and Vue.js components.
## Installation and Configuration
See README.md for installation instructions and usage.
In the standard `~H` setup generated by `mix live_vue.install`, `LiveVue.SharedPropsView`
rewrites LiveVue component tags to inject shared props and `v-socket` automatically.
You usually don't need to pass `v-socket` manually unless you call `LiveVue.vue/1`
directly or bypass that `~H` override.
## Component Options
When using the `vue/1` component or `~V` sigil, the following options are supported:
### Required Attributes
* `v-component` (string) - Name of the Vue component (e.g., "YourComponent", "directory/Example")
> #### Tip {: .tip}
>
> Value of `v-component` will be directly passed to `resolve` function of the `createLiveVue` instance.
> It should return Vue component or a promise that resolves to a Vue component.
> In a standard setup, you can find it in `assets/vue/index.js`.
### Optional Attributes
* `id` (string) - Explicit ID of the wrapper component. If not provided, a random one will be
generated. Useful to keep ID consistent in development (e.g., "vue-1")
* `class` (string) - CSS class(es) to apply to the Vue component wrapper
(e.g., "my-class" or "my-class another-class")
* `v-ssr` (boolean) - Whether to render the component on the server. Defaults to the value set
in config (default: true)
* `v-socket` (LiveView.Socket) - LiveView socket. Usually injected automatically for LiveVue
component tags in standard `~H` templates; pass it manually when calling `LiveVue.vue/1`
directly or bypassing `LiveVue.SharedPropsView`
* `v-inject` (string) - Render this component into the default slot of another LiveVue
component by passing the target component's `id`
* `v-inject:*` (string) - Render this component into a named slot of another LiveVue
component, e.g. `v-inject:sidebar="layout"`
### Event Handlers
* `v-on:*` - Vue event handlers can be attached using the `v-on:` prefix
(e.g., `v-on:click`, `v-on:input`)
### Props and Slots
* All other attributes are passed as props to the Vue component
* Slots can be passed as regular Phoenix slots
"""
use Phoenix.Component
alias LiveVue.Encoder
alias LiveVue.InjectedSSR
alias LiveVue.Patch
alias LiveVue.Slots
alias Phoenix.LiveView
alias Phoenix.LiveView.LiveStream
require Logger
@ssr_default Application.compile_env(:live_vue, :ssr, true)
@diff_default Application.compile_env(:live_vue, :enable_props_diff, true)
defmacro __using__(_opts) do
quote do
import LiveVue
end
end
@doc """
Renders a Vue component within Phoenix LiveView.
## Examples
<.vue
v-component="MyComponent"
message="Hello"
v-on:click="handleClick"
class="my-component"
/>
<.vue
v-component="nested/Component"
v-ssr={false}
items={@items}
>
<:default>Default slot content</:default>
<:named>Named slot content</:named>
</.vue>
"""
def vue(assigns) do
init = assigns.__changed__ == nil
dead = assigns[:"v-socket"] == nil or not LiveView.connected?(assigns[:"v-socket"])
use_diff = Map.get(assigns, :"v-diff", @diff_default)
use_streams_diff = Enum.any?(assigns, fn {_k, v} -> match?(%LiveStream{}, v) end)
render_ssr? = init and dead and Map.get(assigns, :"v-ssr", @ssr_default)
{inject_target, inject_slot} = inject_config(assigns)
# if we enable diffs, we use only changed props for all the remaining calculations
base_assigns =
if use_diff do
Enum.filter(assigns, fn {k, _v} -> key_changed(assigns, k) end)
else
assigns
end
props = extract(base_assigns, :props)
streams = extract(base_assigns, :streams)
slots = extract(base_assigns, :slots)
handlers = extract(base_assigns, :handlers)
props_diff = if use_diff, do: calculate_props_diff(props, assigns), else: []
streams_diff = if use_streams_diff, do: calculate_streams_diff(streams, init or dead), else: []
assigns =
assigns
|> Map.put_new(:class, nil)
|> Map.put(:__component_name, Map.get(assigns, :"v-component"))
|> then(fn assigns ->
# require explicit id when no component is specified (headless mode)
if is_nil(assigns[:__component_name]) and is_nil(assigns[:id]) do
raise ArgumentError, "<.vue> without v-component requires an explicit id"
else
assigns
end
end)
|> then(fn assigns -> Map.put_new_lazy(assigns, :id, fn -> id(assigns.__component_name) end) end)
|> Map.put(:props, props)
# let's compress it a little bit, and decompress it on the client side
|> Map.put(:props_diff, serialize_patch(props_diff))
|> Map.put(:streams_diff, serialize_patch(streams_diff))
|> Map.put(:handlers, handlers)
|> Map.put(:slots, Slots.rendered_slot_map(slots))
|> Map.put(:use_diff, use_diff)
|> Map.put(:inject_target, inject_target)
|> Map.put(:inject_slot, inject_slot)
assigns =
Map.put(assigns, :ssr_render, if(render_ssr? && assigns[:__component_name], do: ssr_render(assigns)))
computed_changed =
%{
# we send initial props only on initial render, later we send only changed props
props: init or dead or not use_diff,
ssr_render: assigns[:ssr_render] != nil,
slots: slots != %{},
handlers: handlers != %{},
# we want to send props_diff always but not on initial render
props_diff: not init and not dead and use_diff,
# we want to send stream_diffs always when there's at least one stream in assigns
streams_diff: use_streams_diff
}
assigns =
update_in(assigns.__changed__, fn
nil -> nil
changed -> for {k, true} <- computed_changed, into: changed, do: {k, true}
end)
# optimizing diffs by using string interpolation
# https://elixirforum.com/t/heex-attribute-value-in-quotes-send-less-data-than-values-in-braces/63274
~H"""
<%= if @ssr_render do %>
{@ssr_render[:preloadLinks]}
<% end %>
<div
id={@id}
data-name={@__component_name}
data-props={"#{Patch.encode_object(Encoder.encode(@props))}"}
data-props-diff={"#{@props_diff}"}
data-streams-diff={"#{@streams_diff}"}
data-ssr={(@ssr_render != nil) |> to_string()}
data-use-diff={@use_diff |> to_string()}
data-handlers={"#{for({k, v} <- @handlers, into: %{}, do: {k, json(v.ops)}) |> json()}"}
data-slots={"#{@slots |> Slots.base_encode_64() |> json}"}
data-inject={@inject_target}
data-inject-slot={@inject_slot}
style={if(@inject_target, do: "display:none")}
phx-update="ignore"
phx-hook="VueHook"
phx-no-format
class={@class}
><%= if @ssr_render, do: @ssr_render[:html] %></div>
"""
end
# Calculates minimal JSON Patch operations for changed props only.
# Uses Phoenix LiveView's __changed__ tracking to identify what props have changed.
# For simple values, generates direct replace operations.
# For complex values (maps, lists), uses Jsonpatch.diff to find minimal changes.
# Uses LiveVue.Encoder to safely encode structs before diffing.
defp calculate_props_diff(props, %{__changed__: changed}) do
# For simple types: changed[k] == true
# For complex types: changed[k] is the old value
props
|> Enum.flat_map(fn {k, new_value} ->
case changed[k] do
nil ->
[]
# For simple types, generate replace operation
true ->
[%{op: "replace", path: "/#{k}", value: Encoder.encode(new_value)}]
# For complex types which didn't change type, use Jsonpatch to find minimal diff
old_value ->
Jsonpatch.diff(old_value, new_value,
ancestor_path: "/#{k}",
prepare_map: fn
struct when is_struct(struct) -> Encoder.encode(struct)
rest -> rest
end,
object_hash: &object_hash/1
)
end
end)
# let's add a harmless test operation to ensure we never have the same diff as before
|> then(fn diff -> [%{op: "test", path: "", value: :rand.uniform(10_000_000)} | diff] end)
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
# this function tells which field of the struct should be used as a key for the diff
# right now it's hardcoded as id, maybe in the future I'll add a way to configure it,
# depending on the user's needs
defp object_hash(%{id: id}), do: id
defp object_hash(_), do: nil
# Generates JSON patch operations for LiveStream changes
# Handles insertions and deletions for Phoenix LiveView streams
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 - inserts at -1 should be correctly ordered, inserts at 0 should be reversed
# see https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#stream/4 :at option
stream.inserts
|> Enum.reverse()
|> Enum.reduce(patches, fn {dom_id, at, item, limit, update_only}, patches ->
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
# we compress the diff to make it smaller, so it's faster to send to the client
# then, it's decompressed on the client side
@doc false
def compress_patch(diff), do: Enum.map(diff, &prepare_diff/1)
@doc false
def serialize_patch(diff), do: Patch.serialize(diff)
defp prepare_diff(%{op: op, path: p, value: value}), do: [op, p, value]
defp prepare_diff(%{op: op, path: p}), do: [op, p]
defp extract(assigns, type) do
Enum.reduce(assigns, %{}, fn {key, value}, acc ->
case normalize_key(key, value) do
^type -> Map.put(acc, key, value)
{^type, k} -> Map.put(acc, k, value)
_ -> acc
end
end)
end
defp normalize_key(key, _val)
when key in ~w"id class v-ssr v-diff v-component v-socket v-inject __changed__ __given__"a, do: :special
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("v-inject:" <> _slot, _val), do: :special
defp normalize_key("v-on:" <> key, _val), do: {:handlers, key}
defp normalize_key(_key, %LiveStream{}), do: :streams
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
component = %{
id: assigns.id,
name: assigns[:"v-component"],
props: Encoder.encode(assigns.props),
slots: assigns.slots
}
InjectedSSR.prepare(component, assigns.inject_target, assigns.inject_slot)
end
defp inject_config(assigns) do
# Check for v-inject (default slot) or v-inject:slotname (named slot)
case Map.get(assigns, :"v-inject") do
nil -> find_named_inject(assigns)
false -> {nil, nil}
target when is_binary(target) -> {target, nil}
_ -> raise ArgumentError, ~s(v-inject requires a target component id, for example v-inject="vue-layout")
end
end
defp find_named_inject(assigns) do
Enum.find_value(assigns, {nil, nil}, fn
{key, value} when is_atom(key) ->
case Atom.to_string(key) do
"v-inject:" <> slot when is_binary(value) ->
{value, slot}
"v-inject:" <> _slot when value in [nil, false] ->
nil
"v-inject:" <> slot ->
raise ArgumentError,
~s(v-inject:#{slot} requires a target component id, for example v-inject:#{slot}="vue-layout")
_ ->
nil
end
_ ->
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_vue_counter, 1)
Process.put(:live_vue_counter, number + 1)
"#{name}-#{number}"
end
@doc false
def get_socket(assigns) do
case get_in(assigns, [:vue_opts, :socket]) || assigns[:socket] do
%LiveView.Socket{} = socket -> socket
_ -> nil
end
end
@doc false
@deprecated "~V sigil is deprecated, please use ~VUE instead."
defmacro sigil_V(term, modifiers) do
do_sigil(term, modifiers, __CALLER__)
end
@doc """
Inlines a Vue single-file component inside a LiveView. This is the new recommended way over the `~V` sigil.
"""
defmacro sigil_VUE(term, modifiers) do
do_sigil(term, modifiers, __CALLER__)
end
defp do_sigil({:<<>>, _meta, [string]}, [], caller) do
path = "./assets/vue/_build/#{caller.module}.vue"
with :ok <- File.mkdir_p(Path.dirname(path)) do
File.write!(path, string)
end
quote do
~H"""
<LiveVue.vue
class={get_in(assigns, [:vue_opts, :class])}
v-component={"_build/#{__MODULE__}"}
v-socket={get_socket(assigns)}
v-ssr={get_in(assigns, [:vue_opts, :ssr]) != false}
{Map.drop(assigns, [:vue_opts, :socket, :flash, :live_action])}
/>
"""
end
end
end