-
Notifications
You must be signed in to change notification settings - Fork 986
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
Colocated Hooks #3705
Conversation
613b7b6
to
7f97522
Compare
Claude could have probably written it better than me.
Still tbd: best syntax for bundle modes ( |
@josevalim you can leave some early feedback if you like |
TODO: consider special |
end | ||
|
||
@doc false | ||
def prune_hooks(file) do |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
.
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:
And then we could also allow hooks to be modules:
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. |
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 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 |
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? |
Is there anything I can do to help with this? Excited for this feature. |
@stevejonesbenson it just needs a bit more thinking time. I'll let you know when it's ready for some real world testing! :) |
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 |
@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 |
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? |
One option to flip this around would be this:
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 |
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. |
@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
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). |
Oh and I just wanted to say that in my opinion, there's not really a documentation concern with the current |
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? |
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. |
I'm afraid this might show spurious syntax error on IDEs interpreting the contents as JavaScript? (Top-level Suggestions:
|
We're going to rework this. |
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:
When LiveView finds a
<script>
element withtype="text/phx-hook"
, it will extract thehook code at compile time and write it into the
assets/js/hooks/
directory of the currentproject. LiveView also creates a manifest file
assets/js/hooks/index.js
that exports allhooks. 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 bymix phx.new
:When rendering a component that includes a colocated hook, the
<script>
tag is omittedfrom 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 manuallyinstall 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:In
current_otp_app
bundle mode, the hook is only extracted when the configuredconfig :phoenix_live_view, :colocated_hooks_app
matches the app that is currentlybeing 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 forlibrary authors, the
"runtime"
mode is only useful in very rare cases, which we'llexplain 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 thereis 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: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:
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:
This is assuming that the
@script_csp_nonce
assign contains the nonce value that is alsosent in the
Content-Security-Policy
header.