Thursday , July 9 2020

Moxie: Incremental Declarative UI in Rust, Hacker News


            

moxieis a small incremental computing runtime focused on efficient declarative UI, written in Rust. moxie itself aims to be platform-agnostic, offering tools to higher-level crates that work on specific platforms. Most applications using moxie will do so through bindings between the runtime and a concrete UI system like the web or a consumer desktop platform.

This post has been forming for a while, and I find myself finally getting to it thanks to Raph’s thought-provoking post on reactive UI. I’ll discuss a bit of how I viewmoxie‘s approach to declarative UI in Rust and how the project fits into the mental model Raph explores in his post.

A few months before writing this Igave a talk at RustConf 2019which covers many similar concepts and subjects, and aversion of it with Q&A at the SF meetup. If you’re reading this you may also find them interesting.

moxie-dom demoahoy

The most mature user of it today ismoxie-dom, a crate for defining web applications in pure Rust using WebAssembly.

The web is pretty cool. Here is a counter built with moxie-dom, embedded into an iframe. You can click the button and watch the count climb! Wow!

Here’sthe codefor that:

moxie_dom :: boot (document().body().unwrap(), || {     letcount=state! (||0);      mox! {        div>{%"hello world from moxie! ({})",&count}div>        buttontype="button"          on={move| _: event :: Click|count.update(|) **************** (C)|Some(C(1)))}>            "increment"         button>    >};      fortin &["first","second","third"] {         mox! {div>{%"{}", t}div>};     } });

In this example, thestate!variable on the second line and themox!macro with quasi-XML syntax are both provided by the core moxie crate and can be shared between arbitrary UI “backends.”

Each time the button is clicked, the closure inbootruns in entirety but if you open your browser’s devtools on the iframe, you should see that only the necessary DOM nodes are seeing updates:

In addition tomoxie-dom,rebohas been working on an interesting experiment to reuse a portion of moxie’s core logic to addhooks to the Seed web framework.

moxie-nativeAhoy

My earlier experiments with moxie were aimed at desktop applications without any web dependency, and this is something I know many in the Rust community are eager to see come together. In that vein,TiffanyBennett has a very new project calledmoxie-nativewhich is exploring the use of moxie andWebRenderto create desktop UI. These Windows and Linux screenshots:

WIP screenshots of moxie-native on windows and linux from nov 2019

are produced fromthis code:

usemoxie_native :: prelude ::*;// eliding definitions for CalcState, Message, etc ...# [topo::nested] # [illicit::from_env(state: &Key)]fncalc_function(message: Message) ->Nodeletstate=state.clone();     leton_click=move|_ event:&ClickEvent|state.update(|)state|Some(state.process(message)));      lettext=matchmessage {         Message :: Cls=>"C".to_owned(),         Message :: Equ=>"=".to_owned(),         Message :: Digit (digit)=>digit .to_string(),         Message :: Op (Op :: Add)=>" ".to_owned(),         Message :: Op (Op :: Sub)=>"-".to_owned(),         Message :: Op (Op :: Mul)=>"*".to_owned(),         Message :: Op (Op :: Div)=>"/".to_owned(),     };      mox! (         button style={BUTTON_STYLE} on={on_click}>            span>{text}span>        button>    ) }  # [topo::nested] # [illicit::from_env(state: &Key)]fncalculator() ->Node{     mox! {         app>            window title="Moxie-Native Calculator">                view style={ROW_STYLE}>                    span>{%"{}", state.display()}span>                view>                view style={ROW_STYLE}>                    calc_function_=(Message :: Digit (7))/>                    calc_function_=(Message :: Digit (8))/>                    calc_function_=(Message :: Digit (9))/>                    calc_function_=(Message :: Op (Op :: Mul)/>                view>                view style={ROW_STYLE}>                    calc_function_=(Message :: Digit (4))/>                    calc_function_=(Message :: Digit (5))/>                    calc_function_=(Message :: Digit (6))/>                    calc_function_=(Message :: Op (Op :: Div)/>                view>                view style={ROW_STYLE}>                    calc_function_=(Message :: Digit (1))/>                    calc_function_=(Message :: Digit (2))/>                    calc_function_=(Message :: Digit (3))/>                    calc_function_=(Message :: Op (Op :: Add)/>                view>                view style={ROW_STYLE}>                    calc_function_=(Message :: Digit (0))/>                    calc_function_=(Message :: Equ)/>                    calc_function_=(Message :: Cls)/>                    calc_function_=(Message :: Op (Op :: Sub)/>                view>            window>        app>    } }fnmain() {     letruntime=moxie_native :: Runtime :: new (|| {         letstate=state! (|| CalcState :: new ());         letwith_state=illicit :: child_env!(Key=>state);         with_state.enter(|| calculator! () )     });     runtime.start(); }

As you can see, it’s still early days but (for me at least) it’s extremely exciting! It’s also worth noting that it’s possible to write all of this functionality without themoxmacro, even if it’s not yet pleasant – I have some thoughts for that too. Full disclosure: Tiffany is also using a small fork of the macro that allows for statically-checked DOM mount-points, we should be able to reconverge soon but in the meantime the macro code in these snippets isn’texactlyidentical .

And more!Ahoy

I am eager to see moxie powering apps on top of many underlying frameworks in the future, including those written using moxie as the main incremental tool (for the extremely adventurous only!). If you’re interested in using moxie to write bindings to the framework of your choice, please come join thechat! Because the core crates aren’tthatstable yet I think the chat channel is best but in the future I expect we’ll have guides for writing bindings & integrations.

After the initial setup of any interactive UI, further changes are primarily driven by some kind of event. Once that event occurs, the program is responsible for computing what must change in the state of the application, deciding how the application’s interface should be changed as a result, and then performing that update accordingly. Along the way it’s typical to request future notifications of hardware inputs, timers, network events, or results from internal asynchronous tasks.

These events are usually handled in a loop, receiving information from the outside world and eventually generating changes in the presented interface. In this way, interactive applications resemble (meta-)control loops, constantly attempting to reconcile the external state of the interface with the “set value” defined by the application’s logic.

moxie views the “control loop” code in UI applications as definingrelations maintained over time:

 ------ -------     events ---->|              | program | --->outputs  initial ---->|  inputs   ------------- 

“Declarative” UI ahoy

Our goal is to allow one to “declare” these relations directly in the program source. In my opinion, the clearest way to think of this is that the goal is to write the code “generically with respect to time, “describing the state of the UIright nowfor any value of now. I think this is clearer in the above code samples, where the code always executes a complete declaration of the desired UI rather than explicitly mutating prior state.

When we declare the shape of our UI in code, we are writing a program which will run repeatedly, each time computing a minimal update with minimal side effects while ensuring that the output matches the snapshot captured by the declaration.

To make snapshots efficient, moxie recognizes that “declarative UI” must be whatAdaptoncalls “incremental computation”: **********

A program P is incremental if repeating P with a changed input is faster than from-scratch recomputation.

This means making decisions about how to structure our declarative applications in such a way as to reuse as much work as we can. Ideally we can do this while preserving other desired properties. We’d like to have generally portable semantics for our declarative runtime, so that Rustaceans can use it on a wide variety of targets and platforms. Expressing the computation incrementally should not undo the benefits of having a single declaration for all instances of the application – it must still be pleasant enough to write.

Most existing UI systems have a way of dividing the output space of the application into nested hierarchies, each one containing 0 or more of its own sub-divisions. These subdivisions form a tree, with the root of the tree owning the total output space, its children owning their own portion each of that, and so on.

Assuming you’re familiar with Rust’s ownership model, this is more or less how Rust encourages viewing execution and dataflow within a thread. Everything descends frommain (), receiving smaller inputs at each lower stage of decomposing the program into smaller function calls, accumulating results on the way back up, informing later siblings and perhaps the final output.

In my opinion, regular function calls are the most “naturally Rustic” tools for modeling dynamic hierarchies. They have a very clear relationship to the ownership and reference models, are the default abstraction chosen when decomposing a solution to a problem, and underly Rust’s uniform execution model. Somoxie offers tools to declare outputs incrementally according to the shape of a callgraph.

Generic IncrementalityAhoy

It needs to be efficient to capture a snapshot of the declared output, since it happens in a loop quite frequently. The primary tool that moxie offers for this ismemoization of nested tree values ​​which are stored by the runtime in-between calls of the program.

The simplest form of memoization is theonce!macro, which never reinitializes its contents after the first time:

letfirst=once! (|| Arc :: new (String:: new (include_str! ("..."))));                // ^ only runs once for this location in the tree                // subsequent runs at this location will clone the Arc

Much of the time, expensive operations take a value as input which sometimes varies but is often stable. In those cases, we’d like to reuse the prior work whenever possible:

/// Reads the contents of the provided path as a string, returning a reference /// to it. The I / O is performed only when this is first called or when it has /// the contents of a different path cached. /// /// I don't recommend writing this function in your projects! So many reasons.# [topo::nested]fnintern_file_contents(path:&(Path) ->Arc{     memo! (path, | (p)Arc :: new (std :: fs :: read_to_string (p).Unwrap()))               // ^ touches disk only when p changes}

Here,memo!takes thecapture argumentpath, and runs the initialization closure wheneverpathchanges. If it’s able to reuse a previous result, then it willClonethat and return it. There are non –Cloneoptions too, it’s just the most straightforward for an example.

The storage is local to that callsite within that function, relative to the current location in the call tree. It holds values ​​across snapshots, which allows our code to be written for “right now,” regardless of prior executions. At the end of each snapshot, the runtime drops all values ​​which were not referenced in the latest “revision.” The initialization and destruction of each memoized value can therefore be used to model mount / unmount style lifecycle events – a subject for another post.

Identifying Storage LocationsAhoy

This memoization tool isn’t very useful unless we can call it multiple times in a given snapshot, storing different values ​​in different parts of the tree without interference between them.

In order to differentiate the usages ofintern_file_contents!within two different callers, it is annotated with# [topo::nested]. A uniquetopo :: Idis constructed at the invocation of functions annotated this way, which can be retrieved withtopo :: Id :: current (). TheIdis unique to the parentIdwhich was active when the current function was called, the source location at which it was created (so we can identify multiple calls within a parent function), and some runtime data we call a “slot.”

By default the slot used is the number of times that source location has already been called during the parentId. This provides a nice default behavior for looping over collections, recursion, etc. The slot can always be overridden with a user-provided slot in cases where the identity shouldn’t be tied to execution order within the collection, for example in a list where the contents are shuffled but individual list items remain intact.

With these combined, eachIdrefers to a stable location within our incremental program’s call tree. When we run our code multiple times, functions with the same “location” in the call tree will receiveIds the same as their previous executions, allowing for a rendezvous with the output of previous invocations.

Tying this together, each call tomemo!during a given snapshot will receive a differentIddepending on tree location, and we usethatas the index into our persistent cross- snapshot storage. Future iterations of the snapshot process will be able to see that data provided their execution takes the same path.

Driving Forwardahoy

The other main primitive in moxie is the (state variable) , which forms the interface between the moxie runtime and underlying event-generating systems. State variables are memoized values created withstate!. They are mutated through theKeyreturned fromstate. Mutations notify the embedding environment that the runtime must be scheduled for execution. Each time that happens, another iteration of the control loop is entered and we snapshot the current interface state.

Ask not what you must do for moxie,Ahoy

This overall approach offers users of the core runtime many degrees of freedom in terms of how they express a UI system on top:

  • moxie-dom incrementally mutates concrete DOM nodes during a declaration, binding them to a “scoped singleton “parent. The currently-mutating-node is then offered as the parent to child function
  • moxie-native components return memoized copy-on-write values ​​which are incrementally evaluated in later stages of the pipeline.
  • Previous experiments of mine have directly accumulated linear display lists forWebRenderduring the course of execution, and I expect that in the future I’ll write some moxie bindings for Dear ImGui or another similarly “linearized snapshot” system.

There are still some admittedly serious restrictions that moxie imposes on users (like transforming functions that create nodes in the tree into macros! or my quick’n’dirty use of hashing to createtopo :: Ids turning out to be unsound), but I think we have paths to resolve all of them within a Few months.

Pleaseemail me,file an issue, or hop in thechatif you’re trying things out and have questions or feedback. I’m eager to hear from you! It highlights places I can learn to improve my communication / process / design / etc, and helps me prioritize efforts! Plus, it’s nice to hear about everyone’s projects: D.


So that’s moxiequamoxie, or as well as I’m able to represent it right now.

If you haven’t read Raph’s excellent andthought-provoking post on reactive UI, I recommend either doing so first or skimming the rest of this post. Assuming you’ve read it, I’ll be quoting liberally from it to add moxie-specific color to the ideas and to offer a few replies since I’ve built such a nice soapbox for myself here. True to Raph’s inspiration, I’m following his walk through ideas since the satisfying narrative conclusion already happened up above.

there are two fundamental ways to represent a tree. First, as adata structure, … Second, as atrace of excecution,

I think about this in terms of the shape of “tree snapshot” structure (s) captured and how. The spectrum of shapes of those data structures is quite broad, as Raph rightly notes. Garbage-collected heap references, identifers stored in ECS-style linear storage, vertex buffers, raw GPU command buffers, intermingled layers of some or all of these, etc.

The shapes of these structures determine which kinds of analyses a runtime or framework can perform to calculate e.g. layout, as well as the efficiency of capturing the snapshot of the application’s visual state in the first place (what Raph calls app data ->widget tree and I tend to think of as the “capture” or “extraction” phase of application logic).

moxie’s approach here is to use callsite-based IDs for identifying incremental storage, but to use initialization of that storage as a tool for memoizing mutations. You can call methods on a builder context, or manipulate tree nodes and connect them together. The goal of the core runtime is to allow for any strategy here, as long as it can fit in an initialization closure.

Raph goes on to describe a model of producing these trees in pipelined stages, with nice diagrams:

There are a number of reasons to analyze the problem of UI as a pipeline of tree stages. Basically, the end-to-end transformation is so complicated it would be very difficult to express directly. So it’s best to break it down into smaller chunks, stitched together in a pipeline.

… you want these tree-to-tree transformations to beincremental.In a UI, you’re not dealing with one tree, but a time sequence of them, and most of the tree is not changing, just a smalldelta.One of the fundamental goals of a UI framework is to keep the deltas flowing down the Pipeline Small.

In my opinion this nicely sets the stage for the comparison between the architectures of contemporary “declarative UI pipelines” and those of today’s optimizing compilers. Without going into too much depth, I’d argue that there is a similar trend at play with other tools undergoing batch ->interactive re-architectures or re-writes:

  • incremental compilers built for IDE usage (Roslyn, rustc, theSkipresearch language was built around a very cool self-hosting incremental toolchain, etc.)
    • I quite likesalsaas a generic tool in this space, and had tried a couple of times to implement moxie on top of it
  • megabuild systems with strict DAG-based builds (eg Bazel)
  • databases materializing synthetic views / tables / etc in response to writes to concrete tables
  • streaming frameworks likeDifferential Dataflow
  • etc

It seems to me thatcomputing ecosystems prioritizing latencytrend toward having “incremental computing engines” at their hearts. It’s been identified by research projects likeAdaptonand others for a while now. I’m not terribly well-versed in the research in this niche, but it seems likely to be a fruitful place to learn.

This is especially true when the transformation is stateful, as it makes sense to attribute that state to a particular stage in the pipeline.

I agree, and this is one of the main ways in which the comparison between UI systems and compilers breaks down. Typical incremental compiler pipelines don’t have to consume different types of events, just project updates.

Pulling from a tree basically means calling a function to access that part of the tree.

IIUC, another important implication of pull-based APIs in Rust is that you generally need concrete return types, intermediate iterator structs, and lifetimes / generics / etc require more effort.

One more thing to say about the interplay between sequence iterators and trees: it’s certainly possible to express a tree as itsflatteninginto a sequence of events, particularly “push “and “pop” (also commonly “start” and “end” in parser lingo) to represent the tree structure.

This is the structure thatDear ImGuiuses, withImGui :: Begin (...)and (ImGui :: End) )calls. This is also the basic structure that I used in a demo I made in early 2019 with proto-moxie andWebRender.

… a change to some node on the input can have nontrivial effects on other nodes in the output. This is the problem of “tracking dependencies” and probably accounts for most of the variation between frameworks.

I think one of the most general and useful strategies isdiffing, … you do a traversal (of some kind) of the output tree, and at each node you keep track of thefocus,specifically which subtree of the input can possibly affect the result.

moxie does this, but at the level of individualletbinding assignments, and the caller must provide the “focus” (which moxie calls the “capture”) value (s) that will be used to determine whether to re-initialize the memoized value.

My hope here is that by keeping diffing, lifecycle, and updates local to specific variables, moxie can provide “incremental connective tissue” without requiring specific tree structures of its users.

Diffing is also available in a less clever, more brute-force form, by actually going over the trees and comparing their contents. This is, I think, quite the common pattern in JS reactive frameworks … **********

Ithinkthat most diffing strategies in JS -land compare virtual DOM trees against one another, rather than comparing directly with the DOM. I could be wrong. A good thing to look closely at if someone were to write more summary content!

I’m not going to spend a lot of words on other incremental strategies, as it’s a huge topic. But besides diffing, the other major approach is explicit deltas, most commonly (but not always) expressed as mutations. Then, you annotate the tree with “listeners,” which are called when the node receives a mutation …

Interestingly, moxie combines these two approaches. Memoized values ​​could be said to be “diffed” by checking the arguments against the existing storage. Updates are only visible to the runtime via state variables which require callingupdate (...)orset (...)on them to cause a state transition.

bookkeeping to identify a subtree of the output that can change, and causes recomputation of that. There’s a choice ofgranularitythere, and a pattern I see a lot (cfRecomposein Jetpack Compose) is to address a node in the output tree and schedule a pull-based computation of that node’s contents.

moxie assumes that the program will enter from the “top” of the tree each time. I think that lower level re-entry may be interesting, but the implementation details for moxie would be quite challenging and may not be feasible without language extensions.

My suspicion is that Rust may be fast enough along with the right memoization strategies to never worry about the time spent getting from the root of the tree to nodes with changes. We’ll see!

And this brings us to one of the fundamental problems in any push-based incremental approach: it becomes necessary toidentifynodes in the output tree, so that it’s possible to push content to them.

This is important! In Raph’s model, moxie is push-based and moxie-dom and moxie-native incrementally map those push-shaped declarations onto pull-based data structures.

That said, it’s completely feasible to treat it completely as a “tracing” system in Raph’s terms, which is what I intend to do with ImGui bindings. moxie would still provide stateful components and memoized reuse of expensive computations, but wouldn’t actually retain any tree structure.

Regarding the concrete strategy for identity, moxie’s a little bit weird according to Raph’s breakdown.

… common approach is some form ofkey path,… at heart a sequence of directions from the root to the identified node.

Semantically, this is what atopo :: Idencodes, where the nodes are function activation records. In the near future I expect it to be a more literal encoding of this.

… a common choice for other path elements is unique call site identifiers.

Eachtopo :: Idis created at a callsite, so the “node” identifiers in moxie’s model are partly made from callsite identifiers.

Using object references is possible in Rust but … nail down the fact that the tree is stored in a data structure at that

moxie-dom and moxie-native rely ontopo :: Ids to map from memoization storage to object references stored between frames. But that doesn’t rule out …

… unique identifiers of some kind (usually just integers). This is the basic approach ofECSand also druid before the muggle branch.

… which would also work with memoization storage, I think.

In both Jetpack Compose and imgui, you have a succession of stages which are basically “push” and end up emitting events to a context on the output. These compose well. … This pattern works especially well for expand-type transformations, as the transformation logic is especially intuitive – it’s just function calls. It’s also really clean when the transformation is stateless, otherwise you have the question of how the transformer gets access to state.

Aside: one interesting way to think about moxie is the constrained ability to add memoization and statefulness to computation written to be stateless and pure. Another interesting way to think about moxie is that it allows for imperative code with the “concision of purity.”

For one, you do put startGroup and endGroup calls in (these are mostly magically inserted by a compiler plugin, doing source transformation of the original logic). These help the Composer keep track of the key path at all times, which, recall, is an identifier to a specific position in the tree. **********

And the Composer internally maps that to aslot,which can be used to store state as needed. So basically, even though there’s not a tree data structure in memory, when you’re in the middle of a transformation stage, you can get access to state for your node asifthe tree was Materialized.

moxie plays a similar trick with its memoization storage being “lensed” by the current instruction pointer, as it were. Memoized value storage is indexed by thetopo :: Idat the callsite where the value is to be materialized. That newIdcomes from the currentId(that of the calling context, or the “parent” in our abstract tree), the callsite of the newId(analogous to startGroup), and some runtime data which moxie calls the “slot.” As I mentioned before, that’s typically populated with the number of times the new callsite has been invoked within the current (parent)Id, allowing for the default behavior in loops to make intuitive sense.

I highly recommend Leland Richardson’s (Compose From First Principles) , as it goes into a fair amount of detail about general tree transformations, and the concept of “positional memoization” as a way to (in the lingo of this blog post) access state of intermediate tree stages in a transformation pipeline.

I also highly recommend Leland’s post! In Compose’s worldview, moxie might be said to implement “topologically-nested memoization.” Ow, that hurts my mouth. Leland and I have talked a few times about how to think about the style of repeatable, declarative functions that moxie and Compose add to their respective languages, and it’s always been a blast. I look forward to seeing a lot more from that team, especially as they’re able to iterate in the open on runtime and language-level features.

There have been a number of attempts to adapt React-style patterns to Rust. Probably the most interesting ismoxie.

(moxie) builds key paths to identify nodes in the output tree, and emits the output tree as a trace. Like Imgui, Jetpack Compose, and makepad, it mostly uses function calls to represent the application logic. I look forward to seeing how it progresses.

To be slightly more precise about my goals re: React, my main goal is to identify the technology needed for the analogousecosystem nichefor Rust. Well, that, and I quite like the (Hooks API) . I. have described moxie a few times as “what if a React in Rust but built out of context and hooks?”

There is some fun nuance here with how moxie actually fits into the broader technical picture Raph paints, but I won’t duplicate that here as thats a … lot of what I’ve already written.

If you’re designing one of these systems for scripting, the cross-language boundary is always going to be one of the most important.

I heartily agree, especially having originally started this project thinking about providing a runtime for JavaScript (story for another time, literally). That’s actually a significant influence on the ImGui flavor of moxie-dom. Calling functions with a context pointer is IMO much simpler to do across an FFI boundary than to navigate shared ownership of nodes with a managed environment.

Obviously most JS reactive frameworks take DOM as a given, and are organized around it. They also tend to gloss over the implicit state in DOM nodes and downstream, pretending that the only state management concerns are in the transformations from app state up to the DOM.

Agreed! I’m particularly interested to see how moxie-native continues to develop, and whether bets on non-hierarchical memoization pay off in later pipeline phases. This is an area where I see React Native as having struggled quite a bit and I think that Rustic solutions need a higher bar.

CMR,Tiffany,Lucien,Brandon,NathanandSunjayall gave their time and attention to review this post and provide invaluable feedback. Thank you!

I’ve been tinkering with a static-site-friendly comments setup but don’t yet have it working. In the meantime, pleaseemail meyour comments and questions and I’ll post them here!


(Notes) ahoy

moxie-dom defaults to a push-based API for ease of use with collections and other control flow, but under the hood manipulates a concrete DOM tree to which it exposes “raw” access for the adventurous.

It’s important to note that while moxie-dom gains convenience from a push-based API, it loses the ability to statically verify the correctness of parent / child relationships the way that moxie-native can by virtue of expressing application logic with a pull-based API. I think there may be solutions at the language level for this, mainly by offering static checking for# [illicit::from_env(...)]or a similar feature. Since that’s very far away, I consider the two approaches to be exploring really valuable tradeoff / design points for “declarative UI in Rust.” Do we actually find ourselves significantly more productive with easy control flow for UI elements? How often in practice do errors occur from having the checks at runtime?

It’s also worth noting that as far as “react-alike” systems go, the use of concrete return values from functions is the undisputed “known entity” and projects like Compose and moxie-dom are outliers

I suspect there’s also an argument that any software which interactively mediates human activity will eventually find its success bounded by its latency properties. Perhaps this implies there’s an argument that meatbag-adjacent technologies should prioritize latency instrumentation and analysis? This has certainly been true of human-facing usage of request / response systems like HTTP services. I think there are some very interesting things moxie can do regarding latency, especially in conjunction with theTracingand (tracing-timing) Crates.

        

Brave Browser
Read More
Payeer

About admin

Leave a Reply

Your email address will not be published. Required fields are marked *