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"""
+
+ + Inner Block + +
+ <:named_slot> ++ + Named Slot + +
+ + ++ + Inner Block + +
+Simple
+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("+ True +
++ False +
++ True +
++ False +
++ True +
+ + <% else %> + <.inner_block_and_slot> ++ False +
+ + <% end %> ++ True +
+ + <% else %> + <.inner_block_and_slot> ++ False +
+ + <% end %> +- - Inner Block - -
- <:named_slot> -- - Named Slot - -
- - -- - Inner Block - -
-- - Named Slot - -
-+ # + # Inner Block + # + #
+ # <:named_slot> + #+ # + # Named Slot + # + #
+ # + # + #+ # + # Inner Block + # + #
+ #+ # + # Named Slot + # + #
+ #+
True
+
False