Skip to content
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

First draft of Xilem blog post #71

Merged
merged 2 commits into from May 7, 2022
Merged

First draft of Xilem blog post #71

merged 2 commits into from May 7, 2022

Conversation

raphlinus
Copy link
Owner

@raphlinus raphlinus commented May 6, 2022

Closes #70

Copy link

@geom3trik geom3trik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very interesting read overall but a few things came to mind while reading it:

  • I think the part detailing the id path stuff could do with a sentence or two explaining why this is preferable to the global state approach you mention.
  • The section on event propgation and mutable access to the app state made no sense to me personally. I will check the prototype code on this but I think the explanation could do with being expanded with some more example code.
  • Possibly same for the adapt and memoize nodes but if it's just a high level overview you're aiming for then those sections are fine.
  • I would be interested to know how this system deals with 'local state' that might be needed by a subtree of widgets but isn't derived from the app state. Is there a special node for this, or something else?

Maybe of-topic for this post but I would be interested to hear your thoughts on state management at some point. When comparing to state management in the elm architecture you stated that:

Some people like the explicitness of this approach, but it is unquestionably more verbose than a single callback that manipulates state directly as in React or SwiftUI.

It's my understanding that having app state centralized and updated with events actually makes it easier to scale an application, which is why Redux is so popular in React. I can infer that you have a different opinion on that but I would be interested to hear what your thoughts are on the scalability of Xilem's approach.


## Synchronized trees

In each "cycle," the app produces a view treeRendering in Xilem begins with the view tree. This tree has fairly short lifetime; each time the UI is updated, a new tree is generated. From this, a widget tree is built (or rebuilt), and the view tree is retained only long enough to assist in event dispatching and then be diffed against the next version, at which point it is dropped. In addition to these two trees, there is a third tree containing *view state,* which persists across cycles. (The view state serves a very similar function as React hooks)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Missing period and space after 'view tree'.
  • Might be worth clariying that the widget tree persists across cycles.
  • Does the view state have to be a tree? Or could it be a graph? I'm thinking of derived data from Recoil if you're familiar.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The view state is stored as a tree, yes. I'm not familiar with Recoil but I certainly am aware of more general incremental computation engines (Salsa, Adapton, Incremental, etc) in which the dependencies are explicitly modeled as a graph.

This is all potentially a much deeper conversation. I believe, without having gone too deep into implementation, that it would be fairly straightforward to wire up an actual graph incremental engine, and have the view state tree consist of lightweight references into that engine. (such an engine could be made available through context/environment, which is not currently part of the implementation but is planned)

So basically this is an area where I want to see what happens.


In each "cycle," the app produces a view treeRendering in Xilem begins with the view tree. This tree has fairly short lifetime; each time the UI is updated, a new tree is generated. From this, a widget tree is built (or rebuilt), and the view tree is retained only long enough to assist in event dispatching and then be diffed against the next version, at which point it is dropped. In addition to these two trees, there is a third tree containing *view state,* which persists across cycles. (The view state serves a very similar function as React hooks)

Of existing UI architectures, the view tree most strongly resembles that of SwiftUI - nodes are plain value objects. They also contain callbacks, for example specifying the action to be taken on clicking a button. Like SwiftUI, but somewhat unusually for UI in more dynamic languages, the view tree is statically typed, but with a typed-erased escape hatch (Swift's AnyView) for instances where strict static typing is too restrictive.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The term node is first mentioned here and it's not clear if this refers to a view or a widget.


## A worked example

We'll use the classic counter as a running example. It's very simple but will give insight into how things work under the hood. For people who want to follow along with the code, check the idiopath directory of the idiopath branch; running `cargo doc --open` there will reveal a bunch of Rustdoc.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly this is obvious depending on the audience but it might be worth mentioning that this is a branch on the druid repo.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also added a link. I'm slightly uncertain about all this as there's a chance I might rename the branch/directory with the "Xilem" branding, but I'll leave it as-is for now.


This was carefully designed to be clean and simple. A few notes about this code, then we'll get in to what happens downstream to actually build and run the UI.

This function is run whenever there are significant changes (more on that later). It takes the current app state (in this case a single number, but in general app state can be anything), and returns a view tree. The exact type of the view tree is not specified, rather it uses the [impl Trait] feature to simple assert that it's something that implments the View trait (parameterized on the type of the app state). The full type happens to be:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'simply' rather than 'simple'.

+------+ +--------+
```

The idea of assigning a stable identity to a widget is quite standard in declarative UI (it's also present in basically all non-toy immediate mode GUI implementations), but Xilem adds a distinctive twist, the use of *id path* rather than a single id. The id path of a widget is the sequence of all ids on the path from the root to that widget in the widget tree. Thus, the id path of the button in the above is `[1, 3]`, while the label is `[1, 2]` and the stack is just `[1]`. The full id path is redundant if we had global information about the structure of the tree (for example, by following parent links), but the point is that given id paths, we don't *need* to track this kind of information.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this 'id path' preferable to global information about the tree structure? You've said that with the 'id path' you don't need that information but not why that is better. I could imagine a system where some context is passed to widgets on constrution which keeps track of this information and would be transparent to the user who interacts with just the views.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added something, but I think you've asked a deep question here, and I'm not positive I can give a good argument at the level of writing I'm doing.

I did indeed in an earlier prototype have tree structure tracking that was done on construction/mutation of the widget tree, so the is visible during reconciliation were just plain integer tokens (not paths). One of the subtle ways that breaks down is ids allocated to futures - they might not correspond to an actual node in the widget tree, or perhaps dummy nodes would be added. Same for env_get, which I've written about on Zulip but not in this blog.

Note also that Elm has an analogous problem; Html has map but there is no comparable component-forming operation for subscriptions. As far as I know, those have to be addressed to the top-level update method of the app logic.

I'll think a little more about whether I can explain things better. Most often, when I find things hard to explain, it's because of a gap in my own understanding.


After clicking the button and running the callback, the app state consists of the number 1, formerly 0. The app logic function is run, producing a new view tree, and this time the string value is "Count: 1" rather than "Count: 0". The challenge is then to update the widget tree with the new data.

As is completely standard in declarative UI, it is done by diffing the old view tree against the new one, in this case calling the `rebuild` method on the `View` trait. This method compares the data, updates the associated widget if there are any changes, and also traverses into children.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this diffing occur if the view tree is short-lived? Is there a copy of the view tree being stored accross cycles?


It would be very limiting to have a single "app state" type throughout the application, and require all callbacks to express their state mutations in terms of that global type. So we won't do that.

The main tool for stitching together components is the `Adapt` view node. This node is so named because it adapts between one app state type and another, using a closure that takes mutable access to the parent state, and calls into a child (through a "thunk") with a mutable reference to the child state.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You haven't defined what a 'component' is. Previous to this the reader knows only about views, widgets, and view state. Is a component just another term for a view? This section is titled 'components' but talks mostly about the adapt node (view?).


The main tool for stitching together components is the `Adapt` view node. This node is so named because it adapts between one app state type and another, using a closure that takes mutable access to the parent state, and calls into a child (through a "thunk") with a mutable reference to the child state.

In the simple case where the child component operates independently of the parent, the adapt node is a couple lines of code. It is also an attachment point for richer interactions - the closure can manipulate the parent state in any way it likes. The event handler of the child component is also allowed to return an arbitrary type (unit by default), for upward propagation of data.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand what any of this means. Maybe an example would help? Does this section require knowledge of Druid because I'm not sure what it means by 'event handler' and 'return an arbitrary type'.


Ron Minsky has [stated][Signals and Threads: Building a UI framework] "hidden inside of every UI framework is some kind of incrementalization framework." Xilem unapologetically contains at its core a lightweight change propagation engine, similar in scope to the attribute graph of SwiftUI, but highly specialized to the needs of UI, and in particular with a lightweight approach to *downward* propagation of dependencies, what in React would be stated as the flow of props into components.

In this particular case, that incremental change propagation is best represented as a *memoization* node, yet another implementation of the View trait. A memoization node takes a data value (which supports both `Clone` and equality testing) and a closure which accepts that same data type. On rebuild, it compares the data value with the previous version, and only runs the closure if it has changed. The signature of this node is very similar to [Html.Lazy] in Elm.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously you said that Xilem:

removes some of the limitations. In particular, Druid requires app state to be clonable and diffable, a stumbling block for many new users

But this seems to be re-introducing that limitation if the user wants this finer grained change propagation.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. It's a tradeoff, and the difference is that it's now opt-in, while Druid absolutely required it.

)
```

This logic propagates the change up the tree *only if* the child state has actually changed.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't you want change to propagate down the tree? Actually, which tree is this referring to?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The app state tree. But I'm going to change the way I word this, because there's no requirement that the app state be modeled as a tree, unlike original Druid. It's quite fine for the app state to be a graph (and we're going to have this in Runebender, as glyphs referencing components is definitely graph rather than tree structure). Good catch, thanks.

@raphlinus
Copy link
Owner Author

Thanks for the detailed commentary @geom3trik! I've been so close to these problems so long it's not easy to get calibrated on what I can take for granted and what needs to be explained in more detail. This will definitely help make the post better, and I hope to have another draft out soon.

A fair amount of new writing in response to feedback, as well as visuals for the worked example. Hopefully things are clearer now.
@raphlinus raphlinus merged commit a2484a9 into master May 7, 2022
@raphlinus raphlinus deleted the xilem branch May 7, 2022 19:03
@black7375
Copy link

Disclaimer: I'm a Web front end developer, and not familiar with native UI.

Web frameworks have some interesting ideas.
I can share a few things.

React's concurrent mode

I heard that druid is switching to a tic model, which I think it is possible to scheduling.

React works in concurrecy mode to prevent blocking.

We’re rendering the results! As soon as React finishes rendering the first slider update, it begins to render the transition to the results. Since this update is opted-into concurrent rendering, React will do three new things:

  • Yielding: every 5 ms, React will stop working to allow the browser to do other work, like run promises or fire events. This is the reason why the big chunk of work from the previous examples is now chopped up into smaller pieces. React has split up everything that it needs to do into smaller pieces of work, and is smart enough to pause and let the browser handle pending events (this is called “yielding”). In our case, yielding allows the browser to fire more mousemove events from the Slider to tell React that the mouse is still moving.
  • Interrupting: When a second mouse move event comes in, we schedule another update for the Slider to move it. But how does React render that update, if we’re already in the middle of rendering the results from the last update? The answer is that when React starts working again, it will see that a new urgent update was scheduled during the mouse move and stop working on the pending results (since they’re out of date anyway). React switches to rendering the urgent Slider update, and when that urgent work is done, it goes back to rendering the results. We call this “interrupting” because rendering the results are “interrupted” to render the Slider.
  • Skipping old results: If React just started rendering the first results it was working on, then it would start to build up a queue of results to render, which would take too much time (like the setTimeout example above). So what React does instead is skips the old work. When it resumes from an interruption, it will start rendering the newest value from the beginning. This means React is only working on the UI that the user will actually need to see rendered, and never an old state.

Timer queue and task queue are separated, and when the time expires, it will be executed from the timer queue to the task queue.
It is sorted according to priority.
image

They increase responsiveness by giving priority to sync, input, general, and idle.

Automatic batching is also possible when events and timeouts occur, which improves performance.
You can also do batching like read, layout, paint..etc when accessing dom other than virtual dom rendering.

More context

Vue's Compiler-Informed virtual dom & Glimmer VM

Vue provides a way to compile components.
Use strategies such as static hoisting, patch flags, tree flattening.

Ember's glimmer generates a kind of bytecode.
image
image

More context

Solid's reactivity & state

Fine-grained reactivity can lead to minimal state changes.

image

An example of a neat library is recoil.
image

Another way to deal with a complex state without the reactive, is statecharts. (Famous library: xstate)

More context

Mikado's pooling

Mikado has a fairly unique pooling system.

image

Style

Freestyler caches styles according to cardinality,
Firefox has a prototype cache

The most advanced aspect of the web in terms of style and theme is how it manages.
In constructing a design system[1, 2, 3] like fast

Others

  • Event: Use the event delegation to reduce the number of listeners to improve performance. Most JS libraries do this automatically.
  • Litho: Asynchronous layout, Flatter view hierarchies, Fine-grained recycling UI framework for Android

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Writeup of Rust UI architecture ideas
3 participants