diff --git a/lib/components.ex b/lib/components.ex index 5dc87b2..9e9eb0f 100644 --- a/lib/components.ex +++ b/lib/components.ex @@ -23,7 +23,16 @@ defmodule LiveSvelte.Components do defp name_to_function(name) do quote do def unquote(:"#{name}")(assigns) do - props = Map.drop(assigns, [:__changed__, :__given__, :ssr, :class, :socket]) + # Extract slot assigns to pass through to LiveSvelte.svelte + slot_assigns = LiveSvelte.Slots.filter_slots_from_assigns(assigns) + + # Filter out reserved keys and slots from props + # Slots contain functions (inner_block) that can't be JSON encoded + props = + assigns + |> Map.drop([:__changed__, :__given__, :ssr, :class, :socket]) + |> Map.drop(Map.keys(slot_assigns)) + |> Enum.into(%{}) var!(assigns) = assigns @@ -32,6 +41,7 @@ defmodule LiveSvelte.Components do |> Map.put_new(:class, nil) |> Map.put_new(:socket, nil) |> assign(:props, props) + |> assign(:__slot_assigns, slot_assigns) ~H""" """ end diff --git a/lib/dynamic_slots.ex b/lib/dynamic_slots.ex new file mode 100644 index 0000000..da34b8f --- /dev/null +++ b/lib/dynamic_slots.ex @@ -0,0 +1,26 @@ +defmodule LiveSvelte.DynamicSlots do + @moduledoc false + + @doc false + defmacro __before_compile__(_env) do + quote do + # Mark Phoenix's generated __components__/0 as overridable, then override it. + # Phoenix generates __components__/0 but doesn't mark it as overridable. + defoverridable __components__: 0 + + # Override Phoenix's __components__/0 to skip slot validation for the svelte component. + # + # Phoenix LiveView 1.x uses __components__/0 to get component definitions for validation. + # The verification code does: + # component = submod.__components__()[fun] + # + # If this returns nil, the verification is skipped entirely for that component. + # This allows LiveSvelte to accept arbitrary slot names without warnings. + def __components__ do + # Return empty map so __components__()[:svelte] returns nil + # This skips slot/attr validation entirely for the svelte component + %{} + end + end + end +end diff --git a/lib/live_svelte.ex b/lib/live_svelte.ex index 3729d4e..d3b6c59 100644 --- a/lib/live_svelte.ex +++ b/lib/live_svelte.ex @@ -13,6 +13,11 @@ defmodule LiveSvelte do alias LiveSvelte.Slots alias LiveSvelte.SSR + # Override Phoenix's slot validation to accept arbitrary slot names. + # This allows users to pass any named slot to Svelte components without + # getting "undefined slot" warnings during compilation. + @before_compile LiveSvelte.DynamicSlots + attr :props, :map, default: %{}, doc: "Props to pass to the Svelte component", diff --git a/lib/slots.ex b/lib/slots.ex index 1bc295d..2729ac5 100644 --- a/lib/slots.ex +++ b/lib/slots.ex @@ -27,12 +27,21 @@ defmodule LiveSvelte.Slots do |> Enum.into(%{}) end - @doc false - defp filter_slots_from_assigns(assigns) do + @doc """ + Filters assigns to return only slot entries. + Slots are identified by having a list value containing maps with `__slot__` key. + """ + def filter_slots_from_assigns(assigns) do assigns |> Enum.filter(fn - {_key, [%{__slot__: _}]} -> true - _ -> false + {_key, value} when is_list(value) -> + Enum.any?(value, fn + %{__slot__: _} -> true + _ -> false + end) + + _ -> + false end) |> Enum.into(%{}) end diff --git a/test/components_test.exs b/test/components_test.exs new file mode 100644 index 0000000..49c2f42 --- /dev/null +++ b/test/components_test.exs @@ -0,0 +1,72 @@ +defmodule LiveSvelte.ComponentsTest do + use ExUnit.Case, async: true + + alias LiveSvelte.Slots + + describe "slot handling in generated components" do + test "slots are properly identified and separated from props" do + # Simulate assigns that would be passed to a generated component + button_slot = %{__slot__: :button, inner_block: fn _, _ -> "button content" end} + items_slot = %{__slot__: :items, inner_block: fn _, _ -> "items content" end} + + assigns = %{ + __changed__: nil, + __given__: %{}, + ssr: true, + class: "my-class", + socket: nil, + button: [button_slot], + items: [items_slot], + custom_prop: "value", + another_prop: 123 + } + + # Extract slots + slot_assigns = Slots.filter_slots_from_assigns(assigns) + + # Build props by filtering out reserved keys and slots + props = + assigns + |> Map.drop([:__changed__, :__given__, :ssr, :class, :socket]) + |> Map.drop(Map.keys(slot_assigns)) + |> Enum.into(%{}) + + # Verify slots were extracted + assert Map.has_key?(slot_assigns, :button) + assert Map.has_key?(slot_assigns, :items) + + # Verify props don't contain slots (which would cause JSON encoding errors) + refute Map.has_key?(props, :button) + refute Map.has_key?(props, :items) + + # Verify props contain the actual props + assert props[:custom_prop] == "value" + assert props[:another_prop] == 123 + + # Verify props can be JSON encoded (slots contain functions which can't) + assert {:ok, _} = Jason.encode(props) + end + + test "slots with functions cannot be JSON encoded" do + slot = %{__slot__: :test, inner_block: fn -> "content" end} + + # This is what was causing the original error + assert_raise Protocol.UndefinedError, fn -> + Jason.encode!(%{slot: [slot]}) + end + end + + test "props without slots can be JSON encoded" do + props = %{ + name: "TestComponent", + count: 42, + active: true, + items: ["a", "b", "c"], + nested: %{foo: "bar"} + } + + assert {:ok, json} = Jason.encode(props) + assert is_binary(json) + end + end +end diff --git a/test/dynamic_slots_test.exs b/test/dynamic_slots_test.exs new file mode 100644 index 0000000..074472d --- /dev/null +++ b/test/dynamic_slots_test.exs @@ -0,0 +1,23 @@ +defmodule LiveSvelte.DynamicSlotsTest do + use ExUnit.Case, async: true + + describe "__components__/0" do + test "returns empty map to skip Phoenix validation" do + # LiveSvelte.__components__() should return %{} so that + # __components__()[:svelte] returns nil, skipping validation + assert LiveSvelte.__components__() == %{} + end + + test "looking up :svelte returns nil" do + # This is what Phoenix does during validation + # When it returns nil, validation is skipped + assert LiveSvelte.__components__()[:svelte] == nil + end + end + + describe "slot validation bypass" do + test "LiveSvelte module has __components__/0 defined" do + assert function_exported?(LiveSvelte, :__components__, 0) + end + end +end diff --git a/test/slots_test.exs b/test/slots_test.exs new file mode 100644 index 0000000..f8de912 --- /dev/null +++ b/test/slots_test.exs @@ -0,0 +1,79 @@ +defmodule LiveSvelte.SlotsTest do + use ExUnit.Case, async: true + + alias LiveSvelte.Slots + + describe "filter_slots_from_assigns/1" do + test "returns empty map when no slots present" do + assigns = %{name: "Test", props: %{foo: "bar"}} + assert Slots.filter_slots_from_assigns(assigns) == %{} + end + + test "filters out slot entries from assigns" do + slot_entry = %{__slot__: :button, inner_block: fn -> "content" end} + assigns = %{ + name: "Test", + button: [slot_entry], + props: %{foo: "bar"} + } + + result = Slots.filter_slots_from_assigns(assigns) + + assert Map.has_key?(result, :button) + assert result[:button] == [slot_entry] + refute Map.has_key?(result, :name) + refute Map.has_key?(result, :props) + end + + test "handles multiple slots" do + button_slot = %{__slot__: :button, inner_block: fn -> "button" end} + items_slot = %{__slot__: :items, inner_block: fn -> "items" end} + + assigns = %{ + name: "Test", + button: [button_slot], + items: [items_slot], + props: %{} + } + + result = Slots.filter_slots_from_assigns(assigns) + + assert Map.keys(result) |> Enum.sort() == [:button, :items] + end + + test "handles slots with multiple entries" do + slot1 = %{__slot__: :item, inner_block: fn -> "item1" end} + slot2 = %{__slot__: :item, inner_block: fn -> "item2" end} + + assigns = %{ + item: [slot1, slot2], + name: "Test" + } + + result = Slots.filter_slots_from_assigns(assigns) + + assert result[:item] == [slot1, slot2] + end + + test "ignores non-list values" do + assigns = %{ + name: "Test", + count: 5, + active: true, + data: %{nested: "value"} + } + + assert Slots.filter_slots_from_assigns(assigns) == %{} + end + + test "ignores lists without slot maps" do + assigns = %{ + items: ["a", "b", "c"], + numbers: [1, 2, 3], + maps: [%{foo: "bar"}] + } + + assert Slots.filter_slots_from_assigns(assigns) == %{} + end + end +end