Skip to content

Colocated Hooks #3705

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 16 commits into from
Closed

Colocated Hooks #3705

wants to merge 16 commits into from

Conversation

SteffenDE
Copy link
Collaborator

@SteffenDE SteffenDE commented Mar 5, 2025

Colocated Hooks

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 colocated hooks:

def phone_number_input(assigns) do
  ~H"""
  <input type="text" name="user[phone_number]" id="user-phone-number" phx-hook="PhoneNumber" />
  <script type="text/phx-hook" 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="text/phx-hook", it will extract the
hook code at compile time and write it into the assets/js/hooks/ directory of the current
project. LiveView also creates a manifest file assets/js/hooks/index.js that exports all
hooks. 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 hooks from "./hooks"

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

When rendering a component that includes a colocated hook, the <script> tag is omitted
from the rendered output. Furthermore, the name given to the hook is local to the component,
therefore it does not conflict with other hooks. This also means that in cases where a hook
is meant to be used in multiple components, the hook must be defined as a regular, non-colocated
hook instead.

Special types of colocated hooks

In some situations, the default behavior of colocated hooks may not be desirable. For example,
if you're building a component library that relies on third-party dependencies that you need
to import into your hook's JavaScript code, the user of your library would need to manually
install this dependency, as the code is extracted as is and included into the user's bundle.

For such cases, you rather want to bundle all hooks when publishing your library. You can do
this with colocated hooks, by setting the bundle="current_otp_app" attribute on your <script> tags:

def my_component(assigns) do
  ~H"""
  <div id="my-component" phx-hook="MyComponent">
    ...
  </div>
  <script type="text/phx-hook" name="MyComponent" bundle="current_otp_app">
    import dependency from "../vendor/dependency"

    export default {
      mounted() {
        ...
      }
    }
  </script>
  """
end

In current_otp_app bundle mode, the hook is only extracted when the configured
config :phoenix_live_view, :colocated_hooks_app matches the app that is currently
being compiled. This means that when you are compiling your library project directly,
the hooks are extracted as usual, but when users are compiling your library as a
dependency, the hooks are ignored as they should be imported from the library's bundle
instead.

The supported values for the bundle attribute are "current_otp_app" and "runtime".
While we've explained the "current_otp_app" mode above, which is mainly useful for
library authors, the "runtime" mode is only useful in very rare cases, which we'll
explain below.

In bundle="runtime" mode, the hook is not removed from the DOM when rendering the component.
Instead, the hook's code is executed directly in the browser with no bundler involved.
One example where this can be useful is when you are creating a custom page for a library
like Phoenix.LiveDashboard. The live dashboard already bundles its hooks, therefore there
is no way to add new hooks to the bundle when the live dashboard is used inside your application.
Because of this, bundle="runtime" hooks must use a slightly different syntax:

<script type="text/phx-hook" name="MyComponent" bundle="runtime">
  return {
    mounted() {
      ...
    }
  }
</script>

Instead of exporting a hook object, we are returning it instead. This is because the hook's
code is wrapped by LiveView into something like this:

window["phx_hook_HASH"] = function() {
  return {
    mounted() {
      ...
    }
  }
}

Still, even for runtime hooks, the hook's name is local to the component and won't conflict with
any existing hooks.

When using runtime hooks, it is important to think about any limitations that content security
policies may impose. Runtime hooks work fine with CSP nonces:

<script type="text/phx-hook" name="MyComponent" nonce={@script_csp_nonce}>

This is assuming that the @script_csp_nonce assign contains the nonce value that is also
sent in the Content-Security-Policy header.

@SteffenDE SteffenDE force-pushed the sd-colo-hooks branch 2 times, most recently from 613b7b6 to 7f97522 Compare March 5, 2025 17:36
@SteffenDE
Copy link
Collaborator Author

Still tbd: best syntax for bundle modes (bundle="current_otp_app" doesn't feel nice, maybe atoms instead?)
Also still missing: tests

@SteffenDE
Copy link
Collaborator Author

@josevalim you can leave some early feedback if you like

@SteffenDE
Copy link
Collaborator Author

TODO: consider special use Phoenix.Component, colocated_hooks_manifest: PATH option for umbrellas?

end

@doc false
def prune_hooks(file) do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this has the potential issue in that, if the file is removed altogether, its hooks will still remain.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. Do you have another idea?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would need a Mix compiler that checks if modules no longer exist and, if they don't, it cleans up any trailing file.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be something simple. For example, assets/hooks/MODULE_NAME, then we make sure for each MODULE_NAME in hooks there is a beam file.

do: {:bundle_current, name}

defp classify_hook(%{"type" => "text/phx-hook", "name" => name}), do: {:bundle, name}
defp classify_hook(%{"type" => "text/phx-hook"}), do: :invalid
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern here is that someone may write type={if foo, do: "text/phx-hook"} and then it won't work properly. I wonder if we should insert a special :bundle="..." attribute, which would give us more control (they are always handled specially). Perhaps it could also be something that we would use for collocated CSS.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. We could have a big warning in the docs though. I'm not sure about making it a special attribute (those are handled in the tag_engine) as it is quite HTML specific. That's why I currently have all the code in Phoenix.LiveView.HTMLEngine.

@josevalim
Copy link
Member

In general it looks like a great direction to me, my only concern is why this is related to hooks. For example, if we decide to add web components support, then would we need another mechanism?

I wonder if it would be more beneficial to generalize this instead. For example, could we make it so hooks are also based on imports? So for example, we could allow defining JS modules that get extracted out:

<script type="module" src="module.js"></script>

And then we could also allow hooks to be modules:

phx-hook="CurrentModule/module.js"

This way we get features like sharing, a more general hook system that does not depend on a single variable, etc.

PS: I have no idea I am talking about but that's the general approach I would aim for.

@SteffenDE
Copy link
Collaborator Author

SteffenDE commented Mar 9, 2025

Well we could maybe do a protocol based implementation:

<script :bundle={ColocatedHook.new("HookName")}>
  export default {
    mounted() {...}
  }
</script>
<div id="my-el" phx-hook="HookName" />
defprotocol Phoenix.LiveView.TagExtractor do
  def extract(data, attributes, textContent, meta)
end

defmodule Phoenix.LiveView.ColocatedHook do
  defstruct [:name]

  def new(name, opts \\ []) do
    %__MODULE__{name: name, opts: opts}
  end

  def empty_manifest do
    """
    let hooks = {}
    export default hooks
    """
  end

  def manifest_path(opts) do
    ...
  end
end

defimpl Phoenix.LiveView.TagExtractor, for: Phoenix.LiveView.ColocatedHook do
  def extract(%{name: name, opts: opts}, attributes, textContent, meta) do
    hashed_name = Phoenix.LiveView.TagExtractorUtils.hashed_name(meta)

    case opts[:type] do
      :runtime ->
        new_content = """
        window["phx_hook_#{hashed_name}"] = function() {
          #{textContent}
        }
        """

        {:keep, attributes, new_content}

      _ ->
        manifest_path = Phoenix.LiveView.ColocatedHook.manifest_path(opts)
        hook_path = Phoenix.LiveView.ColocatedHook.hook_path(hashed_name)
        relative_hook_path = ...
        File.write!(hook_path, textContent)
        Phoenix.LiveView.TagExtractorUtils.append_manifest!(
          manifest_path,
          ~s|\nimport hook_#{hashed_name} from "#{relative_hook_path}"; hooks["#{hashed_name}"] = hook_#{hashed_name};|
        )

        :drop
    end
  end
end

The only problem with this approach is that rewriting the phx-hook="HookName" attribute on another tag is not really possible, so colocated hooks would probably need "global names" and possibly clash with hooks from libraries. LiveView could include implementations for colocated hooks and generic javascript modules.

Anyone that wants something similar can then implement the protocol themselves:

<script :bundle={%MyApp.ColocatedCSS{}}>
  export default {
    mounted() {...}
  }
</script>
defmodule MyApp.ColocatedCSS do
  ...
end

defimpl Phoenix.LiveView.TagExtractor, for: MyApp.ColocatedCSS do
  ...
end

@josevalim
Copy link
Member

How worried are we about global names? We could place them under a Phoenix or LiveView namespace or similar and then we recommend them to be namespaced by library or module name, and we should be fine, in the same way modules are global and we are fine?

@stevejonesbenson
Copy link

Is there anything I can do to help with this? Excited for this feature.

@SteffenDE
Copy link
Collaborator Author

SteffenDE commented Mar 24, 2025

@stevejonesbenson it just needs a bit more thinking time. I'll let you know when it's ready for some real world testing! :)

@SteffenDE SteffenDE mentioned this pull request Mar 24, 2025
2 tasks
@TomGrozev
Copy link

I think this is a really good idea and wanted to add one additional reason this will be a good addition, installation and maintainability of libraries.

When creating a library that requires some JS hooks it requires the additional step of adding the hooks to the app.js file. For example, I'm working on a admin panel project and have been trying to avoid using hooks for that very reason. If hooks are colocated with the components it would make these libraries easier to install and maintain.

@srcrip
Copy link
Contributor

srcrip commented Mar 28, 2025

@SteffenDE Just leaving a high level thought that it may be nice if the 'entry point' into this feature was itself a functional component like:

<.live_script hook="PhoneNumber">
  // here be javascript
</.live_script>

rather then <script type="text/phx-hook" name="PhoneNumber">. I think it feels more phoenix-y and is probably more discoverable and "less magic" in the sense that someone could go look up the documentation of a function like this rather than expect a script tag with very specific special attributes. You know what I mean?

@SteffenDE
Copy link
Collaborator Author

@srcrip note that the new proposal looks like this:

<script :extract={ColocatedHook.new("PhoneNumber")}>
  // javascript content
</script>

and it's not limited to scripts, but can also be used for colocated CSS, see #3725

@srcrip
Copy link
Contributor

srcrip commented Mar 28, 2025

I still think a functional component as sugar would be valuable just for providing a thing people can easily lookup, and I didn't read your parsing implementation super closely but I'd have to imagine it would simplify at least some parsing logic I don't know.

I don't know if you'd prefer this discussion in the other issue but I think colocated CSS is a bit of a rabbit hole personally. A lot of other frameworks have seen problems with how to tackle it well with something like tailwind, without requiring tailwind to run multiple times on multiple files. And how Phoenix provide colocated CSS in a way thats agnostic to whatever bundling setup the user is currently using?

@josevalim
Copy link
Member

One option to flip this around would be this:

<.live_script :bundle>
</.live_script>

And that would invoke the thing at compile-time. This way we move away from the protocol into compile-time dispatching. The tricky is figuring out a good error message if someone pass :bundle to something that is not capable of receiving a bundle. Perhaps having different arities for bundles would suffice (perhaps they receive the contents and __ENV__).

@srcrip
Copy link
Contributor

srcrip commented Mar 28, 2025

Also however we end up doing this we need to think through the fact that people are going to want “preprocessors” sooner or later. CSS is its own minefield but I’d definitely want to be able to write typescript in these spots.

@SteffenDE
Copy link
Collaborator Author

SteffenDE commented Mar 29, 2025

@josevalim I'll experiment with it.

@srcrip note that the current approach in #3725 gives you free reign to do whatever you want. So as long as you configure your bundler to handle typescript, everything already works. We might need to add small convenience things like allowing to define the file extension of the extracted file (and e.g. automatically using .ts for <script :extract={...} type="text/x.typescript">), but the process itself does not care at all about the content of whatever it is extracting.

I don't know if you'd prefer this discussion in the other issue but I think colocated CSS is a bit of a rabbit hole personally.

That's why we (or let's say I) don't plan to add any support for colocated CSS to Phoenix by default. The extraction / bundle feature should give you / library authors the tools they need to implement something like colocated CSS (with optional scoping, autoprefixing, ... support).

@SteffenDE
Copy link
Collaborator Author

I still think a functional component as sugar would be valuable just for providing a thing people can easily lookup

Oh and I just wanted to say that in my opinion, there's not really a documentation concern with the current :extract approach either, since seeing <script :extract={ColocatedHook.new("PhoneNumber")}> would already lead people to look at the docs for ColocatedHook :)

@srcrip
Copy link
Contributor

srcrip commented Mar 29, 2025

That’s true, that is pretty discoverable. One other question, by default with something like JS is all the JS going to end up in one file to be consumed by the bundler? Or a bunch of files?

@SteffenDE
Copy link
Collaborator Author

At the moment each hook gets its own file + one entry in a manifest file that imports one hook each line. That’s to make things easier when a hook is removed. We can just delete the file and remove one line.

@rhcarvalho
Copy link
Contributor

Because of this, bundle="runtime" hooks must use a slightly different syntax:

<script type="text/phx-hook" name="MyComponent" bundle="runtime">
  return {
    mounted() {
      ...
    }
  }
</script>

I'm afraid this might show spurious syntax error on IDEs interpreting the contents as JavaScript? (Top-level return)

Suggestions:

  • Remove the return, so we get to write less; or
  • Require the full function definition making it valid JS

@SteffenDE
Copy link
Collaborator Author

We're going to rework this.

@SteffenDE SteffenDE closed this May 15, 2025
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.

7 participants