Skip to content

Add macro components and colocated hooks / js#3810

Merged
SteffenDE merged 79 commits intomainfrom
sd-macro-component
May 30, 2025
Merged

Add macro components and colocated hooks / js#3810
SteffenDE merged 79 commits intomainfrom
sd-macro-component

Conversation

@SteffenDE
Copy link
Collaborator

This PR introduces "macro components". Below is an excerpt from the (wip) documentation:

Phoenix.Component.MacroComponent

A macro component is a special type of component that can modify its content at compile time.

Instead of introducing a special tag syntax like <#macro-component>, LiveView decided to "hide" macro components behind a special :type attribute. The reason for this is that most of the time, the job of the macro component is to take its content and extract it so somewhere else, for example to a file in the local file system. A good example for this is Phoenix.LiveView.ColocatedHook and Phoenix.LiveView.ColocatedJS.

AST

Macro components work by defining a callback module that implements the Phoenix.LiveView.MacroComponent behaviour. The module's c:transform/2 callback is called for each macro component used while LiveView compiles a HEEx component:

<div id="hey" phx-hook=".foo">
  <!-- content -->
</div>

<script :type={ColocatedHook} name=".foo">
  export default {
    mounted() {
      this.el.firstElementChild.textContent = "Hello from JS!"
    }
  }
</script>

In this example, the ColocatedHook's c:transform/2 callback will be invoked with the AST of the <script> tag:

{"script",
  [{":type", {:__aliases__, [line: 1], [:ColocatedHook]}}, {"name", ".foo"}],
  [
    "\n  export default {\n    mounted() {\n      this.el.firstElementChild.textContent = \"Hello from JS!\"\n    }\n  }\n"
  ]}

The Phoenix.Component.MacroComponent.AST module provides some utilities to work with the AST, but in general it is quite simple:

  1. A HTML tag is represented as {tag, attributes, children}
  2. Text is represented as a plain binary
  3. Attributes are represented as a list of {key, value} tuples where the value is an Elixir AST (which can be a plain binary for simple attributes)

Limitations {: .warning}

The AST is not whitespace preserving, so in cases where you return a modified AST, the original whitespace between attributes is lost.

Also, macro components can currently only contain simple HTML. Any interpolation like <%= @foo %> or components inside are not supported.

Example: a compile-time markdown renderer

Let's say we want to create a macro component that renders markdown as HTML at
compile time. First, we need some library that actually converts the markdown to
HTML. For this example, we use earmark.

We start by defining the module for the macro component:

defmodule MyAppWeb.MarkdownComponent do
  @behaviour Phoenix.Component.MacroComponent

  @impl true
  def transform({"pre", attrs, children}, _meta) do
    markdown = Phoenix.Component.MacroComponent.AST.to_string(children)
    {:ok, html_doc, _} = Earmark.as_html(markdown)

    {"div", attrs, [html_doc]}
  end
end

That's it. Since the div could contain nested elements, for example when using an HTML code block, we need to convert the children to a string first, using the Phoenix.Component.MacroComponent.AST.to_string/1 function.

Then, we can simply replace the element's contents with the returned HTML string from Earmark.

We can now use the macro component inside our HEEx templates:

defmodule MyAppWeb.ExampleLiveView do
  use MyAppWeb, :live_view

  def render(assigns) do
    ~H"""
    <pre :type={ColocatedDemoWeb.Markdown} class="prose mt-8">
    ## Hello World

    This is some markdown!

    ```elixir
    defmodule Hello do
      def world do
        IO.puts "Hello, world!"
      end
    end
    ```
    </pre>
    """
  end
end

Note: this example uses the prose class from TailwindCSS for styling.

One trick to prevent issues with extra whitespace is that we use a <pre> tag in the LiveView template, which prevents the Phoenix.LiveView.HTMLFormatter from indenting the contents, which would mess with the markdown parsing. When rendering, we replace it with a <div> tag in the macro component.

Colocated Hooks / Colocated JavaScript

When writing components that require some more control over the DOM, it often feels inconvenient to have to write a hook in a separate file. Instead, one wants to have the hook logic right next to the component code. For such cases, HEEx supports macro components, and LiveView comes with two implementations of macro components by default: Phoenix.LiveView.ColocatedHook and Phoenix.LiveView.ColocatedJS.

Let's see an example:

def phone_number_input(assigns) do
  ~H"""
  <input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
  <script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
    export default {
      mounted() {
        this.el.addEventListener("input", e => {
          let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
          if(match) {
            this.el.value = `${match[1]}-${match[2]}-${match[3]}`
          }
        })
      }
    }
  </script>
  """
end

When LiveView finds a <script> element with :type={ColocatedHook}, it will extract the hook code at compile time and write it into a special folder inside the _build/ directory. To use the hooks, all that needs to be done is to import the manifest into your JS bundle, which is automatically done in the app.js file generated by mix phx.new:

...
  import {Socket} from "phoenix"
  import {LiveSocket} from "phoenix_live_view"
  import topbar from "../vendor/topbar"
+ import colocated from "phoenix-colocated/my_app"

  let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
  let liveSocket = new LiveSocket("/live", Socket, {
    longPollFallbackMs: 2500,
    params: {_csrf_token: csrfToken},
+   hooks: {...colocated.hooks}
 })

The "phoenix-colocated" package is a JavaScript package inside the Mix.Project.build_path(), which is included by default in the esbuild configuration of new Phoenix projects:

TODO: fix path separator for windows!!!

config :esbuild,
  ...
  my_app: [
    args:
      ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
    cd: Path.expand("../assets", __DIR__),
    env: %{
      "NODE_PATH" => Enum.join([Path.expand("../deps", __DIR__), Mix.Project.build_path()], ":")
    }
  ]

When rendering a component that includes a colocated hook, the <script> tag is omitted from the rendered output. Furthermore, to prevent conflicts with other components, colocated hooks require you to use the special dot syntax when naming the hook, as well as in the phx-hook attribute. LiveView will prefix the hook name by the current module name at compile time. This also means that in cases where a hook is meant to be used in multiple components across a project, the hook must be defined as a regular, non-colocated hook instead.

You can read more about colocated hooks in the module documentation for ColocatedHook. LiveView also supports colocating other JavaScript code, for more information, see Phoenix.LiveView.ColocatedJS.

@SteffenDE SteffenDE merged commit 51cd063 into main May 30, 2025
16 checks passed
@SteffenDE SteffenDE deleted the sd-macro-component branch May 30, 2025 09:49
@kamaroly
Copy link

kamaroly commented Jun 3, 2025

This is great!!💯 thanks for bringing this to life.

What's the rationale behind prefixing the hook name with a dot(.)?

@SteffenDE
Copy link
Collaborator Author

@kamaroly it is to prevent conflicts with hook names, since it is likely that people won’t think a lot about naming their hooks when they are only used inside the same component. And since colocated hooks can also be used in libraries, it is quite likely that a some library names a hook very generically, and when you also do that things would break.

@kamaroly
Copy link

kamaroly commented Jun 3, 2025

Thank you for the clarification.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants