Build time Hooks and Modules
Summary
Dioxus needs an easy and powerful way to extend the framework. As a solution, I pose modules inspired by the plugin systems of Nuxt.js. Rocket and Bevy.
Motivation
Dioxus is a great framework, but it is lacking in the areas of extensibility and customization. I have been using Dioxus for a little while now, and I am very happy with it (bundle sizes aside), but I have noticed that it is missing a few things that I would like to have. One of the things I would like to have is the ability to extend the framework with modules. This would allow me to add functionality to the framework without having to fork it. For example, I would like to be able to add a module that adds tailwindcss support to the framework. This module system should allow modules to hook into lifecycle events of the framework and add functionality to it.
Detailed Design
I have been thinking about this for a while, and I have come up with a few ideas. I am going to go through them in order of how I think they should work.
Hooks
Hooks are the core of the module system, they allow modules to hook into the lifecycle of the framework. Hooks are stored on the LaunchBuilder struct, and are registered with the HookRegistry struct (idk if this is the best way to do this, but it works).
struct LaunchBuilder {
pub hooks: HookRegistry,
// ... other fields
}
impl LaunchBuilder {
fn new() -> Self {
Self {
hooks: HookRegistry::new(),
// ... other fields
}
}
fn attach(mut self, mut module: Box<dyn DioxusModule>) -> Self {
module.attach(&mut self);
self.hooks.push(module);
self
}
fn launch<T: 'static>(self, app: T) {
// call app:created hook for example
self.hooks.on_create(&mut self);
// ... call launch_fn probably, which should be passed the hook registry now I think
}
}
The HookRegistry struct is a simple struct that stores a Vec of hooks. It uses a macro to generate a series of functions that can be called on the HookRegistry struct. This allows for strongly typed hooks that are easy to use. Unfortunately with this approach, modules cannot create their own hooks.
macro_rules! define_hook_methods {
($($name:ident($($arg_name:ident: $arg_ty:ty),*)),* $(,)?) => {
$(
pub fn $name(&mut self, $($arg_name: $arg_ty),*) {
for hook in &mut self.hooks {
hook.$name($($arg_name),*);
}
}
)*
};
}
pub struct HookRegistry {
hooks: Vec<Box<dyn DioxusModule>>,
}
impl HookRegistry {
fn new() -> Self {
Self { hooks: Vec::new() }
}
fn push(&mut self, hook: Box<dyn DioxusModule>) {
self.hooks.push(hook);
}
define_hook_methods!(
on_create(app: &mut LaunchBuilder),
before_build(),
after_build(),
on_render_html(app: &mut LaunchBuilder)
);
}
You could also do something like this to allow for more flexibility in the hooks but this compromises strongly typed hooks
type HookCallback = Box<dyn Fn(&dyn Any) + Send + Sync>;
struct HookRegistry {
hooks: HashMap<String, Vec<HookCallback>>,
}
impl HookRegistry {
fn new() -> Self {
Self {
hooks: HashMap::new(),
}
}
fn register<T: 'static + Send + Sync>(
&mut self,
name: &str,
callback: impl Fn(&T) + Send + Sync + 'static,
) {
self.hooks
.entry(name.to_string())
.or_default()
.push(Box::new(move |data: &dyn Any| {
if let Some(typed_data) = data.downcast_ref::<T>() {
callback(typed_data);
}
}));
}
fn trigger<T: 'static>(&self, name: &str, data: T) {
if let Some(hooks) = self.hooks.get(name) {
for hook in hooks {
hook(&data);
}
}
}
// Convenience method for triggering hooks with no arguments
fn trigger_empty(&self, name: &str) {
self.trigger(name, ());
}
}
Module System
The module system should be able to add modules before Dioxus is 'launched'. Modules should utilize hooks to extend the functionality of Dioxus. Here's what I was thinking:
trait DioxusModule {
fn attach(&mut self, app: &mut LaunchBuilder);
/// # runtime hooks
fn on_create(&mut self, _app: &mut LaunchBuilder) {}
// ... more hooks
/// # build hooks
fn before_build(&mut self) {}
fn after_build(&mut self) {}
// ... more hooks
/// # server hooks
fn on_render_html(&mut self, _app: &mut LaunchBuilder) {}
// ... more hooks
}
struct TailwindCssModules;
impl DioxusModule for TailwindCssModules {
fn attach(&mut self, app: &mut LaunchBuilder) {
// read config and store it I think
}
fn before_build(&mut self) {
Command::new("tailwindcss")
.arg("--input")
// TODO: make this configurable
.arg("styles/tailwind.css")
.arg("--output")
// TODO: make this configurable
.arg("dist/tailwind.css")
.spawn()
.expect("Failed to run tailwindcss");
}
}
An example of loading a module would be:
extern crate dioxus_tailwindcss;
use dioxus_tailwindcss::TailwindCssModules;
fn main() {
LaunchBuilder::new()
// TODO: idk if I like wrapping this in a Box
.attach(Box::new(TailwindCssModules))
.launch(App);
// As opposed to `dioxus::launch(App)`
}
Alternatives
As far as I see it, there arent great alternatives for extending Dioxus aside from a plugin system or something very similar. But a plugin system is no simple feat, and I am not sure how well it would work with Dioxus.
Unresolved Questions
- How would modules be able to access the Dioxus.toml file?
References
Implementations and ideas used to design this that might be useful:
- https://rocket.rs/guide/v0.5/fairings/#fairings
- https://nuxt.com/docs/api/advanced/hooks
- https://bevyengine.org/learn/quick-start/getting-started/plugins/
Conclusion
There is likely more to be added to the module system, but I am not sure what else I would need to add. Or how I might make it a better system to use. I would really love to hear your thoughts on this.
Thanks so much, Zoe
Dioxus has runtime hooks that let you run code at specific points in the rendering lifecycle like use_effect which runs after the render. This issue sounds like it is mostly about hooking into the CLI's build lifecycle.
I agree we should make the build system more flexible, but I don't think configuring that in the launch builder will work:
- You cannot expose hooks that run before the cargo build in your main rust code which runs during the cargo build
- For web apps, the main bundle targets wasm32-unknown-unknown which does not have access to the file system and cannot compile some native libraries for things like file optimization
Alternative solutions could be:
- Exposing more modular
dxcommands that make it easier to create a build script that used dioxus. For example, we could expose adx extract-assets --path-to-exe ...command that just finds the assets that were linked with manganis in a binary - Wasm CLI plugins #1673
Hm, I see. Dioxus hooks seems to follow roughly what solid does with it's reactive primitives, effects work the same way, memos work the same, and the basic reactive signal is the same too. Most of the JS ecosystem seems to be pretty lax of what a "hook" is, but in the Vue world, where I come from, hooks are directives that run when a specific lifecycle point has been reached, ie onMount, beforeMount, beforeUnmount, etc. and everything else, mainly reactive primitives, think effects, memos, etc. use their own names mainly, "watchers" for watch (basically an effect), "computed state" for what is a memo. While Dioxus may have these stateful methods, they aren't the same as hooks, and even though you could likely achieve similar results with some of them (like gating a signal by if a value == 0 to create a "mounted" hook that can consume reactive data, but never reruns), we still cant hook into other important lifecycle methods, like beforeMount.
Build hooks are the main feature I want implemented, but I would love to see lifecycle hooks of all type accessible within Dioxus. I do apologize for assuming that I could add hooks/plugins via the launcher, I just figured since the CLI started a web server before the project was actually run that it did something special. Sorry for my late response
Hm, I see. Dioxus hooks seems to follow roughly what solid does with it's reactive primitives, effects work the same way, memos work the same, and the basic reactive signal is the same too. Most of the JS ecosystem seems to be pretty lax of what a "hook" is, but in the Vue world, where I come from, hooks are directives that run when a specific lifecycle point has been reached, ie onMount, beforeMount, beforeUnmount, etc. and everything else, mainly reactive primitives, think effects, memos, etc. use their own names mainly, "watchers" for watch (basically an effect), "computed state" for what is a memo. While Dioxus may have these stateful methods, they aren't the same as hooks, and even though you could likely achieve similar results with some of them (like gating a signal by if a value == 0 to create a "mounted" hook that can consume reactive data, but never reruns), we still cant hook into other important lifecycle methods, like beforeMount.
Dioxus implements all of those lifecycle events:
- beforeMount is
use_hook - onmount is an event on a specific element or
use_effect - beforeUnmount is
use_drop
There is documentation about hooking into the component lifecycle here
oh, I see, idk why but I thought the docs.rs page for use_drop said it ran after unmount, but I am probably just crazy, and I wasnt aware that use_hook could be used like that since the docs.rs page doesnt make it super obvious, I did miss the lifecycle page however. I do sincerely apologize for- largely wasting your time 😓 I do look forward, and hope to at least contribute somewhat, to CLI build hooks for Dioxus. Thanks for your help and patience with my inability to be literate and find things.