12 KiB
Dioxus Core Architecture:
Main topics covered here:
- Fiber, Concurrency, and Cooperative Scheduling
- Suspense
- Signals
- Patches
- Diffing
- Const/Static structures
- Components/Scope
- Hooks
- VNode Bump Arenas
Components/Scope
All components in Dioxus are backed by something called the Scope
. As a user, you will never directly interact with the Scope
as most calls are shuttled through Context
. Scopes manage all the internal state for components including hooks, bump arenas, and (unsafe!) lifetime management.
Whenever a new component is created from within a user's component, a new "caller" closure is created that captures the component's properties. In contrast to Yew, we allow components to borrow from their parents, provided their memo
(essentially canComponentUpdate) method returns false for non-static items. The Props
macro figures this out automatically, and implements the Properties
trait with the correct canComponentUpdate
flag. Implementing this method manually is unsafe! With the Props
macro you can manually disable memoization, but cannot manually enable memoization for non 'static properties structs without invoking unsafe. For 99% of cases, this is fine.
During diffing, the "caller" closure is updated if the props are not `static. This is very important! If the props change, the old version will be referencing stale data that exists in an "old" bump frame. However, if we cycled the bump frames twice without updating the closure, then the props will point to invalid data and cause memory safety issues. Therefore, it's an invariant to ensure that non-'static Props are always updated during diffing.
Hooks
Hooks are a form of state that's slightly more finicky than structs but more extensible overall. Hooks cannot be used in conditionals, but are portable enough to run on most targets.
The Dioxus hook model uses a Bump arena where user's data lives.
Initializing hooks:
- The component is created
- The virtualdom heuristics engine pre-allocates a calculated set of memory for the bump arena
- The component is called through "run_scope"
- Each call to use_hook allocates new data for the hook, stores the raw pointer, and pushes the hook index forward
- Each call to use_hook also stores a function pointer fn() that will be used to clean up the hook
- Once the component is finished running, the hook index is reset and the bump is shrunk to shrunk to fit
- The final size of the bump is then used in the heuristics engine for future components.
Running hooks:
- Each time use_hook is called, the internal hook state is fetched as &mut T
- We are guaranteed that our &mut T is not aliasing by re-generating any &mut T dependencies
- The hook counter is incremented
Dropping hooks:
- When the hook is scheduled for deletion, the "drop" function is run for each hook
- (dropping hooks is basically a drop implementation, but can be customized even for primitives)
VNode Bump Arenas
Diffing
The entire diffing logic for Dioxus lives in one file (diff.rs). Diffing in Dioxus is hyper-optimized for the types of structures generated by the rsx! and html! macros.
The diffing engine in Dioxus expects the RealDom
Patches
Dioxus uses patches - not imperative methods - to modify the real dom. This speeds up the diffing operation and makes diffing cancelable which is useful for cooperative scheduling. In general, the RealDom trait exists so renderers can share "Node pointers" across runtime boundaries.
There are no contractual obligations between the VirtualDOM and RealDOM. When the VirtualDOM finishes its work, it releases a Vec of Edits (patches) which the RealDOM can use to update itself.
Fiber/Concurrency and Cooperative Scheduling
When an EventTrigger enters the queue and "progress" is called (an async function), Dioxus will get to work running scopes and diffing nodes. Scopes are run and nodes are diffed together. Dioxus records which scopes get diffed to track the progress of its work.
While descending through the stack frame, Dioxus will query the RealDom for "time remaining." When the time runs out, Dioxus will escape the stack frame by queuing whatever work it didn't get to, and then bubbling up out of "diff_node". Dioxus will also bubble out of "diff_node" if more important work gets queued while it was descending.
Once bubbled out of diff_node, Dioxus will request the next idle callback and await for it to become available. The return of this callback is a "Deadline" object which Dioxus queries through the RealDom.
All of this is orchestrated to keep high priority events moving through the VirtualDOM and scheduling lower-priority work around the RealDOM's animations and periodic tasks.
// returns a "deadline" object
function idle() {
return new Promise(resolve => requestIdleCallback(resolve));
}
Suspense
In React, "suspense" is the ability render nodes outside of the traditional lifecycle. React will wait on a future to complete, and once the data is ready, will render those nodes. React's version of suspense is designed to make working with promises in components easier.
In Dioxus, we have similar philosophy, but the use and details of suspense is slightly different. For starters, we don't currently allow using futures in the element structure. Technically, we can allow futures - and we do with "Signals" - but the "suspense" feature itself is meant to be self-contained within a single component. This forces you to handle all the loading states within your component, instead of outside the component, keeping things a bit more containerized.
Internally, the flow of suspense works like this:
- accept the user's future. the future must be owned.
- wrap that owned future with a new future that returns an EventTrigger
- submit the future to the VirtualDOM's task queue
- Poll the task queue in the VirtualDOM's event loop
- use the EventTrigger from the future to find the use_suspense hook again
- run that hook's callback with the component's bump arena and result of the future
- set the hook's "inner value" with the completed valued so futures calls can resolve instantly
- load the original placeholder node (either a suspended node or an actual node)
- diff that node with the new node with a low priority on its own fiber
- return the patches back to the event loop
- apply the patches to the real dom
/* Welcome to Dioxus's cooperative, priority-based scheduler.
I hope you enjoy your stay.
Some essential reading:
- https://github.com/facebook/react/blob/main/packages/scheduler/src/forks/Scheduler.js#L197-L200
- https://github.com/facebook/react/blob/main/packages/scheduler/src/forks/Scheduler.js#L440
- https://github.com/WICG/is-input-pending
- https://web.dev/rail/
- https://indepth.dev/posts/1008/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react
What's going on?
Dioxus is a framework for "user experience" - not just "user interfaces." Part of the "experience" is keeping the UI snappy and "jank free" even under heavy work loads. Dioxus already has the "speed" part figured out - but there's no point in being "fast" if you can't also be "responsive."
As such, Dioxus can manually decide on what work is most important at any given moment in time. With a properly tuned priority system, Dioxus can ensure that user interaction is prioritized and committed as soon as possible (sub 100ms). The controller responsible for this priority management is called the "scheduler" and is responsible for juggling many different types of work simultaneously.
How does it work?
Per the RAIL guide, we want to make sure that A) inputs are handled ASAP and B) animations are not blocked. React-three-fiber is a testament to how amazing this can be - a ThreeJS scene is threaded in between work periods of React, and the UI still stays snappy!
While it's straightforward to run code ASAP and be as "fast as possible", what's not not straightforward is how to do this while not blocking the main thread. The current prevailing thought is to stop working periodically so the browser has time to paint and run animations. When the browser is finished, we can step in and continue our work.
React-Fiber uses the "Fiber" concept to achieve a pause-resume functionality. This is worth reading up on, but not necessary to understand what we're doing here. In Dioxus, our DiffMachine is guided by DiffInstructions - essentially "commands" that guide the Diffing algorithm through the tree. Our "diff_scope" method is async - we can literally pause our DiffMachine "mid-sentence" (so to speak) by just stopping the poll on the future. The DiffMachine periodically yields so Rust's async machinery can take over, allowing us to customize when exactly to pause it.
React's "should_yield" method is more complex than ours, and I assume we'll move in that direction as Dioxus matures. For now, Dioxus just assumes a TimeoutFuture, and selects! on both the Diff algorithm and timeout. If the DiffMachine finishes before the timeout, then Dioxus will work on any pending work in the interim. If there is no pending work, then the changes are committed, and coroutines are polled during the idle period. However, if the timeout expires, then the DiffMachine future is paused and saved (self-referentially).
Priority System
So far, we've been able to thread our Dioxus work between animation frames - the main thread is not blocked! But that doesn't help us under load. How do we still stay snappy... even if we're doing a lot of work? Well, that's where priorities come into play. The goal with priorities is to schedule shorter work as a "high" priority and longer work as a "lower" priority. That way, we can interrupt long-running low-priority work with short-running high-priority work.
React's priority system is quite complex.
There are 5 levels of priority and 2 distinctions between UI events (discrete, continuous). I believe React really only uses 3 priority levels and "idle" priority isn't used... Regardless, there's some batching going on.
For Dioxus, we're going with a 4 tier priority system:
- Sync: Things that need to be done by the next frame, like TextInput on controlled elements
- High: for events that block all others - clicks, keyboard, and hovers
- Medium: for UI events caused by the user but not directly - scrolls/forms/focus (all other events)
- Low: set_state called asynchronously, and anything generated by suspense
In "Sync" state, we abort our "idle wait" future, and resolve the sync queue immediately and escape. Because we completed work before the next rAF, any edits can be immediately processed before the frame ends. Generally though, we want to leave as much time to rAF as possible. "Sync" is currently only used by onInput - we'll leave some docs telling people not to do anything too arduous from onInput.
For the rest, we defer to the rIC period and work down each queue from high to low. */
Strategy:
-
When called, check for any UI events that might've been received since the last frame.
-
Dump all UI events into a "pending discrete" queue and a "pending continuous" queue.
-
If there are any pending discrete events, then elevate our priority level. If our priority level is already "high," then we need to finish the high priority work first. If the current work is "low" then analyze what scopes will be invalidated by this new work. If this interferes with any in-flight medium or low work, then we need to bump the other work out of the way, or choose to process it so we don't have any conflicts. 'static components have a leg up here since their work can be re-used among multiple scopes. "High priority" is only for blocking! Should only be used on "clicks"
-
If there are no pending discrete events, then check for continuous events. These can be completely batched
-
we batch completely until we run into a discrete event
-
all continuous events are batched together
-
so D C C C C C would be two separate events - D and C. IE onclick and onscroll
-
D C C C C C C D C C C D would be D C D C D in 5 distinct phases.
-
!listener bubbling is not currently implemented properly and will need to be implemented somehow in the future
- we need to keep track of element parents to be able to traverse properly
Open questions:
- what if we get two clicks from the component during the same slice?
- should we batch?
- react says no - they are continuous
- but if we received both - then we don't need to diff, do we? run as many as we can and then finally diff?