Skip to content
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

Colocated Hooks #3705

Draft
wants to merge 16 commits into
base: main
Choose a base branch
from
12 changes: 12 additions & 0 deletions assets/js/phoenix_live_view/dom_patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,18 @@ export default class DOMPatch {
if((DOM.isPhxChild(el) && view.ownsElement(el)) || DOM.isPhxSticky(el) && view.ownsElement(el.parentNode)){
this.trackAfter("phxChildAdded", el)
}

// data-phx-runtime-hook
// usually, scripts are not executed when morphdom adds them to the DOM
// we special case runtime colocated hooks
if(el.nodeName === "SCRIPT" && el.hasAttribute("data-phx-runtime-hook")){
const script = document.createElement("script")
script.textContent = el.textContent
DOM.mergeAttrs(script, el, {isIgnored: false})
el.replaceWith(script)
el = script
}

added.push(el)
},
onNodeDiscarded: (el) => this.onNodeDiscarded(el),
Expand Down
20 changes: 19 additions & 1 deletion assets/js/phoenix_live_view/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -739,7 +739,25 @@ export default class View {
this.viewHooks[ViewHook.elementID(hook.el)] = hook
return hook
} else if(hookName !== null){
logError(`unknown hook found for "${hookName}"`, el)
// TODO: probably refactor this whole function
const runtimeHook = document.querySelector(`script[data-phx-runtime-hook="${CSS.escape(hookName)}"][bundle="runtime"]`)
if(runtimeHook){
// if you really want runtime hooks, I
callbacks = window[`phx_hook_${hookName}`]
if(callbacks && typeof callbacks === "function"){
callbacks = callbacks()
if(callbacks && typeof callbacks === "object"){
if(!el.id){ logError(`no DOM ID for hook "${hookName}". Hooks require a unique ID on each element.`, el) }
let hook = new ViewHook(this, el, callbacks)
this.viewHooks[ViewHook.elementID(hook.el)] = hook
return hook
} else {
logError("runtime hook must return an object with hook callbacks", runtimeHook)
}
}
} else {
logError(`unknown hook found for "${hookName}"`, el)
}
}
}
}
Expand Down
137 changes: 137 additions & 0 deletions guides/client/js-interop.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,143 @@ let liveSocket = new LiveSocket("/live", Socket, {

In the example above, all attributes starting with `data-js-` won't be replaced when the DOM is patched by LiveView.

### 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:

```elixir
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`:

```diff
...
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:

```elixir
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:

```heex
<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:

```javascript
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:

```heex
<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.

### Client-server communication

A hook can push events to the LiveView by using the `pushEvent` function and receive a
Expand Down
1 change: 1 addition & 0 deletions lib/phoenix_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1787,6 +1787,7 @@ defmodule Phoenix.Component do

imports =
quote bind_quoted: [opts: opts] do
Phoenix.LiveView.HTMLEngine.prune_hooks(__ENV__.file)
import Kernel, except: [def: 2, defp: 2]
import Phoenix.Component
import Phoenix.Component.Declarative
Expand Down
Loading
Loading