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 <:named>Named slot content """ 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 %>
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] %>
""" 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""" """ end end end