I’m having difficulty understanding where JS can belong in a liveview app. I understand that JS hooks belong in app.js, but there is only one app.js which means every hook you write, will be bundled and sent to the client. For universal hooks that would be ok, but for component specific js, if a client never navigates to a view which uses it, it would lead to unused JS being sent to the client. It would be lovely to be able to write JS in the heex, and it appears to be possible, but is it safe to write component specific JS within tags in heex?
You certainly could create components that have in JS them, but it does come with a can of worms:
- Need to remember to load script components when needed by some component on a page
- If JS is put in the component itself, it will be duplicated
- A lot of JS needs to be a hook, and as far as I know, there’s not a way to load hooks dynamically
I think it’s important to note that, aside from hooks, there’s no component-level scope for JS in LiveView, so whatever scripts you are doing inside a heex
template is going to need to be assigned to some globally accessible variables.
What I have done for really big JS deps (like GraphiQL) is to create a separate bundle and create a specific root layout for the pages that needed it.
I have also done a few “page-level” components for things like dark mode JS and some other little things like that, and those are loaded only once in the applicable root layouts.
I forget to say this, but Chris mentioned at the end of the 1.0 blog that collocated JS hooks are an upcoming planned feature for LiveView. So, this could soon be a resolved issue.
Very nice!
I always advised people to avoid using hooks unnecessarily because of this global nature. This might be the feature that will open the gates for UI component libraries wide open.
I don’t think colocation will change anything about that. When LV encounters a phx-hook
attribute it needs to know what to do with it. That’s a completely separate concern to how you organize source files.
For particularly heavy dependencies I’d probably do a hook, which pulls in the dependency as an async import at the moment. That way the hook definition still works fine and can stay slim and you can actually model the fact that the dependency has some loading associated.
Since this is a internal implementation detail, it might as well be possible that the core team will find a better way to achieve the same result.
I am more interested in hooks as interaction with UI parts that cannot work without JS, for example rendering a map with maplibre.js
, without the JS initialization part this is not possible.
The most I seem them doing is hoist the “async portion” to the hook level instead of it being part of a hooks implementation. But once they encounter a phx-hook they need to do something and I don’t see much alternative to either “start loading a hook” or “start using a hook”. Eventually that attribute needs to result in code being available to execute. In either way somewhere there needs to be the knowledge of which hooks exist and if they’re to be loaded where to do so. JS already has means for doing that.
I’m not sure I understand what you mean. You want to use maplibre, which seems to be a given. So you have three options.
- Bundle maplibre with all your other code.
single, but large, app.js - Do not bundle maplibre, but have static knowledge about which page to include the js in.
additional dynamic js linked in head
caches independently to app.js
requires full page – including root layout – navigation for LV though - Load the js dynamically when encountering the need for it to be included at runtime.
async import of additional js file
Thanks everyone for chiming in : )
I have some questions if you don’t mind.
- @LostKobrakai your point regarding:
Blockquote 3. Load the js dynamically when encountering the need for it to be included at runtime.
async import of additional js file.
Would you mind giving an example of how you’d implement this?
- @frankdugan3 Is it possible to load a different ‘app.js’ from the root depending on the session? I.e user and user type?
For now you’d have something like this
{
mounted() {
this.loading = true;
import("heavy_dep")
.then(dep => {
this.loading = false;
# start using dep
# assign stuff to this for use in other hook callbacks
# …
})
},
…
}
Depending on the bundler used this should generate additional js files, which are only loaded once that import function is called.
Proabably, but at the very least you’d need to ensure whenever the session changed, you did a full page refresh. TBH, I think @LostKobrakai has the proper suggestion. Dynamically loading the heavy deps of hooks is the most straightforward way to go, instead of splitting up the hooks into top-level bundles.
You could even do some preloading of those deps so there would be no loading latency when the hook is mounted, but the page would still load without waiting for the heavy modules:
<script rel="modulepreload" type="module" src="dynamic-module.js"></script>
I’m using “dynamic hooks” in an app where liveviews are not part of the main build but loaded at runtime and where I would not want those scripts to be part of the main JS bundle.
def hooks() do
%{
act_on_coordinates: ~JS"""
h.el.addEventListener("mouseenter", (e) => {
// handle your event
});
"""
}
end
...
~H"""
<.register_hook :for={{key, content} <- @hooks} bind="h" name={key} script={content} />
"""
The JS sigil has formatting-on-save built in, I’m working to add syntax coloring. But I allowed myself to do this in my project because I’ve read that co-located hooks will drop at some point. So when it’s the case, I’ll remove this and migrate to the new standard way.
Note that I’m not importing dependencies in those dynamic hooks, they are mostly vanilla JS 1 to 10-liners.
Phoenix and Liveview are quite permissive and provide a lot of foundations to build on - use this to your advantage but know the pitfalls if you deviate too much.
I’m very interested in all of that, but particularly what happens inside the register_hook
component. Care to share?
I’m a bit hesitant because it’s going quite against the flow… but hey, why not.
Here’s a simplified version :
JS side, there is a function allowing to register a dynamic hook, and a record holding them.
Sorry if there are syntax errors, I do not have the project on hand.
declare global {
interface Window {
__liveutils: {
registerHook: (name: string, cb: () => void) => void,
dynamicHooks: Record<string, Hook>
}
}
}
The register-hook component calls this registerHook function by just rendering the script. Note that inline scripts need special care to work with CSP headers : CSP : script-src - HTTP | MDN
<script phx-update="ignore" id={@name}>
__liveutils.registerHook('{ @name }', (<%= @bind %>) => <%="{"%>
<%= Phoenix.HTML.raw(assigns[:script] || "") %>
<%="}"%>);
</script>
The bundled app.js needs to have a hook dedicated to handle those runtime-registered hooks :
let liveSocket = new LiveSocket("/live", Socket, {
params: ...,
hooks: {
dynamic_hook: {
mounted() {
const hook = this.el.getAttribute("data-hook");
if (window.__liveutils.dynamicHooks[hook]) {
window.__liveutils.dynamicHooks[hook].mounted(this, this.el);
}
},
... rest of lifecycle
}
}
};
And a basic registerHook function would be as simple as this :
registerHook = function (key, fn) {
window.__liveuitls.dynamicHooks[key] = {mounted: fn};
};
Then, the LiveSocket object is only aware of a dynamic_hook
hook that could be used like that :
<div phx-hook="dynamic_hook" data-hook="act_on_coordinates" data-foo="bar">...</div>
So, there’s somewhere a map of callbacks, a function to register them, and they are looked up when an element with phx-hook=“dynamic_hook” is rendered.
But I’m not advocating for that too much. My use case is very small, one-purpose independent liveviews that might or might not be loaded into a main app. The app itself has a “real” frontend with a build system for libraries and compile-time liveviews…
I just read your recent article, loved it! Did you ever implement syntax highlighting for sigils? I’m doing some experiments with component tooling around CSS and JS sigils, syntax highlighting would be icing on the cake.
Syntax highlighting is an editor feature. See GitHub - phoenixframework/vscode-phoenix: Syntax highlighting support for Phoenix templates in Visual Studio Code. / GitHub - phoenixframework/tree-sitter-heex: HEEx grammer for Tree-sitter
Of course, but it was mentioned in the post it was being worked on. I have looked into adding generic support for various languages in treesitter, which is non-trivial (for me) to jump in and implement, especially since I want highlighting for the embedded language and EEx templating. So, I was hoping for an example to draw from for JS in particular.
Hello Frank,
I did not yet get to it but it’s on my very near todolist because It looks like I’m not done with code-in-code approaches.
I’ll post an update but since the way is to scaffold an editor plugin, I’ll only be able to provide samples for Code and Zed.
Whew ! That was quite a rabbit hole .
I need to clean up the VSCode extension project and I’ll upload it to github.
I used an LLM and VSCode docs and bits of syntax highlighter plugins to guide me.
Highlighting JS is a bit different (in an easier way) than the official HEEX support because HEEX isn’t quite HTML. In short, you “just” have to tell tree-sitter to consider the inside of ~SIGIL"“” / “”" as another language that is already supported.
So highlighting CSS (or even Zigler NIFs!) shouldn’t be too hard to add to that kind of plugin.
I’ll provide the vscode/zed extensions code but will not package them, because I’m not in a position to take maintenance of a “public” plug-in today.
Oh wow, this was waaaay easier than I thought with Neovim!
; ~/.config/nvim/queries/elixir/injections.scm
;; extends
(sigil
(sigil_name) @_sigil_name
(quoted_content) @injection.content
(#any-of? @_sigil_name "JS")
(#set! injection.language "javascript"))
Thanks for pointing me in the right direction.
If someone has a similar need someday, here’s the VSCode extension POC repository :
Zed looks a lot leaner on ceremony but Neovim seems to take the crown here !
I’ve also linked to this discussion in my blog post. Thanks for giving me the necessary push !