From a720189d5066de4741a72b6fa9e14cb26a12f4fb Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:23:15 -0500 Subject: [PATCH 01/13] Add MacroComponent Directives and Root Tag Annotation functionality --- lib/phoenix_component.ex | 91 ++++++ lib/phoenix_component/macro_component.ex | 30 +- lib/phoenix_live_view/tag_engine.ex | 178 ++++++++++-- .../macro_component_integration_test.exs | 271 +++++++++++++++++- test/phoenix_live_view/html_engine_test.exs | 219 ++++++++++++++ test/support/live_views/root_tag_anno.exs | 191 ++++++++++++ test/test_helper.exs | 4 + 7 files changed, 955 insertions(+), 29 deletions(-) create mode 100644 test/support/live_views/root_tag_anno.exs diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 78392549fb..0fdb214ccb 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -554,6 +554,97 @@ defmodule Phoenix.Component do Changing this configuration will require `mix clean` and a full recompile. + ## Root tag annotations + + HEEx templates support adding root tag annotations to the rendered page. + These can be useful for debugging or as selectors for things such as CSS. + + Note that root tag annotations are applied to all root tags in the given + template, not just the outermost root tags. This means that tags at the root + of the template itself, at the root of any component inner blocks, or at + the root of any component slots will all be annotated. + + For example, imagine the following component definition: + + ```elixir + defmodule MyAppWeb.MyModule do + slot :inner_block, required: true + slot :named_slot, required: true + + def my_function(assigns) do + ~H""" +
+
+ {render_slot(@inner_block)} +
+
+ + """ + end + end + ``` + + And the following HEEx template: + + ```heex +
+
+ <.my_function> +

+ + Inner Block + +

+ <:named_slot> +

+ + Named Slot + +

+ + +
+
+ ``` + + By setting `root_tag_annotation` to "phx-r", the rendered HTML would look as follows: + + ```html +
+
+
+
+

+ + Inner Block + +

+
+
+ +
+
+ ``` + + This feature works on any `~H` or `.html.heex` template. They can be enabled + globally with the following configuration in your `config/config.exs` file: + + config :phoenix_live_view, root_tag_annotation: "phx-r" + + Changing this configuration will require `mix clean` and a full recompile. + ## Dynamic Component Rendering Sometimes you might need to decide at runtime which component to render. diff --git a/lib/phoenix_component/macro_component.ex b/lib/phoenix_component/macro_component.ex index 5c93cb00c8..e2e3f76d49 100644 --- a/lib/phoenix_component/macro_component.ex +++ b/lib/phoenix_component/macro_component.ex @@ -120,6 +120,30 @@ defmodule Phoenix.Component.MacroComponent do # LiveView's end to end tests: a macro component that performs # [syntax highlighting at compile time](https://github.com/phoenixframework/phoenix_live_view/blob/38851d943f3280c5982d75679291dccb8c442534/test/e2e/support/colocated_live.ex#L4-L35) # using the [Makeup](https://hexdocs.pm/makeup/Makeup.html) library. + # + # ## Directives + # + # Macro components may return directives from `transform/2` which can be used to influence + # other elements in the template outside of the macro component at compile-time. For example: + # + # ```elixir + # defmodule MyAppWeb.TagRootSampleComponent do + # @behaviour Phoenix.Component.MacroComponent + # + # @impl true + # def transform(_ast, _meta) do + # {:ok, "", %{}, [root_tag_annotation: "test1", root_tag_annotation: "test2"]} + # end + # end + # ``` + # The following directives are currently supported: + # + # ### Options + # + # * `:root_tag_annotation` - A value to apply as an annotation to all root tags during template compilation. + # Requires that a `:root_tag_annotation` be configured for the application. Value must be a string. May be + # provided multiple times to apply multiple annotations. See the docs for `Phoenix.Component` for details on + # root tag annotations and how to configure them. @type tag :: binary() @type attribute :: {binary(), Macro.t()} @@ -128,9 +152,13 @@ defmodule Phoenix.Component.MacroComponent do @type tag_meta :: %{closing: :self | :void} @type heex_ast :: {tag(), attributes(), children(), tag_meta()} | binary() @type transform_meta :: %{env: Macro.Env.t()} + @type directive :: {:root_tag_annotation, String.t()} + @type directives :: [directive] @callback transform(heex_ast :: heex_ast(), meta :: transform_meta()) :: - {:ok, heex_ast()} | {:ok, heex_ast(), data :: term()} + {:ok, heex_ast()} + | {:ok, heex_ast(), data :: term()} + | {:ok, heex_ast(), data :: term(), directives :: directives()} @doc """ Returns the stored data from macro components that returned `{:ok, ast, data}`. diff --git a/lib/phoenix_live_view/tag_engine.ex b/lib/phoenix_live_view/tag_engine.ex index 8c89d03e0d..7f272bc213 100644 --- a/lib/phoenix_live_view/tag_engine.ex +++ b/lib/phoenix_live_view/tag_engine.ex @@ -218,7 +218,7 @@ defmodule Phoenix.LiveView.TagEngine do token_state = state - |> token_state(nil) + |> template_token_state() |> continue(tokens) |> validate_unclosed_tags!("template") @@ -269,24 +269,21 @@ defmodule Phoenix.LiveView.TagEngine do @impl true def handle_end(state) do state - |> token_state(false) + |> nesting_token_state() |> continue(Enum.reverse(state.tokens)) |> validate_unclosed_tags!("do-block") |> invoke_subengine(:handle_end, []) end - defp token_state( - %{ - subengine: subengine, - substate: substate, - file: file, - caller: caller, - source: source, - indentation: indentation, - tag_handler: tag_handler - }, - root - ) do + defp template_token_state(%{ + subengine: subengine, + substate: substate, + file: file, + caller: caller, + source: source, + indentation: indentation, + tag_handler: tag_handler + }) do %{ subengine: subengine, substate: substate, @@ -296,9 +293,35 @@ defmodule Phoenix.LiveView.TagEngine do tags: [], slots: [], caller: caller, - root: root, + root: nil, indentation: indentation, - tag_handler: tag_handler + tag_handler: tag_handler, + root_tag_annotations: [] + } + end + + defp nesting_token_state(%{ + subengine: subengine, + substate: substate, + file: file, + caller: caller, + source: source, + indentation: indentation, + tag_handler: tag_handler + }) do + %{ + subengine: subengine, + substate: substate, + source: source, + file: file, + stack: [], + tags: [], + slots: [], + caller: caller, + root: false, + indentation: indentation, + tag_handler: tag_handler, + root_tag_annotations: false } end @@ -786,12 +809,13 @@ defmodule Phoenix.LiveView.TagEngine do attrs = postprocess_attrs(attrs, state) validate_phx_attrs!(attrs, tag_meta, state) validate_tag_attrs!(attrs, tag_meta, state) + previous_tag = List.first(state.tags) case pop_special_attrs!(attrs, tag_meta, state) do {false, tag_meta, attrs} -> state |> set_root_on_tag() - |> handle_tag_and_attrs(name, attrs, suffix, to_location(tag_meta)) + |> handle_tag_and_attrs(name, attrs, suffix, to_location(tag_meta), previous_tag) |> continue(tokens) {true, new_meta, new_attrs} -> @@ -799,7 +823,7 @@ defmodule Phoenix.LiveView.TagEngine do |> push_substate_to_stack() |> update_subengine(:handle_begin, []) |> set_root_on_not_tag() - |> handle_tag_and_attrs(name, new_attrs, suffix, to_location(new_meta)) + |> handle_tag_and_attrs(name, new_attrs, suffix, to_location(new_meta), previous_tag) |> handle_special_expr(new_meta) |> continue(tokens) end @@ -811,6 +835,7 @@ defmodule Phoenix.LiveView.TagEngine do attrs = postprocess_attrs(attrs, state) validate_phx_attrs!(attrs, tag_meta, state) validate_tag_attrs!(attrs, tag_meta, state) + previous_tag = List.first(state.tags) case List.keytake(attrs, ":type", 0) do {{":type", {:expr, code, _}, _meta}, attrs} -> @@ -823,7 +848,7 @@ defmodule Phoenix.LiveView.TagEngine do state |> set_root_on_tag() |> push_tag(token) - |> handle_tag_and_attrs(name, attrs, ">", to_location(tag_meta)) + |> handle_tag_and_attrs(name, attrs, ">", to_location(tag_meta), previous_tag) |> continue(tokens) {true, new_meta, new_attrs} -> @@ -832,7 +857,7 @@ defmodule Phoenix.LiveView.TagEngine do |> update_subengine(:handle_begin, []) |> set_root_on_not_tag() |> push_tag({:tag, name, new_attrs, new_meta}) - |> handle_tag_and_attrs(name, new_attrs, ">", to_location(new_meta)) + |> handle_tag_and_attrs(name, new_attrs, ">", to_location(new_meta), previous_tag) |> continue(tokens) end end @@ -896,12 +921,79 @@ defmodule Phoenix.LiveView.TagEngine do |> handle_ast(new_ast, tag_meta) |> continue(maybe_prune_text_after_macro_component(new_ast, rest)) + {{:ok, new_ast, data, directives}, rest} -> + Module.put_attribute(state.caller.module, :__macro_components__, {module, data}) + + # This is effectively a check for this macro component + # being at the very beginning of the root of the template + if Enum.any?(directives) and not is_nil(state.root) do + raise_syntax_error!( + "macro component #{module} specified directives and therefore must appear at the very beginning of the template", + tag_meta, + state + ) + end + + state = + Enum.reduce(directives, state, fn directive, state -> + apply_macro_component_directive!(directive, module, tag_meta, state) + end) + + state + |> handle_ast(new_ast, tag_meta) + |> continue(maybe_prune_text_after_macro_component(new_ast, rest)) + {other, _rest} -> raise ArgumentError, - "a macro component must return {:ok, ast} or {:ok, ast, data}, got: #{inspect(other)}" + "a macro component must return {:ok, ast}, {:ok, ast, data}, or {:ok, ast, data, directives}, got: #{inspect(other)}" + end + end + + defp apply_macro_component_directive!({:root_tag_annotation, value}, module, tag_meta, state) do + case Application.get_env(:phoenix_live_view, :root_tag_annotation) do + anno when is_binary(anno) -> + :ok + + anno -> + message = """ + no root tag annotation is configured for macro component :root_tag_annotation directive + + Macro Component: #{module} + + Expected a string root tag annotation to be configured, got: #{inspect(anno)} + + You can configure a root tag annotation like so: + + config :phoenix_live_view, root_tag_annotation: "phx-r" + """ + + raise_syntax_error!(message, tag_meta, state) + end + + cond do + !state.root_tag_annotations -> + state + + is_binary(value) -> + %{state | root_tag_annotations: [value | state.root_tag_annotations]} + + true -> + raise_syntax_error!( + "expected string value for :root_tag_annotation directive from macro component #{module}, got: #{inspect(value)}", + tag_meta, + state + ) end end + defp apply_macro_component_directive!(directive, module, tag_meta, state) do + raise_syntax_error!( + "unknown directive #{inspect(directive)} provided by macro component #{module}", + tag_meta, + state + ) + end + # self closing / void tags cannot have children defp handle_ast(state, {tag, attrs, [], %{closing: closing}}, tag_open_meta) do suffix = @@ -1053,13 +1145,11 @@ defmodule Phoenix.LiveView.TagEngine do ## handle_tag_and_attrs - defp handle_tag_and_attrs(state, name, attrs, suffix, meta) do + defp handle_tag_and_attrs(state, name, attrs, suffix, meta, previous_tag) do text = - if Application.get_env(:phoenix_live_view, :debug_attributes, false) do - "<#{name} data-phx-loc=\"#{meta[:line]}\"" - else - "<#{name}" - end + "<#{name}" + |> maybe_add_phx_loc(meta) + |> maybe_add_root_tag_annotations(state, previous_tag) state |> update_subengine(:handle_text, [meta, text]) @@ -1067,6 +1157,40 @@ defmodule Phoenix.LiveView.TagEngine do |> update_subengine(:handle_text, [meta, suffix]) end + defp maybe_add_phx_loc(text, meta) do + if Application.get_env(:phoenix_live_view, :debug_attributes, false) do + "#{text} data-phx-loc=\"#{meta[:line]}\"" + else + "#{text}" + end + end + + defp maybe_add_root_tag_annotations(text, state, previous_tag) do + case Application.get_env(:phoenix_live_view, :root_tag_annotation) do + anno when is_binary(anno) -> + # By checking if the previous tag that was pushed was a normal html tag, + # we effectively check if the tag we are dealing with is a "local" root + # (root tag of the whole template, a component's inner block, or a slot) + # or not. + if not match?({:tag, _, _, _}, previous_tag) do + annos = Enum.map(state.root_tag_annotations, fn val -> {anno, val} end) + + attrs = + [{anno, true} | annos] + |> Phoenix.HTML.attributes_escape() + |> Phoenix.HTML.safe_to_string() + + # Phoenix.HTML.attributes_escape/1 adds a leading space automatically + "#{text}#{attrs}" + else + text + end + + _ -> + text + end + end + defp handle_tag_attrs(state, meta, attrs) do Enum.reduce(attrs, state, fn {:root, {:expr, _, _} = expr, _attr_meta}, state -> diff --git a/test/phoenix_component/macro_component_integration_test.exs b/test/phoenix_component/macro_component_integration_test.exs index f18ce446bd..68f072b9e8 100644 --- a/test/phoenix_component/macro_component_integration_test.exs +++ b/test/phoenix_component/macro_component_integration_test.exs @@ -1,5 +1,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do - use ExUnit.Case, async: true + # async: false due to manipulating the Application env + # for :root_tag_annotation + use ExUnit.Case, async: false use Phoenix.Component @@ -19,6 +21,33 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end end + defmodule DirectiveMacroComponent do + @behaviour Phoenix.Component.MacroComponent + + @impl true + def transform(_ast, _meta) do + {:ok, "", %{}, [root_tag_annotation: "test1", root_tag_annotation: "test2"]} + end + end + + defmodule BadRootTagAnnoDirectiveMacroComponent do + @behaviour Phoenix.Component.MacroComponent + + @impl true + def transform(_ast, _meta) do + {:ok, "", %{}, [root_tag_annotation: false]} + end + end + + defmodule UnknownDirectiveMacroComponent do + @behaviour Phoenix.Component.MacroComponent + + @impl true + def transform(_ast, _meta) do + {:ok, "", %{}, [unknown: true]} + end + end + test "receives ast" do defmodule TestComponentAst do use Phoenix.Component @@ -239,6 +268,246 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end end + test "raises if :root_tag_annotation is not configured and the root_tag_annotation directive is provided" do + message = ~r""" + no root tag annotation is configured for macro component :root_tag_annotation directive + + Macro Component: #{__MODULE__}\.DirectiveMacroComponent + + Expected a string root tag annotation to be configured, got: nil + + You can configure a root tag annotation like so: + + config :phoenix_live_view, root_tag_annotation: \"phx-r\" + """ + + assert_raise Phoenix.LiveView.Tokenizer.ParseError, + message, + fn -> + defmodule TestRootTagAnnotationNotConfig do + use Phoenix.Component + + def render(assigns) do + ~H""" +
+ """ + end + end + end + end + + test "raises if root_tag_annotation directive is provided with a non-string value" do + message = + ~r/unknown directive {:unknown, true} provided by macro component #{__MODULE__}\.UnknownDirectiveMacroComponent/ + + assert_raise Phoenix.LiveView.Tokenizer.ParseError, + message, + fn -> + defmodule TestUnknownDirective do + use Phoenix.Component + + def render(assigns) do + ~H""" +
+ """ + end + end + end + end + + describe "macro components with directives" do + setup do + # Need to set a :root_tag_annotation as the only directive supported + # by macro components currently is [root_tag_annotation: "value"] which + # requires a :root_tag_annotation to be configured + Application.put_env(:phoenix_live_view, :root_tag_annotation, "phx-r") + on_exit(fn -> Application.delete_env(:phoenix_live_view, :root_tag_annotation) end) + end + + test "raises if root_tag_annotation directive is provided with a non-string value" do + message = + ~r/expected string value for :root_tag_annotation directive from macro component #{__MODULE__}\.BadRootTagAnnoDirectiveMacroComponent, got: false/ + + assert_raise Phoenix.LiveView.Tokenizer.ParseError, + message, + fn -> + defmodule TestBadRootTagAnnoDirective do + use Phoenix.Component + + def render(assigns) do + ~H""" +
+ """ + end + end + end + end + + test "raises if macro components with directives are not defined at the beginning of the template" do + message = + ~r/macro component #{__MODULE__}\.DirectiveMacroComponent specified directives and therefore must appear at the very beginning of the template/ + + defmodule TestComponentDirectiveAtBeginning1 do + use Phoenix.Component + + def render(assigns) do + ~H""" +
+ """ + end + end + + defmodule TestComponentDirectiveAtBeginning2 do + use Phoenix.Component + + def render(assigns) do + ~H""" +
+ """ + end + end + + assert_raise Phoenix.LiveView.Tokenizer.ParseError, + message, + fn -> + defmodule TestComponentDirectiveAtBeginning3 do + use Phoenix.Component + + def render(assigns) do + ~H""" +
+
+ """ + end + end + end + + assert_raise Phoenix.LiveView.Tokenizer.ParseError, + message, + fn -> + defmodule TestComponentDirectiveAtBeginning4 do + use Phoenix.Component + + def render(assigns) do + ~H""" +
+
+ """ + end + end + end + + assert_raise Phoenix.LiveView.Tokenizer.ParseError, + message, + fn -> + defmodule TestComponentDirectiveAtBeginning5 do + use Phoenix.Component + + def render(assigns) do + ~H""" + <.link>Link +
+ """ + end + end + end + + assert_raise Phoenix.LiveView.Tokenizer.ParseError, + message, + fn -> + defmodule TestComponentDirectiveAtBeginning6 do + use Phoenix.Component + + def render(assigns) do + ~H""" + Link +
+ """ + end + end + end + + assert_raise Phoenix.LiveView.Tokenizer.ParseError, + message, + fn -> + defmodule TestComponentDirectiveAtBeginning7 do + use Phoenix.Component + + def render(assigns) do + ~H""" + {if true, do: "Test"} +
+ """ + end + end + end + + assert_raise Phoenix.LiveView.Tokenizer.ParseError, + message, + fn -> + defmodule TestComponentDirectiveAtBeginning8 do + use Phoenix.Component + + def render(assigns) do + ~H""" + <%= if true do %> +
+ <% end %> + """ + end + end + end + + assert_raise Phoenix.LiveView.Tokenizer.ParseError, + message, + fn -> + defmodule TestComponentDirectiveAtBeginning9 do + use Phoenix.Component + + def render(assigns) do + ~H""" +
+
+
+ """ + end + end + end + + assert_raise Phoenix.LiveView.Tokenizer.ParseError, + message, + fn -> + defmodule TestComponentDirectiveAtBeginning10 do + use Phoenix.Component + + def render(assigns) do + ~H""" + <.link> +
+ + """ + end + end + end + + assert_raise Phoenix.LiveView.Tokenizer.ParseError, + message, + fn -> + defmodule TestComponentDirectiveAtBeginning11 do + use Phoenix.Component + + def render(assigns) do + ~H""" + +
+
+ """ + end + end + end + end + end + test "handles quotes" do Process.put( :new_ast, diff --git a/test/phoenix_live_view/html_engine_test.exs b/test/phoenix_live_view/html_engine_test.exs index ce9bbbf50a..eb27834f90 100644 --- a/test/phoenix_live_view/html_engine_test.exs +++ b/test/phoenix_live_view/html_engine_test.exs @@ -7,6 +7,14 @@ defmodule Phoenix.LiveView.HTMLEngineTest do alias Phoenix.LiveView.Tokenizer.ParseError + defp normalize_whitespace(string) do + # Eliminate all newlines and space between tags to make + # assertions more resilient against irrelevant whitespace differences + string + |> String.replace("\n", "") + |> String.replace(~r/> +<") + end + defp eval(string, assigns \\ %{}, opts \\ []) do {env, opts} = Keyword.pop(opts, :env, __ENV__) @@ -459,6 +467,217 @@ defmodule Phoenix.LiveView.HTMLEngineTest do end end + describe "root tag annotations" do + alias Phoenix.LiveViewTest.Support.RootTagAnno + + test "single self-closing tag" do + assigns = %{} + + compiled = compile("") + + expected = "
" + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end + + test "single tag with body" do + assigns = %{} + + compiled = compile("") + + expected = "
Test
" + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end + + test "multiple self-closing tags" do + assigns = %{} + + compiled = compile("") + + expected = """ +
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end + + test "multiple tags with bodies" do + assigns = %{} + + compiled = compile("") + + expected = """ +
Test1
+
Test2
+
Test3
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end + + test "tags root tags of nested tags" do + assigns = %{} + + compiled = compile("") + + expected = """ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end + + test "tags root tags of component inner_blocks" do + assigns = %{} + + compiled = compile("") + + expected = + """ +
+
+
+
+
+ Inner Block 1 +
+
+
+
+
+
+ Inner Block 2 +
+
+
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end + + test "tags root tags of component named slots" do + assigns = %{} + + compiled = compile("") + + expected = + """ +
+
+
+ +
+
+ +
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end + + test "tags root tags correctly for complex nestings of tags, components, and slots" do + assigns = %{} + + compiled = compile("") + + expected = + """ +
+
+
+
+
+
+

Simple

+
+ +
+
+ +
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end + + test "extra annotations provided by macro component directives" do + assigns = %{} + + compiled = compile("") + + expected = + """ +
+
+
+
Inner Block
+ +
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end + end + describe "handle function components" do test "remote call (self close)" do assigns = %{} diff --git a/test/support/live_views/root_tag_anno.exs b/test/support/live_views/root_tag_anno.exs new file mode 100644 index 0000000000..b424daee9c --- /dev/null +++ b/test/support/live_views/root_tag_anno.exs @@ -0,0 +1,191 @@ +# Note this file is intentionally a .exs file because it is loaded +# in the test helper with root_tag_annotation turned on. +defmodule Phoenix.LiveViewTest.Support.RootTagAnno do + use Phoenix.Component + + defmodule RootTagMacroComponent do + @behaviour Phoenix.Component.MacroComponent + + @impl true + def transform(_ast, _meta) do + {:ok, "", %{}, [root_tag_annotation: "test1", root_tag_annotation: "test2"]} + end + end + + def macro_component_annos(assigns) do + ~H""" +
+
+
+ <.inner_block_and_slot> +
Inner Block
+ <:test> +
+ Named Slot +
+ + +
+
+ """ + end + + slot :inner_block, required: true + slot :test + + def single_self_close(assigns) do + ~H""" +
+ """ + end + + def single_with_body(assigns) do + ~H""" +
Test
+ """ + end + + def multiple_self_close(assigns) do + ~H""" +
+
+
+ """ + end + + def multiple_with_bodies(assigns) do + ~H""" +
Test1
+
Test2
+
Test3
+ """ + end + + def nested_tags(assigns) do + ~H""" +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """ + end + + def component_inner_blocks(assigns) do + ~H""" +
+
+ <.inner_block_and_slot> +
+
+ Inner Block 1 +
+
+ + <.inner_block_and_slot> +
+
+ Inner Block 2 +
+
+ +
+
+ """ + end + + def component_named_slots(assigns) do + ~H""" +
+
+ <.inner_block_and_slot> + <:test> +
+
+ Inner Block 1 +
+
+ + + <.inner_block_and_slot> + <:test> +
+
+ Inner Block 2 +
+
+ + +
+
+ """ + end + + def nested_tags_components_slots(assigns) do + ~H""" +
+
+ <.inner_block_and_slot> +
+ <.inner_block_and_slot> +
+ <.simple /> +
+ <:test> +
+ <.simple /> +
+ + +
+ <:test> +
+ <.inner_block_and_slot> +
+ <.simple /> +
+ <:test> +
+ <.simple /> +
+ + +
+ + +
+
+ """ + end + + slot :inner_block, required: true + slot :test + + defp inner_block_and_slot(assigns) do + ~H""" +
+ {render_slot(@inner_block)} + +
+ """ + end + + defp simple(assigns) do + ~H""" +

Simple

+ """ + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 5cb28257b1..ab70f06fef 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -4,5 +4,9 @@ Code.require_file("test/support/live_views/debug_anno.exs") Application.put_env(:phoenix_live_view, :debug_attributes, false) Application.put_env(:phoenix_live_view, :debug_heex_annotations, false) +Application.put_env(:phoenix_live_view, :root_tag_annotation, "phx-r") +Code.require_file("test/support/live_views/root_tag_anno.exs") +Application.delete_env(:phoenix_live_view, :root_tag_annotation) + {:ok, _} = Phoenix.LiveViewTest.Support.Endpoint.start_link() ExUnit.start() From 0d176ced503d05f199074e3b2f62b7331a8460d1 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:45:14 -0500 Subject: [PATCH 02/13] Add MacroComponent directives/2 callback and pre-pass for collecting directives This addresses the issue of apply directives within nestings, as the pre-pass to collect directives ensures we have the information to apply them by the time we process nestings --- lib/phoenix_component/macro_component.ex | 17 +- lib/phoenix_live_view/tag_engine.ex | 279 ++++++++++-------- lib/phoenix_live_view/tokenizer.ex | 3 +- .../macro_component_integration_test.exs | 21 +- test/phoenix_live_view/html_engine_test.exs | 72 +++++ test/support/live_views/root_tag_anno.exs | 56 +++- 6 files changed, 318 insertions(+), 130 deletions(-) diff --git a/lib/phoenix_component/macro_component.ex b/lib/phoenix_component/macro_component.ex index e2e3f76d49..2400889b20 100644 --- a/lib/phoenix_component/macro_component.ex +++ b/lib/phoenix_component/macro_component.ex @@ -123,16 +123,22 @@ defmodule Phoenix.Component.MacroComponent do # # ## Directives # - # Macro components may return directives from `transform/2` which can be used to influence - # other elements in the template outside of the macro component at compile-time. For example: + # Macro components may return directives from the module's optional `c:directives/2` callback + # which can be used to influence other elements in the template outside of the macro component at compile-time. + # For example: # # ```elixir # defmodule MyAppWeb.TagRootSampleComponent do # @behaviour Phoenix.Component.MacroComponent # # @impl true + # def directives(_ast, _meta) do + # {:ok, [root_tag_annotation: "test1", root_tag_annotation: "test2"]} + # end + # + # @impl true # def transform(_ast, _meta) do - # {:ok, "", %{}, [root_tag_annotation: "test1", root_tag_annotation: "test2"]} + # {:ok, "", %{}} # end # end # ``` @@ -151,10 +157,15 @@ defmodule Phoenix.Component.MacroComponent do @type children :: [heex_ast()] @type tag_meta :: %{closing: :self | :void} @type heex_ast :: {tag(), attributes(), children(), tag_meta()} | binary() + @type directives_meta :: %{env: Macro.Env.t()} @type transform_meta :: %{env: Macro.Env.t()} @type directive :: {:root_tag_annotation, String.t()} @type directives :: [directive] + @optional_callbacks [directives: 2] + + @callback directives(heex_ast :: heex_ast(), meta :: directives_meta()) :: {:ok, directives} + @callback transform(heex_ast :: heex_ast(), meta :: transform_meta()) :: {:ok, heex_ast()} | {:ok, heex_ast(), data :: term()} diff --git a/lib/phoenix_live_view/tag_engine.ex b/lib/phoenix_live_view/tag_engine.ex index 7f272bc213..38a6d4916d 100644 --- a/lib/phoenix_live_view/tag_engine.ex +++ b/lib/phoenix_live_view/tag_engine.ex @@ -205,7 +205,9 @@ defmodule Phoenix.LiveView.TagEngine do indentation: Keyword.get(opts, :indentation, 0), caller: Keyword.fetch!(opts, :caller), source: Keyword.fetch!(opts, :source), - tag_handler: tag_handler + tag_handler: tag_handler, + root_tag_annotations: [], + collecting_directives?: true } end @@ -218,7 +220,7 @@ defmodule Phoenix.LiveView.TagEngine do token_state = state - |> template_token_state() + |> token_state(nil) |> continue(tokens) |> validate_unclosed_tags!("template") @@ -269,46 +271,26 @@ defmodule Phoenix.LiveView.TagEngine do @impl true def handle_end(state) do state - |> nesting_token_state() + |> token_state(false) |> continue(Enum.reverse(state.tokens)) |> validate_unclosed_tags!("do-block") |> invoke_subengine(:handle_end, []) end - defp template_token_state(%{ - subengine: subengine, - substate: substate, - file: file, - caller: caller, - source: source, - indentation: indentation, - tag_handler: tag_handler - }) do - %{ - subengine: subengine, - substate: substate, - source: source, - file: file, - stack: [], - tags: [], - slots: [], - caller: caller, - root: nil, - indentation: indentation, - tag_handler: tag_handler, - root_tag_annotations: [] - } - end - - defp nesting_token_state(%{ - subengine: subengine, - substate: substate, - file: file, - caller: caller, - source: source, - indentation: indentation, - tag_handler: tag_handler - }) do + defp token_state( + %{ + subengine: subengine, + substate: substate, + file: file, + caller: caller, + source: source, + indentation: indentation, + tag_handler: tag_handler, + root_tag_annotations: root_tag_annotations, + collecting_directives?: collecting_directives? + }, + root + ) do %{ subengine: subengine, substate: substate, @@ -318,10 +300,11 @@ defmodule Phoenix.LiveView.TagEngine do tags: [], slots: [], caller: caller, - root: false, + root: root, indentation: indentation, tag_handler: tag_handler, - root_tag_annotations: false + root_tag_annotations: root_tag_annotations, + collecting_directives?: collecting_directives? } end @@ -333,7 +316,7 @@ defmodule Phoenix.LiveView.TagEngine do @impl true def handle_begin(state) do - update_subengine(%{state | tokens: []}, :handle_begin, []) + update_subengine(%{state | tokens: [], collecting_directives?: false}, :handle_begin, []) end @impl true @@ -342,6 +325,8 @@ defmodule Phoenix.LiveView.TagEngine do tokenizer_state = Tokenizer.init(indentation, file, source, state.tag_handler) {tokens, cont} = Tokenizer.tokenize(text, meta, tokens, cont, tokenizer_state) + state = collect_directives(state, Enum.reverse(tokens)) + %{ state | tokens: tokens, @@ -350,6 +335,112 @@ defmodule Phoenix.LiveView.TagEngine do } end + defp collect_directives(state, []), do: state + + defp collect_directives(state, tokens) do + # Allow for leading whitespace + stripped_tokens = Tokenizer.strip_text_token_fully(tokens) + + case stripped_tokens do + [] -> + state + + [{:tag, _name, _attrs, tag_meta} = token | rest] -> + case check_and_validate_macro_component(token, state) do + {:macro_component, module, _attrs} -> + # Use build_ast to consume all of the tokens in this MacroComponent + try do + {ast, rest} = + case Phoenix.Component.MacroComponent.build_ast(stripped_tokens, state.caller) do + {:ok, ast, rest} -> {ast, rest} + {:error, message, meta} -> raise_syntax_error!(message, meta, state) + end + + cond do + state.collecting_directives? and function_exported?(module, :directives, 2) -> + state = + case module.directives(ast, %{env: state.caller}) do + {:ok, directives} when is_list(directives) -> + Enum.reduce(directives, state, fn directive, state -> + apply_macro_component_directive!(directive, module, tag_meta, state) + end) + + other -> + raise ArgumentError, + "a macro component must return {:ok, directives}, got: #{inspect(other)}" + end + + collect_directives(state, rest) + + not state.collecting_directives? and function_exported?(module, :directives, 2) -> + raise_syntax_error!( + "macro component #{module} specified directives and therefore must appear at the very beginning of the template", + tag_meta, + state + ) + + true -> + collect_directives(state, rest) + end + rescue + e in ArgumentError -> + raise_syntax_error!( + Exception.message(e), + tag_meta, + state + ) + end + + :tag -> + collect_directives(%{state | collecting_directives?: false}, rest) + end + + [_ | rest] -> + # We encountered something other than whitespace or a macrocomponent, so stop collecting. + collect_directives(%{state | collecting_directives?: false}, rest) + end + end + + defp apply_macro_component_directive!({:root_tag_annotation, value}, module, tag_meta, state) do + case Application.get_env(:phoenix_live_view, :root_tag_annotation) do + anno when is_binary(anno) -> + :ok + + anno -> + message = """ + no root tag annotation is configured for macro component :root_tag_annotation directive + + Macro Component: #{module} + + Expected a string root tag annotation to be configured, got: #{inspect(anno)} + + You can configure a root tag annotation like so: + + config :phoenix_live_view, root_tag_annotation: "phx-r" + """ + + raise_syntax_error!(message, tag_meta, state) + end + + if is_binary(value) do + %{state | root_tag_annotations: [value | state.root_tag_annotations]} + else + raise_syntax_error!( + "expected string value for :root_tag_annotation directive from macro component #{module}, got: #{inspect(value)}", + tag_meta, + state + ) + end + end + + defp apply_macro_component_directive!(directive, module, tag_meta, state) do + raise_syntax_error!( + "unknown directive #{inspect(directive)} provided by macro component #{module}", + tag_meta, + state + ) + end + @impl true def handle_expr(%{tokens: tokens} = state, marker, expr) do %{state | tokens: [{:expr, marker, expr} | tokens]} @@ -837,12 +928,11 @@ defmodule Phoenix.LiveView.TagEngine do validate_tag_attrs!(attrs, tag_meta, state) previous_tag = List.first(state.tags) - case List.keytake(attrs, ":type", 0) do - {{":type", {:expr, code, _}, _meta}, attrs} -> - # validate_phx_attrs! already ensured that if :type is present, it is an expression - handle_macro_component([{:tag, name, attrs, tag_meta} | tokens], code, state) + case check_and_validate_macro_component(token, state) do + {:macro_component, module, attrs} -> + handle_macro_component([{:tag, name, attrs, tag_meta} | tokens], module, state) - nil -> + :tag -> case pop_special_attrs!(attrs, tag_meta, state) do {false, tag_meta, attrs} -> state @@ -876,7 +966,7 @@ defmodule Phoenix.LiveView.TagEngine do defp handle_macro_component( [{:tag, _name, _attrs, tag_meta} | _] = tokens, - module_string, + module, state ) do # Macro components work by converting the HEEx tokens into an AST @@ -886,13 +976,6 @@ defmodule Phoenix.LiveView.TagEngine do # # The AST is limited in functionality and we handle it separately in # the handle_ast function. - - Macro.Env.required?(state.caller, Phoenix.Component) || - raise ArgumentError, - "macro components are only supported in modules that `use Phoenix.Component`" - - module = validate_module!(module_string, tag_meta, state) - try do {ast, rest} = case Phoenix.Component.MacroComponent.build_ast(tokens, state.caller) do @@ -921,79 +1004,12 @@ defmodule Phoenix.LiveView.TagEngine do |> handle_ast(new_ast, tag_meta) |> continue(maybe_prune_text_after_macro_component(new_ast, rest)) - {{:ok, new_ast, data, directives}, rest} -> - Module.put_attribute(state.caller.module, :__macro_components__, {module, data}) - - # This is effectively a check for this macro component - # being at the very beginning of the root of the template - if Enum.any?(directives) and not is_nil(state.root) do - raise_syntax_error!( - "macro component #{module} specified directives and therefore must appear at the very beginning of the template", - tag_meta, - state - ) - end - - state = - Enum.reduce(directives, state, fn directive, state -> - apply_macro_component_directive!(directive, module, tag_meta, state) - end) - - state - |> handle_ast(new_ast, tag_meta) - |> continue(maybe_prune_text_after_macro_component(new_ast, rest)) - {other, _rest} -> raise ArgumentError, - "a macro component must return {:ok, ast}, {:ok, ast, data}, or {:ok, ast, data, directives}, got: #{inspect(other)}" + "a macro component must return {:ok, ast} or {:ok, ast, data}, got: #{inspect(other)}" end end - defp apply_macro_component_directive!({:root_tag_annotation, value}, module, tag_meta, state) do - case Application.get_env(:phoenix_live_view, :root_tag_annotation) do - anno when is_binary(anno) -> - :ok - - anno -> - message = """ - no root tag annotation is configured for macro component :root_tag_annotation directive - - Macro Component: #{module} - - Expected a string root tag annotation to be configured, got: #{inspect(anno)} - - You can configure a root tag annotation like so: - - config :phoenix_live_view, root_tag_annotation: "phx-r" - """ - - raise_syntax_error!(message, tag_meta, state) - end - - cond do - !state.root_tag_annotations -> - state - - is_binary(value) -> - %{state | root_tag_annotations: [value | state.root_tag_annotations]} - - true -> - raise_syntax_error!( - "expected string value for :root_tag_annotation directive from macro component #{module}, got: #{inspect(value)}", - tag_meta, - state - ) - end - end - - defp apply_macro_component_directive!(directive, module, tag_meta, state) do - raise_syntax_error!( - "unknown directive #{inspect(directive)} provided by macro component #{module}", - tag_meta, - state - ) - end - # self closing / void tags cannot have children defp handle_ast(state, {tag, attrs, [], %{closing: closing}}, tag_open_meta) do suffix = @@ -1047,7 +1063,26 @@ defmodule Phoenix.LiveView.TagEngine do end) end - defp validate_module!(module_string, tag_meta, state) do + defp check_and_validate_macro_component({:tag, _name, attrs, tag_meta}, state) do + validate_phx_attrs!(attrs, tag_meta, state) + + # validate_phx_attrs! already ensured that if :type is present, it is an expression + case List.keytake(attrs, ":type", 0) do + {{":type", {:expr, module_string, _}, _meta}, attrs} -> + Macro.Env.required?(state.caller, Phoenix.Component) || + raise ArgumentError, + "macro components are only supported in modules that `use Phoenix.Component`" + + module = validate_macro_component_module!(module_string, tag_meta, state) + + {:macro_component, module, attrs} + + nil -> + :tag + end + end + + defp validate_macro_component_module!(module_string, tag_meta, state) do module = Code.string_to_quoted!(module_string, file: state.file, diff --git a/lib/phoenix_live_view/tokenizer.ex b/lib/phoenix_live_view/tokenizer.ex index 93c9f0f842..5c6e2f311d 100644 --- a/lib/phoenix_live_view/tokenizer.ex +++ b/lib/phoenix_live_view/tokenizer.ex @@ -735,7 +735,8 @@ defmodule Phoenix.LiveView.Tokenizer do [{type, name, attrs, meta} | acc] end - defp strip_text_token_fully(tokens) do + @doc false + def strip_text_token_fully(tokens) do with [{:text, text, _} | rest] <- tokens, "" <- String.trim_leading(text) do strip_text_token_fully(rest) diff --git a/test/phoenix_component/macro_component_integration_test.exs b/test/phoenix_component/macro_component_integration_test.exs index 68f072b9e8..5b43293b26 100644 --- a/test/phoenix_component/macro_component_integration_test.exs +++ b/test/phoenix_component/macro_component_integration_test.exs @@ -24,27 +24,42 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do defmodule DirectiveMacroComponent do @behaviour Phoenix.Component.MacroComponent + @impl true + def directives(_ast, _meta) do + {:ok, [root_tag_annotation: "test1", root_tag_annotation: "test2"]} + end + @impl true def transform(_ast, _meta) do - {:ok, "", %{}, [root_tag_annotation: "test1", root_tag_annotation: "test2"]} + {:ok, "", %{}} end end defmodule BadRootTagAnnoDirectiveMacroComponent do @behaviour Phoenix.Component.MacroComponent + @impl true + def directives(_ast, _meta) do + {:ok, [root_tag_annotation: false]} + end + @impl true def transform(_ast, _meta) do - {:ok, "", %{}, [root_tag_annotation: false]} + {:ok, "", %{}} end end defmodule UnknownDirectiveMacroComponent do @behaviour Phoenix.Component.MacroComponent + @impl true + def directives(_ast, _meta) do + {:ok, [unknown: true]} + end + @impl true def transform(_ast, _meta) do - {:ok, "", %{}, [unknown: true]} + {:ok, "", %{}} end end diff --git a/test/phoenix_live_view/html_engine_test.exs b/test/phoenix_live_view/html_engine_test.exs index eb27834f90..fbae3b173a 100644 --- a/test/phoenix_live_view/html_engine_test.exs +++ b/test/phoenix_live_view/html_engine_test.exs @@ -653,6 +653,42 @@ defmodule Phoenix.LiveView.HTMLEngineTest do assert normalize_whitespace(compiled) == normalize_whitespace(expected) end + test "within nestings" do + assigns = %{} + + compiled = compile("") + + expected = """ +
+
+
+

+ True +

+
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + + compiled = compile("") + + expected = """ +
+
+
+

+ False +

+
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end + test "extra annotations provided by macro component directives" do assigns = %{} @@ -676,6 +712,42 @@ defmodule Phoenix.LiveView.HTMLEngineTest do assert normalize_whitespace(compiled) == normalize_whitespace(expected) end + + test "extra annotations provided by macro component directives within nestings" do + assigns = %{} + + compiled = compile("") + + expected = """ +
+
+
+

+ True +

+
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + + compiled = compile("") + + expected = """ +
+
+
+

+ False +

+
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end end describe "handle function components" do diff --git a/test/support/live_views/root_tag_anno.exs b/test/support/live_views/root_tag_anno.exs index b424daee9c..c88aba2b24 100644 --- a/test/support/live_views/root_tag_anno.exs +++ b/test/support/live_views/root_tag_anno.exs @@ -6,12 +6,66 @@ defmodule Phoenix.LiveViewTest.Support.RootTagAnno do defmodule RootTagMacroComponent do @behaviour Phoenix.Component.MacroComponent + @impl true + def directives(_ast, _meta) do + {:ok, [root_tag_annotation: "test1", root_tag_annotation: "test2"]} + end + @impl true def transform(_ast, _meta) do - {:ok, "", %{}, [root_tag_annotation: "test1", root_tag_annotation: "test2"]} + {:ok, "", %{}} end end + def macro_component_annos_within_nestings(assigns) do + ~H""" +
+ <%= if true do %> +
+
+ <%= if @bool do %> + <.inner_block_and_slot> +

+ True +

+ + <% else %> + <.inner_block_and_slot> +

+ False +

+ + <% end %> +
+
+ <% end %> + """ + end + + def within_nestings(assigns) do + ~H""" + <%= if true do %> +
+
+ <%= if @bool do %> + <.inner_block_and_slot> +

+ True +

+ + <% else %> + <.inner_block_and_slot> +

+ False +

+ + <% end %> +
+
+ <% end %> + """ + end + def macro_component_annos(assigns) do ~H"""
From 249e76dd1916ca66d747ca91c33bdc8229d00fd0 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sat, 17 Jan 2026 15:07:04 -0500 Subject: [PATCH 03/13] Apply separate root tag attributes rather than one attribute with multiple values One attribute being specified multiple times with different values is invalid HTML --- lib/phoenix_component.ex | 91 --------------- lib/phoenix_component/macro_component.ex | 105 +++++++++++++++++- lib/phoenix_live_view/tag_engine.ex | 52 ++++----- .../macro_component_integration_test.exs | 36 +++--- test/phoenix_live_view/html_engine_test.exs | 14 +-- test/support/live_views/root_tag_anno.exs | 8 +- test/test_helper.exs | 4 +- 7 files changed, 160 insertions(+), 150 deletions(-) diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 0fdb214ccb..78392549fb 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -554,97 +554,6 @@ defmodule Phoenix.Component do Changing this configuration will require `mix clean` and a full recompile. - ## Root tag annotations - - HEEx templates support adding root tag annotations to the rendered page. - These can be useful for debugging or as selectors for things such as CSS. - - Note that root tag annotations are applied to all root tags in the given - template, not just the outermost root tags. This means that tags at the root - of the template itself, at the root of any component inner blocks, or at - the root of any component slots will all be annotated. - - For example, imagine the following component definition: - - ```elixir - defmodule MyAppWeb.MyModule do - slot :inner_block, required: true - slot :named_slot, required: true - - def my_function(assigns) do - ~H""" -
-
- {render_slot(@inner_block)} -
-
- - """ - end - end - ``` - - And the following HEEx template: - - ```heex -
-
- <.my_function> -

- - Inner Block - -

- <:named_slot> -

- - Named Slot - -

- - -
-
- ``` - - By setting `root_tag_annotation` to "phx-r", the rendered HTML would look as follows: - - ```html -
-
-
-
-

- - Inner Block - -

-
-
- -
-
- ``` - - This feature works on any `~H` or `.html.heex` template. They can be enabled - globally with the following configuration in your `config/config.exs` file: - - config :phoenix_live_view, root_tag_annotation: "phx-r" - - Changing this configuration will require `mix clean` and a full recompile. - ## Dynamic Component Rendering Sometimes you might need to decide at runtime which component to render. diff --git a/lib/phoenix_component/macro_component.ex b/lib/phoenix_component/macro_component.ex index 2400889b20..69d01e60b9 100644 --- a/lib/phoenix_component/macro_component.ex +++ b/lib/phoenix_component/macro_component.ex @@ -121,6 +121,100 @@ defmodule Phoenix.Component.MacroComponent do # [syntax highlighting at compile time](https://github.com/phoenixframework/phoenix_live_view/blob/38851d943f3280c5982d75679291dccb8c442534/test/e2e/support/colocated_live.ex#L4-L35) # using the [Makeup](https://hexdocs.pm/makeup/Makeup.html) library. # + # ## Root tag attributes + # + # HEEx templates support adding a root tag attributes to the rendered page. + # These can be useful for debugging or as selectors for things such as CSS. + # + # Note that root tag attributes are applied to all root tags in the given + # template, not just the outermost root tags. This means that tags at the root + # of the template itself, at the root of any component inner blocks, or at + # the root of any component slots will all have root tag attributes applied. + # + # For example, imagine the following component definition: + # + # ```elixir + # defmodule MyAppWeb.MyModule do + # slot :inner_block, required: true + # slot :named_slot, required: true + # + # def my_function(assigns) do + # ~H""" + #
+ #
+ # {render_slot(@inner_block)} + #
+ #
+ # + # """ + # end + # end + # ``` + # + # And the following HEEx template: + # + # ```heex + #
+ #
+ # <.my_function> + #

+ # + # Inner Block + # + #

+ # <:named_slot> + #

+ # + # Named Slot + # + #

+ # + # + #
+ #
+ # ``` + # + # By setting the global `root_tag_attribute` to "phx-r", the rendered HTML would look as follows: + # + # ```html + #
+ #
+ #
+ #
+ #

+ # + # Inner Block + # + #

+ #
+ #
+ # + #
+ #
+ # ``` + # + # This feature works on any `~H` or `.html.heex` template. They can be enabled + # globally with the following configuration in your `config/config.exs` file: + # + # config :phoenix_live_view, root_tag_attribute: "phx-r" + # + # Changing this configuration will require `mix clean` and a full recompile. + # + # Additional root tag attributes can also be applied by MacroComponents. See the `Directives` section + # below for details. + # # ## Directives # # Macro components may return directives from the module's optional `c:directives/2` callback @@ -133,7 +227,7 @@ defmodule Phoenix.Component.MacroComponent do # # @impl true # def directives(_ast, _meta) do - # {:ok, [root_tag_annotation: "test1", root_tag_annotation: "test2"]} + # {:ok, [root_tag_attribute: {"phx-sample-one", "test"}, root_tag_attribute: {"phx-sample-two", "test"}]} # end # # @impl true @@ -146,10 +240,9 @@ defmodule Phoenix.Component.MacroComponent do # # ### Options # - # * `:root_tag_annotation` - A value to apply as an annotation to all root tags during template compilation. - # Requires that a `:root_tag_annotation` be configured for the application. Value must be a string. May be - # provided multiple times to apply multiple annotations. See the docs for `Phoenix.Component` for details on - # root tag annotations and how to configure them. + # * `:root_tag_attribute` - `{attribute_name, attribute_value}` to apply as an attribute to all root tags during template compilation. + # Requires that a global `:root_tag_attribute` be configured for the application. Both name and value must be compile-time strings. May be + # provided multiple times to apply multiple attributes. See the `Root tag attributes` above for more details. @type tag :: binary() @type attribute :: {binary(), Macro.t()} @@ -159,7 +252,7 @@ defmodule Phoenix.Component.MacroComponent do @type heex_ast :: {tag(), attributes(), children(), tag_meta()} | binary() @type directives_meta :: %{env: Macro.Env.t()} @type transform_meta :: %{env: Macro.Env.t()} - @type directive :: {:root_tag_annotation, String.t()} + @type directive :: {:root_tag_attribute, {name :: String.t(), value :: String.t()}} @type directives :: [directive] @optional_callbacks [directives: 2] diff --git a/lib/phoenix_live_view/tag_engine.ex b/lib/phoenix_live_view/tag_engine.ex index 38a6d4916d..4efd06b6cb 100644 --- a/lib/phoenix_live_view/tag_engine.ex +++ b/lib/phoenix_live_view/tag_engine.ex @@ -206,7 +206,7 @@ defmodule Phoenix.LiveView.TagEngine do caller: Keyword.fetch!(opts, :caller), source: Keyword.fetch!(opts, :source), tag_handler: tag_handler, - root_tag_annotations: [], + root_tag_attributes: [], collecting_directives?: true } end @@ -286,7 +286,7 @@ defmodule Phoenix.LiveView.TagEngine do source: source, indentation: indentation, tag_handler: tag_handler, - root_tag_annotations: root_tag_annotations, + root_tag_attributes: root_tag_attributes, collecting_directives?: collecting_directives? }, root @@ -303,7 +303,7 @@ defmodule Phoenix.LiveView.TagEngine do root: root, indentation: indentation, tag_handler: tag_handler, - root_tag_annotations: root_tag_annotations, + root_tag_attributes: root_tag_attributes, collecting_directives?: collecting_directives? } end @@ -401,35 +401,37 @@ defmodule Phoenix.LiveView.TagEngine do end end - defp apply_macro_component_directive!({:root_tag_annotation, value}, module, tag_meta, state) do - case Application.get_env(:phoenix_live_view, :root_tag_annotation) do - anno when is_binary(anno) -> + defp apply_macro_component_directive!({:root_tag_attribute, attribute}, module, tag_meta, state) do + case Application.get_env(:phoenix_live_view, :root_tag_attribute) do + root_tag_attribute when is_binary(root_tag_attribute) -> :ok - anno -> + root_tag_attribute -> message = """ - no root tag annotation is configured for macro component :root_tag_annotation directive + a global :root_tag_attribute must be configured for macro components to use the :root_tag_attribute directive Macro Component: #{module} - Expected a string root tag annotation to be configured, got: #{inspect(anno)} + Expected global :root_tag_attribute to be a string, got: #{inspect(root_tag_attribute)} - You can configure a root tag annotation like so: + You can configure a global root tag attribute like so: - config :phoenix_live_view, root_tag_annotation: "phx-r" + config :phoenix_live_view, root_tag_attribute: "phx-r" """ raise_syntax_error!(message, tag_meta, state) end - if is_binary(value) do - %{state | root_tag_annotations: [value | state.root_tag_annotations]} - else - raise_syntax_error!( - "expected string value for :root_tag_annotation directive from macro component #{module}, got: #{inspect(value)}", - tag_meta, - state - ) + case attribute do + {name, value} when is_binary(name) and is_binary(value) -> + %{state | root_tag_attributes: [{name, value} | state.root_tag_attributes]} + + attribute -> + raise_syntax_error!( + "expected {name, value} compile-time strings for :root_tag_attribute directive from macro component #{module}, got: #{inspect(attribute)}", + tag_meta, + state + ) end end @@ -1184,7 +1186,7 @@ defmodule Phoenix.LiveView.TagEngine do text = "<#{name}" |> maybe_add_phx_loc(meta) - |> maybe_add_root_tag_annotations(state, previous_tag) + |> maybe_add_root_tag_attributes(state, previous_tag) state |> update_subengine(:handle_text, [meta, text]) @@ -1200,18 +1202,16 @@ defmodule Phoenix.LiveView.TagEngine do end end - defp maybe_add_root_tag_annotations(text, state, previous_tag) do - case Application.get_env(:phoenix_live_view, :root_tag_annotation) do - anno when is_binary(anno) -> + defp maybe_add_root_tag_attributes(text, state, previous_tag) do + case Application.get_env(:phoenix_live_view, :root_tag_attribute) do + root_tag_attribute when is_binary(root_tag_attribute) -> # By checking if the previous tag that was pushed was a normal html tag, # we effectively check if the tag we are dealing with is a "local" root # (root tag of the whole template, a component's inner block, or a slot) # or not. if not match?({:tag, _, _, _}, previous_tag) do - annos = Enum.map(state.root_tag_annotations, fn val -> {anno, val} end) - attrs = - [{anno, true} | annos] + [{root_tag_attribute, true} | state.root_tag_attributes] |> Phoenix.HTML.attributes_escape() |> Phoenix.HTML.safe_to_string() diff --git a/test/phoenix_component/macro_component_integration_test.exs b/test/phoenix_component/macro_component_integration_test.exs index 5b43293b26..0f4a5aa3e3 100644 --- a/test/phoenix_component/macro_component_integration_test.exs +++ b/test/phoenix_component/macro_component_integration_test.exs @@ -1,6 +1,6 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do # async: false due to manipulating the Application env - # for :root_tag_annotation + # for :root_tag_attribute use ExUnit.Case, async: false use Phoenix.Component @@ -26,7 +26,11 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do @impl true def directives(_ast, _meta) do - {:ok, [root_tag_annotation: "test1", root_tag_annotation: "test2"]} + {:ok, + [ + root_tag_attribute: {"phx-sample-one", "test"}, + root_tag_attribute: {"phx-sample-two", "test"} + ]} end @impl true @@ -40,7 +44,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do @impl true def directives(_ast, _meta) do - {:ok, [root_tag_annotation: false]} + {:ok, [root_tag_attribute: false]} end @impl true @@ -283,17 +287,17 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end end - test "raises if :root_tag_annotation is not configured and the root_tag_annotation directive is provided" do + test "raises if :root_tag_attribute is not configured and the :root_tag_attribute directive is provided" do message = ~r""" - no root tag annotation is configured for macro component :root_tag_annotation directive + a global :root_tag_attribute must be configured for macro components to use the :root_tag_attribute directive Macro Component: #{__MODULE__}\.DirectiveMacroComponent - Expected a string root tag annotation to be configured, got: nil + Expected global :root_tag_attribute to be a string, got: nil - You can configure a root tag annotation like so: + You can configure a global root tag attribute like so: - config :phoenix_live_view, root_tag_annotation: \"phx-r\" + config :phoenix_live_view, root_tag_attribute: \"phx-r\" """ assert_raise Phoenix.LiveView.Tokenizer.ParseError, @@ -311,7 +315,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end end - test "raises if root_tag_annotation directive is provided with a non-string value" do + test "raises if an unknown directive is provided" do message = ~r/unknown directive {:unknown, true} provided by macro component #{__MODULE__}\.UnknownDirectiveMacroComponent/ @@ -332,16 +336,16 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do describe "macro components with directives" do setup do - # Need to set a :root_tag_annotation as the only directive supported - # by macro components currently is [root_tag_annotation: "value"] which - # requires a :root_tag_annotation to be configured - Application.put_env(:phoenix_live_view, :root_tag_annotation, "phx-r") - on_exit(fn -> Application.delete_env(:phoenix_live_view, :root_tag_annotation) end) + # Need to set a :root_tag_attribute as the only directive supported + # by macro components currently is [root_tag_attribute: {name, value}] which + # requires a :root_tag_attribute to be configured + Application.put_env(:phoenix_live_view, :root_tag_attribute, "phx-r") + on_exit(fn -> Application.delete_env(:phoenix_live_view, :root_tag_attribute) end) end - test "raises if root_tag_annotation directive is provided with a non-string value" do + test "raises if :root_tag_attribute directive is provided with an invalid value" do message = - ~r/expected string value for :root_tag_annotation directive from macro component #{__MODULE__}\.BadRootTagAnnoDirectiveMacroComponent, got: false/ + ~r/expected {name, value} compile-time strings for :root_tag_attribute directive from macro component #{__MODULE__}\.BadRootTagAnnoDirectiveMacroComponent, got: false/ assert_raise Phoenix.LiveView.Tokenizer.ParseError, message, diff --git a/test/phoenix_live_view/html_engine_test.exs b/test/phoenix_live_view/html_engine_test.exs index fbae3b173a..2d3110bf9d 100644 --- a/test/phoenix_live_view/html_engine_test.exs +++ b/test/phoenix_live_view/html_engine_test.exs @@ -696,12 +696,12 @@ defmodule Phoenix.LiveView.HTMLEngineTest do expected = """ -
+
-
Inner Block
+
Inner Block
@@ -719,10 +719,10 @@ defmodule Phoenix.LiveView.HTMLEngineTest do compiled = compile("") expected = """ -
+
-

+

True

@@ -735,10 +735,10 @@ defmodule Phoenix.LiveView.HTMLEngineTest do compiled = compile("") expected = """ -
+
-

+

False

diff --git a/test/support/live_views/root_tag_anno.exs b/test/support/live_views/root_tag_anno.exs index c88aba2b24..a50f3e6a15 100644 --- a/test/support/live_views/root_tag_anno.exs +++ b/test/support/live_views/root_tag_anno.exs @@ -1,5 +1,5 @@ # Note this file is intentionally a .exs file because it is loaded -# in the test helper with root_tag_annotation turned on. +# in the test helper with :root_tag_attribute turned on. defmodule Phoenix.LiveViewTest.Support.RootTagAnno do use Phoenix.Component @@ -8,7 +8,11 @@ defmodule Phoenix.LiveViewTest.Support.RootTagAnno do @impl true def directives(_ast, _meta) do - {:ok, [root_tag_annotation: "test1", root_tag_annotation: "test2"]} + {:ok, + [ + root_tag_attribute: {"phx-sample-one", "test"}, + root_tag_attribute: {"phx-sample-two", "test"} + ]} end @impl true diff --git a/test/test_helper.exs b/test/test_helper.exs index ab70f06fef..4cb2dfa6f4 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -4,9 +4,9 @@ Code.require_file("test/support/live_views/debug_anno.exs") Application.put_env(:phoenix_live_view, :debug_attributes, false) Application.put_env(:phoenix_live_view, :debug_heex_annotations, false) -Application.put_env(:phoenix_live_view, :root_tag_annotation, "phx-r") +Application.put_env(:phoenix_live_view, :root_tag_attribute, "phx-r") Code.require_file("test/support/live_views/root_tag_anno.exs") -Application.delete_env(:phoenix_live_view, :root_tag_annotation) +Application.delete_env(:phoenix_live_view, :root_tag_attribute) {:ok, _} = Phoenix.LiveViewTest.Support.Endpoint.start_link() ExUnit.start() From 1fc284cfac4daf62672fac784f68f1213589767f Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sat, 17 Jan 2026 15:21:53 -0500 Subject: [PATCH 04/13] Default root_tag_attribute to "phx-r" --- config/test.exs | 5 +++++ lib/phoenix_component/macro_component.ex | 3 ++- lib/phoenix_live_view/tag_engine.ex | 19 +++++++++++++++---- .../macro_component_integration_test.exs | 11 ++++++++--- test/test_helper.exs | 2 +- 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/config/test.exs b/config/test.exs index 48793de45e..7b33aa6c66 100644 --- a/config/test.exs +++ b/config/test.exs @@ -4,3 +4,8 @@ config :logger, :level, :debug config :logger, :default_handler, false config :phoenix_live_view, enable_expensive_runtime_checks: true + +# Disable :root_tag_attribute so the majority of the tests +# are not polluted by it. It will be explicitly re-enabled for +# tests related to :root_tag_attribute functionality. +config :phoenix_live_view, :root_tag_attribute, nil diff --git a/lib/phoenix_component/macro_component.ex b/lib/phoenix_component/macro_component.ex index 69d01e60b9..3a9fe3c467 100644 --- a/lib/phoenix_component/macro_component.ex +++ b/lib/phoenix_component/macro_component.ex @@ -242,7 +242,8 @@ defmodule Phoenix.Component.MacroComponent do # # * `:root_tag_attribute` - `{attribute_name, attribute_value}` to apply as an attribute to all root tags during template compilation. # Requires that a global `:root_tag_attribute` be configured for the application. Both name and value must be compile-time strings. May be - # provided multiple times to apply multiple attributes. See the `Root tag attributes` above for more details. + # provided multiple times to apply multiple attributes. Requires that a global `:root_tag_attribute` be configured. See the `Root tag attributes` + # section above for more details. @type tag :: binary() @type attribute :: {binary(), Macro.t()} diff --git a/lib/phoenix_live_view/tag_engine.ex b/lib/phoenix_live_view/tag_engine.ex index 4efd06b6cb..8a52826a5c 100644 --- a/lib/phoenix_live_view/tag_engine.ex +++ b/lib/phoenix_live_view/tag_engine.ex @@ -20,6 +20,8 @@ defmodule Phoenix.LiveView.TagEngine do Where `:tag_handler` implements the behaviour defined by this module. """ + @default_root_tag_attribute "phx-r" + @doc """ Classify the tag type from the given binary. @@ -402,7 +404,7 @@ defmodule Phoenix.LiveView.TagEngine do end defp apply_macro_component_directive!({:root_tag_attribute, attribute}, module, tag_meta, state) do - case Application.get_env(:phoenix_live_view, :root_tag_attribute) do + case get_root_tag_attribute() do root_tag_attribute when is_binary(root_tag_attribute) -> :ok @@ -414,9 +416,14 @@ defmodule Phoenix.LiveView.TagEngine do Expected global :root_tag_attribute to be a string, got: #{inspect(root_tag_attribute)} - You can configure a global root tag attribute like so: + The global :root_tag_attribute defaults to "#{@default_root_tag_attribute}". If you are getting this warning, the global + :root_tag_attribute has been explicitly disabled in your application's config. You will be unable + to use this macro component until the global `:root_tag_attribute` is re-enabled. + + If you wish to use a global :root_tag_attribute other than "#{@default_root_tag_attribute}", you can configure a + custom attribute like so: - config :phoenix_live_view, root_tag_attribute: "phx-r" + config :phoenix_live_view, root_tag_attribute: "your-attribute-here" """ raise_syntax_error!(message, tag_meta, state) @@ -1203,7 +1210,7 @@ defmodule Phoenix.LiveView.TagEngine do end defp maybe_add_root_tag_attributes(text, state, previous_tag) do - case Application.get_env(:phoenix_live_view, :root_tag_attribute) do + case get_root_tag_attribute() do root_tag_attribute when is_binary(root_tag_attribute) -> # By checking if the previous tag that was pushed was a normal html tag, # we effectively check if the tag we are dealing with is a "local" root @@ -1646,6 +1653,10 @@ defmodule Phoenix.LiveView.TagEngine do ## Helpers + defp get_root_tag_attribute() do + Application.get_env(:phoenix_live_view, :root_tag_attribute, @default_root_tag_attribute) + end + defp to_location(%{line: line, column: column}), do: [line: line, column: column] defp actual_component_module(env, fun) do diff --git a/test/phoenix_component/macro_component_integration_test.exs b/test/phoenix_component/macro_component_integration_test.exs index 0f4a5aa3e3..006df7892b 100644 --- a/test/phoenix_component/macro_component_integration_test.exs +++ b/test/phoenix_component/macro_component_integration_test.exs @@ -295,9 +295,14 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do Expected global :root_tag_attribute to be a string, got: nil - You can configure a global root tag attribute like so: + The global :root_tag_attribute defaults to "phx-r". If you are getting this warning, the global + :root_tag_attribute has been explicitly disabled in your application's config. You will be unable + to use this macro component until the global `:root_tag_attribute` is re-enabled. - config :phoenix_live_view, root_tag_attribute: \"phx-r\" + If you wish to use a global :root_tag_attribute other than "phx-r", you can configure a + custom attribute like so: + + config :phoenix_live_view, root_tag_attribute: "your-attribute-here" """ assert_raise Phoenix.LiveView.Tokenizer.ParseError, @@ -340,7 +345,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do # by macro components currently is [root_tag_attribute: {name, value}] which # requires a :root_tag_attribute to be configured Application.put_env(:phoenix_live_view, :root_tag_attribute, "phx-r") - on_exit(fn -> Application.delete_env(:phoenix_live_view, :root_tag_attribute) end) + on_exit(fn -> Application.put_env(:phoenix_live_view, :root_tag_attribute, nil) end) end test "raises if :root_tag_attribute directive is provided with an invalid value" do diff --git a/test/test_helper.exs b/test/test_helper.exs index 4cb2dfa6f4..edd6f0bd1b 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -6,7 +6,7 @@ Application.put_env(:phoenix_live_view, :debug_heex_annotations, false) Application.put_env(:phoenix_live_view, :root_tag_attribute, "phx-r") Code.require_file("test/support/live_views/root_tag_anno.exs") -Application.delete_env(:phoenix_live_view, :root_tag_attribute) +Application.put_env(:phoenix_live_view, :root_tag_attribute, nil) {:ok, _} = Phoenix.LiveViewTest.Support.Endpoint.start_link() ExUnit.start() From 932c135fd9ac69f8196503d656526ea3579b8502 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sat, 17 Jan 2026 15:35:44 -0500 Subject: [PATCH 05/13] Fix incorrect typespec --- lib/phoenix_component/macro_component.ex | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/phoenix_component/macro_component.ex b/lib/phoenix_component/macro_component.ex index 3a9fe3c467..a5f65898a5 100644 --- a/lib/phoenix_component/macro_component.ex +++ b/lib/phoenix_component/macro_component.ex @@ -251,19 +251,17 @@ defmodule Phoenix.Component.MacroComponent do @type children :: [heex_ast()] @type tag_meta :: %{closing: :self | :void} @type heex_ast :: {tag(), attributes(), children(), tag_meta()} | binary() - @type directives_meta :: %{env: Macro.Env.t()} @type transform_meta :: %{env: Macro.Env.t()} + @type directives_meta :: %{env: Macro.Env.t()} @type directive :: {:root_tag_attribute, {name :: String.t(), value :: String.t()}} @type directives :: [directive] @optional_callbacks [directives: 2] - @callback directives(heex_ast :: heex_ast(), meta :: directives_meta()) :: {:ok, directives} + @callback directives(heex_ast :: heex_ast(), meta :: directives_meta()) :: {:ok, directives()} @callback transform(heex_ast :: heex_ast(), meta :: transform_meta()) :: - {:ok, heex_ast()} - | {:ok, heex_ast(), data :: term()} - | {:ok, heex_ast(), data :: term(), directives :: directives()} + {:ok, heex_ast()} | {:ok, heex_ast(), data :: term()} @doc """ Returns the stored data from macro components that returned `{:ok, ast, data}`. From f458b8dfe01cd3ae222f1a4b001dd67a9c7f5cf0 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sat, 17 Jan 2026 15:41:53 -0500 Subject: [PATCH 06/13] Anno -> Attr --- .../macro_component_integration_test.exs | 10 +++--- test/phoenix_live_view/html_engine_test.exs | 34 +++++++++---------- .../{root_tag_anno.exs => root_tag_attr.exs} | 10 +++--- test/test_helper.exs | 2 +- 4 files changed, 28 insertions(+), 28 deletions(-) rename test/support/live_views/{root_tag_anno.exs => root_tag_attr.exs} (94%) diff --git a/test/phoenix_component/macro_component_integration_test.exs b/test/phoenix_component/macro_component_integration_test.exs index 006df7892b..228fd44fb9 100644 --- a/test/phoenix_component/macro_component_integration_test.exs +++ b/test/phoenix_component/macro_component_integration_test.exs @@ -39,7 +39,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end end - defmodule BadRootTagAnnoDirectiveMacroComponent do + defmodule BadRootTagAttrDirectiveMacroComponent do @behaviour Phoenix.Component.MacroComponent @impl true @@ -308,7 +308,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do assert_raise Phoenix.LiveView.Tokenizer.ParseError, message, fn -> - defmodule TestRootTagAnnotationNotConfig do + defmodule TestRootTagAttrNotConfig do use Phoenix.Component def render(assigns) do @@ -350,17 +350,17 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do test "raises if :root_tag_attribute directive is provided with an invalid value" do message = - ~r/expected {name, value} compile-time strings for :root_tag_attribute directive from macro component #{__MODULE__}\.BadRootTagAnnoDirectiveMacroComponent, got: false/ + ~r/expected {name, value} compile-time strings for :root_tag_attribute directive from macro component #{__MODULE__}\.BadRootTagAttrDirectiveMacroComponent, got: false/ assert_raise Phoenix.LiveView.Tokenizer.ParseError, message, fn -> - defmodule TestBadRootTagAnnoDirective do + defmodule TestBadRootTagAttrDirective do use Phoenix.Component def render(assigns) do ~H""" -
+
""" end end diff --git a/test/phoenix_live_view/html_engine_test.exs b/test/phoenix_live_view/html_engine_test.exs index 2d3110bf9d..ee33e81471 100644 --- a/test/phoenix_live_view/html_engine_test.exs +++ b/test/phoenix_live_view/html_engine_test.exs @@ -467,13 +467,13 @@ defmodule Phoenix.LiveView.HTMLEngineTest do end end - describe "root tag annotations" do - alias Phoenix.LiveViewTest.Support.RootTagAnno + describe "root tag attributes" do + alias Phoenix.LiveViewTest.Support.RootTagAttr test "single self-closing tag" do assigns = %{} - compiled = compile("") + compiled = compile("") expected = "
" @@ -483,7 +483,7 @@ defmodule Phoenix.LiveView.HTMLEngineTest do test "single tag with body" do assigns = %{} - compiled = compile("") + compiled = compile("") expected = "
Test
" @@ -493,7 +493,7 @@ defmodule Phoenix.LiveView.HTMLEngineTest do test "multiple self-closing tags" do assigns = %{} - compiled = compile("") + compiled = compile("") expected = """
@@ -507,7 +507,7 @@ defmodule Phoenix.LiveView.HTMLEngineTest do test "multiple tags with bodies" do assigns = %{} - compiled = compile("") + compiled = compile("") expected = """
Test1
@@ -521,7 +521,7 @@ defmodule Phoenix.LiveView.HTMLEngineTest do test "tags root tags of nested tags" do assigns = %{} - compiled = compile("") + compiled = compile("") expected = """
@@ -548,7 +548,7 @@ defmodule Phoenix.LiveView.HTMLEngineTest do test "tags root tags of component inner_blocks" do assigns = %{} - compiled = compile("") + compiled = compile("") expected = """ @@ -578,7 +578,7 @@ defmodule Phoenix.LiveView.HTMLEngineTest do test "tags root tags of component named slots" do assigns = %{} - compiled = compile("") + compiled = compile("") expected = """ @@ -612,7 +612,7 @@ defmodule Phoenix.LiveView.HTMLEngineTest do test "tags root tags correctly for complex nestings of tags, components, and slots" do assigns = %{} - compiled = compile("") + compiled = compile("") expected = """ @@ -656,7 +656,7 @@ defmodule Phoenix.LiveView.HTMLEngineTest do test "within nestings" do assigns = %{} - compiled = compile("") + compiled = compile("") expected = """
@@ -672,7 +672,7 @@ defmodule Phoenix.LiveView.HTMLEngineTest do assert normalize_whitespace(compiled) == normalize_whitespace(expected) - compiled = compile("") + compiled = compile("") expected = """
@@ -689,10 +689,10 @@ defmodule Phoenix.LiveView.HTMLEngineTest do assert normalize_whitespace(compiled) == normalize_whitespace(expected) end - test "extra annotations provided by macro component directives" do + test "extra attributes provided by macro component directives" do assigns = %{} - compiled = compile("") + compiled = compile("") expected = """ @@ -713,10 +713,10 @@ defmodule Phoenix.LiveView.HTMLEngineTest do assert normalize_whitespace(compiled) == normalize_whitespace(expected) end - test "extra annotations provided by macro component directives within nestings" do + test "extra attributes provided by macro component directives within nestings" do assigns = %{} - compiled = compile("") + compiled = compile("") expected = """
@@ -732,7 +732,7 @@ defmodule Phoenix.LiveView.HTMLEngineTest do assert normalize_whitespace(compiled) == normalize_whitespace(expected) - compiled = compile("") + compiled = compile("") expected = """
diff --git a/test/support/live_views/root_tag_anno.exs b/test/support/live_views/root_tag_attr.exs similarity index 94% rename from test/support/live_views/root_tag_anno.exs rename to test/support/live_views/root_tag_attr.exs index a50f3e6a15..4ad96decc3 100644 --- a/test/support/live_views/root_tag_anno.exs +++ b/test/support/live_views/root_tag_attr.exs @@ -1,6 +1,6 @@ # Note this file is intentionally a .exs file because it is loaded # in the test helper with :root_tag_attribute turned on. -defmodule Phoenix.LiveViewTest.Support.RootTagAnno do +defmodule Phoenix.LiveViewTest.Support.RootTagAttr do use Phoenix.Component defmodule RootTagMacroComponent do @@ -21,9 +21,9 @@ defmodule Phoenix.LiveViewTest.Support.RootTagAnno do end end - def macro_component_annos_within_nestings(assigns) do + def macro_component_attrs_within_nestings(assigns) do ~H""" -
+
<%= if true do %>
@@ -70,9 +70,9 @@ defmodule Phoenix.LiveViewTest.Support.RootTagAnno do """ end - def macro_component_annos(assigns) do + def macro_component_attrs(assigns) do ~H""" -
+
<.inner_block_and_slot> diff --git a/test/test_helper.exs b/test/test_helper.exs index edd6f0bd1b..294dfa4b36 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -5,7 +5,7 @@ Application.put_env(:phoenix_live_view, :debug_attributes, false) Application.put_env(:phoenix_live_view, :debug_heex_annotations, false) Application.put_env(:phoenix_live_view, :root_tag_attribute, "phx-r") -Code.require_file("test/support/live_views/root_tag_anno.exs") +Code.require_file("test/support/live_views/root_tag_attr.exs") Application.put_env(:phoenix_live_view, :root_tag_attribute, nil) {:ok, _} = Phoenix.LiveViewTest.Support.Endpoint.start_link() From 80bc68955c7db009f9443acf33864f5d0ca10a8a Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:03:49 -0500 Subject: [PATCH 07/13] Allow for MacroComponents to apply root tag attributes without values --- lib/phoenix_component/macro_component.ex | 8 ++-- lib/phoenix_live_view/tag_engine.ex | 10 +++- .../macro_component_integration_test.exs | 7 ++- test/phoenix_live_view/html_engine_test.exs | 36 +++++++++++++-- test/support/live_views/root_tag_attr.exs | 46 +++++++++++++++++-- 5 files changed, 89 insertions(+), 18 deletions(-) diff --git a/lib/phoenix_component/macro_component.ex b/lib/phoenix_component/macro_component.ex index a5f65898a5..e41bbf4ec9 100644 --- a/lib/phoenix_component/macro_component.ex +++ b/lib/phoenix_component/macro_component.ex @@ -240,10 +240,10 @@ defmodule Phoenix.Component.MacroComponent do # # ### Options # - # * `:root_tag_attribute` - `{attribute_name, attribute_value}` to apply as an attribute to all root tags during template compilation. - # Requires that a global `:root_tag_attribute` be configured for the application. Both name and value must be compile-time strings. May be - # provided multiple times to apply multiple attributes. Requires that a global `:root_tag_attribute` be configured. See the `Root tag attributes` - # section above for more details. + # * `:root_tag_attribute` - `{name, value}` pair to apply as an attribute to all root tags during template compilation. + # Requires that a global `:root_tag_attribute` be configured for the application. `name` must be a compile-time string. `value` must be a + # compile-time string or `true`. May be provided multiple times to apply multiple attributes. Requires that a global `:root_tag_attribute` be configured. + # See the `Root tag attributes` section above for more details. @type tag :: binary() @type attribute :: {binary(), Macro.t()} diff --git a/lib/phoenix_live_view/tag_engine.ex b/lib/phoenix_live_view/tag_engine.ex index 8a52826a5c..681cf2f5c7 100644 --- a/lib/phoenix_live_view/tag_engine.ex +++ b/lib/phoenix_live_view/tag_engine.ex @@ -430,12 +430,18 @@ defmodule Phoenix.LiveView.TagEngine do end case attribute do - {name, value} when is_binary(name) and is_binary(value) -> + {name, value} when (is_binary(name) and is_binary(value)) or value == true -> %{state | root_tag_attributes: [{name, value} | state.root_tag_attributes]} attribute -> + message = """ + expected {name, value} for :root_tag_attribute directive from macro component #{module}, got: #{inspect(attribute)} + + name must be a compile-time string, and value must be a compile-time string or true + """ + raise_syntax_error!( - "expected {name, value} compile-time strings for :root_tag_attribute directive from macro component #{module}, got: #{inspect(attribute)}", + message, tag_meta, state ) diff --git a/test/phoenix_component/macro_component_integration_test.exs b/test/phoenix_component/macro_component_integration_test.exs index 228fd44fb9..d73c4c4e6a 100644 --- a/test/phoenix_component/macro_component_integration_test.exs +++ b/test/phoenix_component/macro_component_integration_test.exs @@ -349,8 +349,11 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end test "raises if :root_tag_attribute directive is provided with an invalid value" do - message = - ~r/expected {name, value} compile-time strings for :root_tag_attribute directive from macro component #{__MODULE__}\.BadRootTagAttrDirectiveMacroComponent, got: false/ + message = ~r""" + expected {name, value} for :root_tag_attribute directive from macro component #{__MODULE__}\.BadRootTagAttrDirectiveMacroComponent, got: false + + name must be a compile-time string, and value must be a compile-time string or true + """ assert_raise Phoenix.LiveView.Tokenizer.ParseError, message, diff --git a/test/phoenix_live_view/html_engine_test.exs b/test/phoenix_live_view/html_engine_test.exs index ee33e81471..1255067de3 100644 --- a/test/phoenix_live_view/html_engine_test.exs +++ b/test/phoenix_live_view/html_engine_test.exs @@ -689,10 +689,10 @@ defmodule Phoenix.LiveView.HTMLEngineTest do assert normalize_whitespace(compiled) == normalize_whitespace(expected) end - test "extra attributes provided by macro component directives" do + test "extra attributes with values provided by macro component directives" do assigns = %{} - compiled = compile("") + compiled = compile("") expected = """ @@ -713,10 +713,35 @@ defmodule Phoenix.LiveView.HTMLEngineTest do assert normalize_whitespace(compiled) == normalize_whitespace(expected) end - test "extra attributes provided by macro component directives within nestings" do + test "extra attributes without values provided by macro component directives" do assigns = %{} - compiled = compile("") + compiled = compile("") + + expected = + """ +
+
+
+
Inner Block
+ +
+
+
+ """ + + assert normalize_whitespace(compiled) == normalize_whitespace(expected) + end + + test "extra attributes with values provided by macro component directives within nestings" do + assigns = %{} + + compiled = + compile("") expected = """
@@ -732,7 +757,8 @@ defmodule Phoenix.LiveView.HTMLEngineTest do assert normalize_whitespace(compiled) == normalize_whitespace(expected) - compiled = compile("") + compiled = + compile("") expected = """
diff --git a/test/support/live_views/root_tag_attr.exs b/test/support/live_views/root_tag_attr.exs index 4ad96decc3..3ebaf14d76 100644 --- a/test/support/live_views/root_tag_attr.exs +++ b/test/support/live_views/root_tag_attr.exs @@ -3,7 +3,7 @@ defmodule Phoenix.LiveViewTest.Support.RootTagAttr do use Phoenix.Component - defmodule RootTagMacroComponent do + defmodule RootTagsWithValuesMacroComponent do @behaviour Phoenix.Component.MacroComponent @impl true @@ -21,9 +21,27 @@ defmodule Phoenix.LiveViewTest.Support.RootTagAttr do end end - def macro_component_attrs_within_nestings(assigns) do + defmodule RootTagsWithoutValuesMacroComponent do + @behaviour Phoenix.Component.MacroComponent + + @impl true + def directives(_ast, _meta) do + {:ok, + [ + root_tag_attribute: {"phx-sample-one", true}, + root_tag_attribute: {"phx-sample-two", true} + ]} + end + + @impl true + def transform(_ast, _meta) do + {:ok, "", %{}} + end + end + + def macro_component_attrs_with_values_within_nestings(assigns) do ~H""" -
+
<%= if true do %>
@@ -70,9 +88,27 @@ defmodule Phoenix.LiveViewTest.Support.RootTagAttr do """ end - def macro_component_attrs(assigns) do + def macro_component_attrs_with_values(assigns) do + ~H""" +
+
+
+ <.inner_block_and_slot> +
Inner Block
+ <:test> +
+ Named Slot +
+ + +
+
+ """ + end + + def macro_component_attrs_without_values(assigns) do ~H""" -
+
<.inner_block_and_slot> From 1bf4cce631282c8f55833788ba669e97a2d9c83c Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:38:18 -0500 Subject: [PATCH 08/13] Fix incorrect typespec --- lib/phoenix_component/macro_component.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_component/macro_component.ex b/lib/phoenix_component/macro_component.ex index e41bbf4ec9..09dd5e85b1 100644 --- a/lib/phoenix_component/macro_component.ex +++ b/lib/phoenix_component/macro_component.ex @@ -253,7 +253,7 @@ defmodule Phoenix.Component.MacroComponent do @type heex_ast :: {tag(), attributes(), children(), tag_meta()} | binary() @type transform_meta :: %{env: Macro.Env.t()} @type directives_meta :: %{env: Macro.Env.t()} - @type directive :: {:root_tag_attribute, {name :: String.t(), value :: String.t()}} + @type directive :: {:root_tag_attribute, {name :: String.t(), value :: String.t() | true}} @type directives :: [directive] @optional_callbacks [directives: 2] From 7ccbb3db35d754b7e90d494cfaf3a394fba15f45 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:39:31 -0500 Subject: [PATCH 09/13] Remove unnecessary function clause for collect_directives/2 Tokenizer.strip_text_token_fully/1 already handles an empty list of tokens --- lib/phoenix_live_view/tag_engine.ex | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/phoenix_live_view/tag_engine.ex b/lib/phoenix_live_view/tag_engine.ex index 681cf2f5c7..a3c6f2ff54 100644 --- a/lib/phoenix_live_view/tag_engine.ex +++ b/lib/phoenix_live_view/tag_engine.ex @@ -337,8 +337,6 @@ defmodule Phoenix.LiveView.TagEngine do } end - defp collect_directives(state, []), do: state - defp collect_directives(state, tokens) do # Allow for leading whitespace stripped_tokens = Tokenizer.strip_text_token_fully(tokens) From fa63849740cc3e3fbd22c07712c729c6b9d6f219 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:41:10 -0500 Subject: [PATCH 10/13] Propagate updated attrs with :type removed for consistency --- lib/phoenix_live_view/tag_engine.ex | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/phoenix_live_view/tag_engine.ex b/lib/phoenix_live_view/tag_engine.ex index a3c6f2ff54..a7850d2c79 100644 --- a/lib/phoenix_live_view/tag_engine.ex +++ b/lib/phoenix_live_view/tag_engine.ex @@ -345,13 +345,16 @@ defmodule Phoenix.LiveView.TagEngine do [] -> state - [{:tag, _name, _attrs, tag_meta} = token | rest] -> + [{:tag, name, _attrs, tag_meta} = token | rest] -> case check_and_validate_macro_component(token, state) do - {:macro_component, module, _attrs} -> + {:macro_component, module, attrs} -> # Use build_ast to consume all of the tokens in this MacroComponent try do {ast, rest} = - case Phoenix.Component.MacroComponent.build_ast(stripped_tokens, state.caller) do + case Phoenix.Component.MacroComponent.build_ast( + [{:tag, name, attrs, tag_meta} | rest], + state.caller + ) do {:ok, ast, rest} -> {ast, rest} {:error, message, meta} -> raise_syntax_error!(message, meta, state) end From f40e6ae510b1d7aead59a6f9cfe46cbc12d439f9 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:42:55 -0500 Subject: [PATCH 11/13] Simplify logic in collect_directives/2 --- lib/phoenix_live_view/tag_engine.ex | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/phoenix_live_view/tag_engine.ex b/lib/phoenix_live_view/tag_engine.ex index a7850d2c79..8da0407965 100644 --- a/lib/phoenix_live_view/tag_engine.ex +++ b/lib/phoenix_live_view/tag_engine.ex @@ -361,19 +361,19 @@ defmodule Phoenix.LiveView.TagEngine do cond do state.collecting_directives? and function_exported?(module, :directives, 2) -> - state = - case module.directives(ast, %{env: state.caller}) do - {:ok, directives} when is_list(directives) -> + case module.directives(ast, %{env: state.caller}) do + {:ok, directives} when is_list(directives) -> + state = Enum.reduce(directives, state, fn directive, state -> apply_macro_component_directive!(directive, module, tag_meta, state) end) - other -> - raise ArgumentError, - "a macro component must return {:ok, directives}, got: #{inspect(other)}" - end + collect_directives(state, rest) - collect_directives(state, rest) + other -> + raise ArgumentError, + "a macro component must return {:ok, directives}, got: #{inspect(other)}" + end not state.collecting_directives? and function_exported?(module, :directives, 2) -> raise_syntax_error!( @@ -399,7 +399,6 @@ defmodule Phoenix.LiveView.TagEngine do end [_ | rest] -> - # We encountered something other than whitespace or a macrocomponent, so stop collecting. collect_directives(%{state | collecting_directives?: false}, rest) end end From 150036a9309f2d88ddc663bea1f1c91805cc55f5 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:45:01 -0500 Subject: [PATCH 12/13] Document that MacroComponents with directives must appear at the beginning of the template --- lib/phoenix_component/macro_component.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/phoenix_component/macro_component.ex b/lib/phoenix_component/macro_component.ex index 09dd5e85b1..d44bc23e0f 100644 --- a/lib/phoenix_component/macro_component.ex +++ b/lib/phoenix_component/macro_component.ex @@ -219,6 +219,7 @@ defmodule Phoenix.Component.MacroComponent do # # Macro components may return directives from the module's optional `c:directives/2` callback # which can be used to influence other elements in the template outside of the macro component at compile-time. + # Macro components which specify directives **must** be placed at the beginning of the template in which they are used. # For example: # # ```elixir From b7232af8b145edbebf59778d5e67dad0bead7a23 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:46:34 -0500 Subject: [PATCH 13/13] Fix incorrect parentheses for validation of root_tag_attribute directive value --- lib/phoenix_live_view/tag_engine.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view/tag_engine.ex b/lib/phoenix_live_view/tag_engine.ex index 8da0407965..7585105b4d 100644 --- a/lib/phoenix_live_view/tag_engine.ex +++ b/lib/phoenix_live_view/tag_engine.ex @@ -430,7 +430,7 @@ defmodule Phoenix.LiveView.TagEngine do end case attribute do - {name, value} when (is_binary(name) and is_binary(value)) or value == true -> + {name, value} when is_binary(name) and (is_binary(value) or value == true) -> %{state | root_tag_attributes: [{name, value} | state.root_tag_attributes]} attribute ->