I'd like to figure out how to write UI code in Roc. I plan on writing a series of short (?) articles in this thread about UI approaches / framework design / philosophy in this thread with the goal of organizing my own thoughts and providing background for people who aren't as into the UI side of programming. After I get tired of writing about more general topics I'll go into the API alternatives in Roc I've been working through.
This will be coming mostly from a front-end persepctive even though I really don't want to tie any system I design to the browser. I've spent most of my career doing frontend development starting with the Gecko XPFE (cross-platform front end) in 2001 and more or less continuously since. I'm interested in the framework/system design of UI because I've done four graphical interface builders at different companies over the course of my career. I've done small projects in the various desktop and mobile native options but nothing in anger.
I am looking for feedback on all this and am happy to answer questions. In particular, if you know of something nice from native or gaming UI I should look into, I'd like to hear about it.
The switch to agent coding does a couple things for UI.
The first is that it makes some degree of authoring friction significantly more bearable. I think the system design still matters because I do still read the output and having primitives behave predictably and patterns that fail gracefully leads to reduced thrashing and token spend.
The second is that agents highly reward logging UI actions. I have a decent sized (10k LoC Typescript, 50k LoC Rust) Tauri-based transcription app using a variant of the re-frame architecture (somewhat similar to Elm architecture) that logs all actions to disk. I can say things like "hitting space is skipping a mark" and it'll dig through the history, reconstruct the state, trace the code branches affected, and fix the bug, injecting synthetic log entries as needed to debug finer points. It's so much better than attempting to do it with playwright or something that I will not design a UI framework that does not have some sort of centralized logging hook and a mechanism for obtaining a full state snapshot.
I suspect Roc's lack of ambient side effects allows for an improvement on normal logging in the form of reverse debugging. By recording all the inputs along with an index of the event that previously changed the data it should be straightfoward to roll back history and track down the source.
Finally, the prevalence of frontend UI code in general and React in particular results in an LLM bias towards React-shaped answers. I've only had light to moderate issues getting Claude to do other patterns but the bias is there.
I have had some trouble with Roc and my logging requirement. Naïvely doing both reads and writes off the log leads to a refcount of 2 which throws modifications from O(1) to O(N) and a consequent orders of magnitude perf cliff. I don't have a solution for this other than being careful.
Conceptually a persistent datastructure solves the issue and it does work quite well if the rest of the system is slow enough. I built several large apps in Clojurescript and with a persistent central store in React and they were fine but the approach is more about avoiding slowdown than about good performance. When doing a Solid 2 signals port to Rust for this project the overhead of the persistent datastructure over plain structs+Vecs (I tried two libraries) was high enough that I decided just copying would be a better approach and I suspect Roc code is fast enough for the same consideration.
Excited to see you explore that in roc :)
UI-related things I liked, which may (or not) inspire you, and which you probably already know about:
I love elm-ui too
Karl said:
I have had some trouble with Roc and my logging requirement. Naïvely doing both reads and writes off the log leads to a refcount of 2 which throws modifications from O(1) to O(N) and a consequent orders of magnitude perf cliff. I don't have a solution for this other than being careful.
thanks for the detailed writeup!
can you say more about how the log interacts with application state, and when the reads and writes are happening?
like for example is the log a List and then the reads are happening in the application via dbg of items in that list, which is bumping the refcount?
The basic problem is that to be able to log (boundary, before, after) I need to have both the before and after values. This is pretty obvious written out but it's something that the agent happily bulldozed over. I set up an O(1) check and prompted the agent that writes don't have to go into the same place and to instead log and merge host side. I didn't think to look into exactly what shape was causing the issue, it was a problem with a fairly straightforward solution so I just took the solution.
After setting up an O(1) check as part of the authoring process it triggered two additional times unexpectedly. The first was my deciding to do event handlers as |event, model| ...and that was worked around by switching to |model, event} .... The other is that Elm architecture handlers needed to be boxed and the model returned directly and not wrapped as in {model , cmds}. Since these had workarounds I haven't really looked into them but I do plan to do a workaround sweep.
Fundamentally UI is just drawing boxes and text to the screen. The two main UI framework approaches mirror the historic retained mode and immediate mode for graphics APIs.
In retained mode the client (app) doesn't draw directly to the screen. Instead it constructs a scene graph using the library's object model and the library translates that to draw calls. The advantage is that the library can optimize the rendering on an update and do the minimal amount of work.
In immediate mode the client makes the draw calls directly. The advantage is not needing to coordinate between app state and a library's object state.
This split carries over into UI frameworks, which have historically been retained APIs. It's speculation on my part because it significantly predates me and I haven't deliberately researched it but I think a large part of the reason is the concurrent rise of Object Oriented Programming. Regardless of the reason for the decision in other encironments, retained mode is the natural fit for the web since the object graph (DOM) can be described as data (HTML) in a way that matches domain semantics instead of drawing calls. We've collectively bolted an app platform on top of a document viewer and this is one of the consequences.
The core challenge in dealing with retained mode UI frameworks is that state coordination is hard. The change might be minimal from the drawing side but if you're switching tabs in your tabset you have to highlight the new one and un-highlight the old one. This particular switch is handled by the tabset widget author in every widget system since it's core to the behavior contract but it's still somewhere in the system. I believe humans are fundamentally incapable of fully comprehending complex systems and the state space of any practical UI beyond our capabilities. Because of this the code dealing with executing the state transitions has historically been both verbose and error prone.
The web is inherently retained but it's possible to implement an immediate mode API on top and the first library to popularize the approach was React. The vdom+diff handles the error-prone state transition part and greatly reduces the amount of code. I was consulting just as React was coming into popularity and it was fairly normal to get a 65-85% code reduction against the previous jquery / YUI / Ext version of the same app. The other reasons for React's popularity outside the immediate mode authoring experience were that it also fully solved composition and it was resistant to corruption from other JS on the page (the DOM is a mutable shared global) so it wasn't just one thing but the vdom is the headline for a reason.
Aside: If anybody is wondering why it took the web 20 years to get to React when the immediate vs retained tradeoffs are well known, the answer is that Javascript got 50 times faster between the summer 2008 and the summer 2009; the language simply wasn't fast enough to do significant amounts of computation. React landed in December 2011 which really wasn't that long after the approach became viable.
React also happens to work particularly well with functional approaches which can describe trees quite well but can't really describe mutation. The first significant adoption of React outside of Facebook was the Clojurescript community. The whole Redux and immutable state with reducers idea is from this initial Clojure influence. Most of the other functional language takes on the domain track the Elm architecture and are predominantly on react-style frameworks. It's been a couple years since I last checked so I plan on doing a survey as part of this exercise.
Despite the popularity of React the web is fundamentally retained and there's an alternative way of deriving state transitions: Signals. The general approach predates React with Knockout having some success but Knockout didn't have React's other advantages and was tied to the MVVM conceptual model that never really caught on. React basically cleared the field of all previous frameworks and the leading outstanding question for further refinement was performance. That quest plus a good experience with Knockout led Ryan Carniato to develop SolidJS and with it the general Signals model which pretty much everybody else has adopted. I can go into the history and progression of dataflow programming and reactivity if someone is interested but those aren't relevant to understanding the state of the art.
The basic approach is to split up your state into small pieces that are easy to compare using equality and put each piece into a different object called a signal. Then you register any reads of that signal as a subscriber and when the state gets written compare the new value with the old one and if they're different propagate the change to all the subscribers. There are two main types of subscribers: memos which are pure state transformers (usually map in the FP sense) and effects which run after the signal graph is settled and affect the changes triggered by the data update.
I believe that Signals are a better model for Javascript and the DOM. The implementation is lighter, they do less work, they scale better. I also find them roughly as ergonomic and the mental model is cleaner than React's hooks. Ryan is deeply dedicated to his domain and has been grinding at it for a decade now with a particular focus on composable primitives. If you want to go into the weeds, he has a roughly biweekly 6 hour livestream where he basically talks through whatever piece he's currently working on and takes feedback.
So when I went to do a UI platform for Roc I had the agent do a Rust port of Solid 2 and that actually went really well. Propagation overhead is 200ns, the port can smapshot the contents of the full signal graph, and there's a hook for logging and reverse debugging that goes into a ring buffer at the cost of ~12% throughput. Pretty happy. Actually getting it into Roc with a development experience that's on par with other languages hasn't gone as well. I'll get into it but the short version is that signals need to manage control flow and actually getting fine grained updates out of the graph requires slicing up code into many thunks.
I realize that this is pretty web focused but the prevalence of Web UI has led to more recent native UI frameworks being pretty heavily inspired by one or the other and I find both approaches better than older native stacks retained APIs.
It turns out that only the last dropped field of a record can be written in place due to refcount limitations. https://github.com/roc-lang/roc/issues/10426
Karl said:
The basic problem is that to be able to log
(boundary, before, after)I need to have both the before and after values.
hm, what do these 3 values refer to?
I want to help, but I need way more context than this haha :sweat_smile:
about the specifics of the approach you're trying, I mean
Very thoughtful writeup Karl, I'm new here, but I got excited when I saw a "Thoughts on UI" thread because it's a topic I have thoughts on :). I'm a Haskell guy at heart, but wind up working on other platforms that have more of a "builder" energy behind them. I got interested in Roc because it's a FP language that is asking a lot of exciting questions. I attended the meetup the other day and I was impressed. I thought a good way to dig in would be to port something I've been experimenting with on Haskell/Rust.
https://github.com/jbrownson/roc-puri
This is my first Roc effort, and the first time I'm discussing Puri publicly. I'm very open to feedback.
I should mention that I don't always think of React as immediate mode as there is a retained DOM, so I may be using "immediate mode" slightly differently than the rest of this thread.
I think the reason UI is such a challenge is that we are tasked with keeping two piles of state synchronized. We have our app data model, and we have the UI data model. This is fairly obvious when talking about retained mode, but even frameworks that claim to be immediate mode do this. A text box needs to know where the cursor is, what's selected, if we're dragging/etc. A scroll view needs to know the scroll offset. That state is being kept somewhere tied to some kind of identity that needs to be stable across frames. The React model was a huge step forward in acknowledging this problem, but the DOM is still inherently stateful, and the structural identity model is fragile. Immediate mode frameworks skip persisting the dom and diffing it, but still use structural identity with hashing to persist UI state as a separate pile of state that requires synchronization.
In Rust there is a lot of exploration of the UI space, but most fall into some kind of diffing on a retained mode model, or immediate mode. I haven't found a model that isn't an attempt at synchronizing two piles of state. You can think of the DOM as a very complex, but widely available retained mode UI framework. A lot of frameworks target the DOM so they don't have to reinvent all the widgets (text boxes are very complicated), but in Rust many are starting to draw to canvas directly as it's more of a native platform. Each of these frameworks has to develop its own drawing style and widget behavior just to innovate on state management/synchronization/identity. Linebender is doing some particularly interesting work in this area, and have done a nice job offering modules instead of one monolithic stack, but their widget library is coupled to a particular state management model.
Puri is a "truly" pure UI model that does not store any state. Each widget is a pure function from a description of the widget including its state to draw calls and an event handler. It is unopinionated about how the state is stored and reconnected in the next frame. You can choose to not make your GUI app two piles of state, but just one pile of state. You can put the UI state of your widgets right inside your data model, so when you remove an item from the list you don't have to remember to remove a <li> from the UI list. If you don't like that you can experiment with automatic state management/synchronization without developing your own widgets.
There are deeper underlying motivations for Puri which I've briefly documented in the repo so I'll omit them here.
Karl said:
I believe that Signals are a better model for Javascript and the DOM.
This is my favorite Evan talk: https://www.youtube.com/watch?v=Agu6jipKfYw
Signals seems to be one of those things that seems simple, but is very foot-gun prone. Do you disagree w/ the conclusions in Evan's talk Karl?
I got sidetracked by Richard's offer to help into designing a proper logging system instead of just the dumb one (copy both before/after) I put in place to prevent Claude from blocking it off. The design process turned into a round of compiler bug hunt/fixing to actually have the design work. I'll have that written up later today.
But while we're both on I'd like to have at least the potential for a conversation.
It might not have come across but I think that the retained versus immediate is a spectrum when it comes to more abstracted environments like the web. In the C/C++ immediate mode systems the view-specific state (the cursor, selection, scroll) needs an identifying anchor but there's no real coordination with the app state. Whatever code is drawing the view simply reads from whatever state it needs and draws to the screen. See Casey Muratori's talk about the general pattern or Nic Barker's intro to Clay for examples:
https://www.youtube.com/watch?v=Z1qyvQsjK5Y
https://www.youtube.com/watch?v=DYWTw19_8r4
Yeah I'm familiar w/ that Casey talk, and I've used egui which is heavily based on those ideas. There is a Roc port of clay in the puri repo :)
To me immediate mode is basically specifically what Casey lays out there, it still is two piles of state. IIRC Casey doesn't specifically specify structural identity, but the library implementations seem to do it that way by default
Immediate mode frameworks skip persisting the dom and diffing it, but still use structural identity with hashing to persist UI state as a separate pile of state that requires synchronization.
The structural identity comparison is purely an optimization driven by the abstraction layers. It's an important optimization when you have a single global app state but not inherent in the model. Faster vdom libraries collapse the static parts of the tree which tends to reduce the comparison to just the control flow nodes which can yield similar results. Nothing prevents both from being put into practice but the HAMT collections are pretty expensive on their own.
well if you are keeping a pile of state attached to a part of an output of a pure function then you need an identity system to reconnect the state every frame, so it's an optimization from other ways of doing that, but it exists because of the two state piles
For what it's worth, I have been tracking Raph's work in Rust for several years. They seem to be kind of exploratory and very much drawing/graphics focused and I do not consider that to be the hard part of UI. I agree with you that the state coordination is the problem.
IIRC Casey talks about like assigning manually chosen identities for things, little names, but that doesn't scale to a general dynamic solution
Yes, Ralph is doing great work in vector graphics, I don't see a lot of novelty in the state management side, he has done a few iterations of it
Linebender is Ralphs organization for all this btw
If you are w/ me on the state coordination problem you might enjoy the motivations document in the repo, esp the incremental lambda calculus/purview stuff
Reading through it now.
I'm going to rewatch Casey's talk to remember exactly how he discusses the state thing, I know he addresses identity, maybe mainly for focus, I feel like he didn't really address the two state problem, but I might be forgetting, been a while
Pretty sure he just handwaves it.
As a point of comparison, here's the current Todo implementation example without the setup/event handlers:
## A row's identity is a `key` ATTR on the row node — the same list the click
## handler goes in.
body : Model, Env -> View(Model)
body = |model, _env|
View.col
.append(View.text("todos (${List.len(model.todos).to_str()} items)"))
.append(
View.row
.append(View.input.on_input(set_draft).value(model.draft).key("new-todo"))
.append(View.button.on_click(add).label("add")),
)
.concat(List.map(model.todos, row_view))
row_view : Todo -> View(Model)
row_view = |todo| {
key = todo_key(todo)
View.row
.key(key)
.append(View.button.on_click(toggle(key)).label(if todo.done { "[x]" } else { "[ ]" }))
.append(View.text(todo.label))
.append(View.button.on_click(remove(key)).label("x"))
}
todo_key : Todo -> Str
todo_key = |todo| "row-${todo.id.to_str()}"
I don't love the .append and pervasive chaining kind of makes everything feel the same in the lisp sense instead of having structure but it's roughly the amount of code/effort I'd expect.
I'm confused, what is that from?
Sorry, this is the immediate-mode todo example from my work-in-progress platform.
Ahh got it, yeah looks very similar to egui
a big problem I have w/ immediate mode gui that is somewhat separate from the two state problem is you wind up mutating the data model halfway through your render, so the second half is rendering a different model, unless you manually defer things
I hit a lot of issues w/ that in my egui thing
This was the bug that was the straw that made me give up on it: https://github.com/emilk/egui/issues/2142
they fixed it by just deferring the event to the next frame, w/ stuff like that you get little frame glitches that make the app feel glitchy
That is one thing this approach doesn't have. All the builders are pure so this producing a tree that the host evaluates.
For my actual project that is underlying the underlying motivation I want to have really fine tuned tab nav, I tried so many different UI platforms and it was broken on all of them, even Apple's core stuff
If you can handle more than one event per frame you're not rendering the wrong thing, but you are running events on something different than you initially intended to potentially, I hit that too
I did notice the very careful focus handling in your todo example.
Btw, yeah if you're comparing my TODO implementation vs one that has automatic UI state management/syncing it will be longer, but if you're building something that tests the boundaries of the automatic state manageent it's much easier to just be explicit about things, and I'd love to see immediate mode state management built against something like Puri so we can have different state models w/o having to reinvent widgets, obviously the existing widget set is for demonstration
It's very tempting to work against the DOM as it's such a refined, portable retained mode GUI, but you're doing the 2 state piles thing then, IMO you need Purview to tame it, but even then the UI deltas are implicit
tab nav is actually very complex, like what if gaining/losing focus mutates the UI/etc, it's one of those things that kinda mostly works, but if you are doing something at all complicated it falls apart and you wish you could just be explicit about it
I make the same arguments about drag and drop where I have my own system that I keep porting to new projects. Whenever you're doing anything drag intensive you pretty much always need to drop "near" something or "off the bottom of the document" or "in the top half" or "preview the drop result" which breaks the more common DOM node is drop target approach. I build a set of hitboxes off the DOM rects on drag and drop test against that which would break if I intentionally wanted to hit a moving node but I've never had a UI that wanted that.
Interesting, yeah, I think it's true about basically every aspect of GUI because we haven't really identified the actual problem of GUI. I haven't done much drag/drop, but I can totally see that being similar
We have a lot of answers, but haven't found the right question
the ILC w/ merging model is the most honest I've seen. I actually sent Phil Freeman the ILC paper which inspired his Purview, I haven't seen it anywhere outside of that
I used to run the Santa Monica Haskell group before I moved back to PNW, and he was a member
Purview probably requires HKT to do the Jet thing, so you can run a function in one monad for the initial computation, and another for the derivative
could do it w/o, but you'd have binds and stuff everywhere, I've tried that for other reasons in Typescript and it's not good
really it's do that's important, could have do for specific monads w/o HKT
Software engineering is just dealing with state so the problem is always state but I've never liked approaches that try to avoid it completely and prefer the functional core/mutable support approach.
I planned on writing a lot more about it but I think the thing that separates the UI domain from others is the pervasive optionality of everything. UI comes down to preference/taste so surprisingly little holds in a way that can be statically encoded.
yeah, it's not a bug if it doesn't make it to state somewhere :)
This is why the advice is always "just reboot it" or if software gets into a glitch we relaunch it, the initial load is a pure function, then we're trying to sync state
One of the reasons I've been having trouble in Roc is that everything is currently very fixed. The only two mechanisms for expressing optionality are lists (which have to be homogenous and allocate) and chaining.
Optionality like Haskell Maybe? Yeah it was jarring to me that there's no Maybe, I think the idiom is that you just make your own anonymous sum types? Puri does that in a couple spots
I think the motivation is that you can use more expressive names
No like having a variable number of children/attributes.
Or optional fields in a struct (which is coming).
ahh okay, the way I'd do that in like Typescript is offer a map/record w/ defaults and you can {...defaults color:blue}
I really don't have a problem with it in other domains but it's been a really heavy constraint on the six overall designs I've attempted in this project.
I will have thoughts about puri but I have to think through consequences so it'll be at least a day or two.
Love it, thanks for taking a look
I was planning to pitch this to the Rust community first as there are a lot of folks thinking about GUI there, but seemed like it would be more fun to do it here first
I should mention that I don't always think of React as immediate mode as there is a retained DOM, so I may be using "immediate mode" slightly differently than the rest of this thread.
I agree, and that's even more evident in React than it is in Elm because of the concept of component-local state...but it's still evident in both because browser Elements have retained-mode state, e.g. whether a dropdown is open or closed. Really nothing in the browser is immediate mode, I'd say. :smile:
Karl said:
One of the reasons I've been having trouble in Roc is that everything is currently very fixed. The only two mechanisms for expressing optionality are lists (which have to be homogenous and allocate) and chaining.
[...] like having a variable number of children/attributes. Or optional fields in a struct (which is coming).
if you want to avoid allocations, then the only way to do it is to have a struct with fields that are sum types - the in-PR optional record fields feature is still sum types at runtime, just different programmer ergonomics
that's true whether it's a variable number of children/attributes or optional fields
I don't think it's feasible (like, at a hardware level) to have tree nodes with a variable number of children and avoid heap allocations (or something with essentially the same characteristics as them) - your best bet to make that cheap would be to make heap allocations cheap by doing them all in an arena
The goal isn't to completely avoid allocations, just to avoid unnecessary ones. I do have a version with the tree described Elm style with a list of attrs and a list of children. This iteration turns the attrs into a struct and just has the children as a list which .append and .concat directly map to. I also feel like the Elm pattern loses a lot of its visual appeal moving from div [] [text "blah"] to div([],[text("blah")]) and when you make it multi-line you wind up with a bunch of floating ),.
yeah that makes sense! Attributes are a weird case when it comes to memory usage. Like if you do them as a record with optional fields, then your total memory usage necessarily involves storing a value for every single attribute for every single node. Like for example, you're storing font size, font face, attributes like weight etc, on every single node, even though almost no nodes will customize that. Same with border etc. And the more attributes you add, the higher your memory usage grows.
in contrast, if you do it as a List of attributes, you have no allocations if the list is empty, and 1 allocation if you have 1 attribute - which on the one hand is a big jump in bookkeeping (unless you're doing arena allocation) but on the other hand is still much gentler on total memory usage, and therefore cache misses, than the approach of records with optional fields
so my personal suspicion is that if you were to benchmark all of them, I'd predict that the one that would perform the best would be heap-allocated list of attributes in an arena, followed by the same thing without an arena, and I think optional record fields would likely perform best in small examples but worst in real-world examples once you had implemented all the attributes you wanted to support, just because the high total memory usage would lead to even more cache misses than the heap allocation scenario
that prediction could be wrong though!
Pushed a bunch of refinements, including drag & drop for Karl :), btw fast builds go off the rails w/ this codebase during specialization, I'm guessing it's the final tagless stuff.
Does off the rails mean it's good or bad? I'm not familiar with that phrase. I assume there is a bug here and it's running slow?
bad, sorry for the Americanism, it just goes up over 10GB of memory and eventually crashes
I'm using a recent nightly
roc --version
Roc compiler version release-fast-b6cdced9
if you can push a link to a commit that reproduces it, I can take a look!
it has done it the whole time, including master HEAD, I've only tested on Mac, not sure what others are running. I can try to find a minimized case first if you like. I've just been dev building it.
I strongly suspect it's my Haskelly style stuff
perfect for flushing out bugs in the compiler :grinning_face_with_smiling_eyes:
To make sure we're talking about the same thing, I'm talking about https://github.com/jbrownson/roc-puri built against Roc compiler version release-fast-b6cdced9, I'm running make -C todo native-speed-run at the root of that repo
Didn't mean to turn this into a bug report, was going to look for a minimal case first hah, but okay
I hit and diagnosed a significant number of specialization/tagging blowups while working on the platform. If it's not too much effort would you try my local-fixes branch of the Roc compiler? I don't think my fixes are good for the long term health of the compiler but I would be interested if they cover your issue.
yeah, making a minimal repro and GH issue is always helpful. :thumbs_up:
sure why not
RocRay was the closest available native platform, but its Roc API did not expose everything needed for ordinary Puri controls: clipboard access, nested clipping, fractional two-axis trackpad scrolling, multi-click counting, minimum window sizing, and control over Raylib's Escape-to-exit behavior.
Would you like me to look at adding these?
I would always be happy to accept PR's too if that works better for you. I'm rolling around just working on the next thing on my list of TODO's
If I go further with this I'd switch to a linebender based platform I think. TBH I find the whole platform thing to be extremely awkward.
No worries, happy to help if you need with setting up a linebender platform foundation etc.
I like that there's a method of dropping into struct of arrays or having a way to work around refcount stuff that doesn't involve going through a C ABI. I'm interested in seeing how the breakdown of platform vs Roc tradeoffs goes.
I initially switched to running dev builds because I hit https://github.com/roc-lang/roc/issues/10317. You guys fixed that super quick though, I was very impressed, but kept running dev until I eventually tried fast and noticed the current problem
How should independently developed packages share platform-provided nominal types and overlapping capability sets?
The intended design is for cross-platform packages which define nominal types and are re-used. An example of this is the https://github.com/roc-lang/http package. However we've been working through various bugs and issues so the full design experiment hasn't really concluded much yet. Hopefully as we see more packages developed and we continue to push these ideas we can validate this or revisit the design.
Higher-kinded abstraction is missing from finally-tagless code
I don't fully grok everything here... but I feel like maybe the where constraints are the solution or something worth exploring around this issue.
Is that from my roc-notes? those are not human reviewed, just had my LLM keep a log as I went to tidy up later, and I'm realizing the thing I might be missing most is do notation actually, I think you might be right that a .plus/+ version of do notation might be what I'm wanting
Directory-qualified local modules are missing in the Zig compiler
filed at https://github.com/roc-lang/roc/issues/10448
Yeah I've been reading your notes and thinking about any issues or things to improve
hah I didn't mean for that to be read yet, but you guys are too quick
not complaining
Groups of method constraints cannot be named
We have talked about maybe supporting aliases for this use-case, and I think it's a great idea... but we decided to see how much of an issue not having it would be in practice
Also just noticed the puri todo app has a serious memory leak, like 10GB after 10m
could be a bug in the app I suppose, closing over stuff it shouldn't? my LLM doesn't think so
Sort(a) : a where [a.order : a, a -> Ordering]
sort : List(elem) -> List(elem) where [elem.Sort]
Or maybe... we decided to add that and it just never got fully implemented? I can't remember where we got too with that
I saw something about a historical feature, maybe pre-zig
obviously in Haskell I would use typeclasses
or Rust traits
Karl said:
would you try my
local-fixesbranch of the Roc compiler?
No noticeable difference building the todo demo w/ this branch.
Jake Brownson said:
just noticed the puri todo app has a serious memory leak, like 10GB after 10m
I haven't filed the bug but at least for the Rust glue top level lists leak their contents. There's a plan for it in src/glue/src/RustGlue.roc:2109 but that only works if a RocList is wrapped in something else. The LLM says:
A top-level returned
RocList<T>isn't a field or a payload of anything, and glue mints no named type for it — so there's noimplto hang adecrefon, and the correct code generator is simply never invoked.
Richard Feldman said:
I want to help, but I need way more context than this
I originally picked something simple to fill the goal and avoid designing something that couldn't be fully logged. I read this a couple hours after it was posted and thought that if I'm going to get help I'll attempt a proper design and see what breaks. This led to some compiler bugs and Claude claiming that logging is impossible so the process took longer than expected but here's the design in LLM overview style.
The TL;DR is that I want something toggleable but fast enough to leave on for general use. My expected use case is "where did this go wrong" so state modifications log which handler is making the change and the old values of the changed field contents to a ring buffer. These are numbers+bytes and the contents are formatted into strings on read.
Given the history of Roc I've been generally following Elm architecture decisions when doing an immediate mode design so handlers get a lensed view of their local model using a meta wrapper. I'm using that point for logging and the largest problem was detecting what the handler has changed. I'm currently doing this using the formatting infrastructure (Claude: can't implement this design because Roc doesn't have row level polymorphism), my encoder just happens to serialize changed field ids. This caused most of the delay because a consequence of the previously linked #10426 is that it makes encoding accidentally quadratic with respect to size so I had to get a local workaround to see things working. In the process I found out that Roc doesn't seem to have a way to do reference comparison (JS ===) and the fast path for comparing strings gets dropped outside of dev builds (#10444).
Perf was a focus throughout and the ring buffer is sized to 16MB. My two working sample apps are a sqlite db viewer (dbx) and a port of the roc-signals Realworld demo. The logging overhead in Realworld (which doesn't have a lot of app state) maxes out at 0.26 ms and for dbx with a 5 (scalar) col x 5000 row the highest I saw was 10.2ms. I decided that was too high and added a way to opt into which fields log and the remaining state is sub ms.
I am particularly interested in a better solution for "what changed" the result is okay here but it's still a full walk of the model.
Given the history of Roc I've been generally following Elm architecture decisions
ah, so I suspect this is going to be a recurring stumbling block unfortunately :sweat_smile:
Elm Architecture relies pretty heavily (imo) on the assumption of persistent data structures for performance, and Roc doesn't have persistent data structures.
I concluded years ago based on this that Elm Architecture wouldn't work well in Roc and we'd need to take a different approach that fits Roc's opportunistic mutation design better
The reason I have a big writeup about vdom being immediate is because TEA and friends can be dropped directly onto an immediate mode library (Clay) and the design works. C can draw a lot of boxes/text to a framebuffer so it's only too slow if the substrate is the DOM.
I'm pretty sure it can be done at speed on the DOM as well without persistent datastructures. There are vdom frameworks in the js frameworks benchmark that are competitive with the fastest signals ones. The basic trick is to compress/skip the static parts of the vdom tree so the diff boils down to the conditionals and leaves, which is roughly on par with what a signals framework tracks. If there's an efficient "what changed" primitive then that takes care of the primary use case for persistent datastructures in vdom systems, they're just used to prune the rendering tree.
Claude: can't implement this design because Roc doesn't have row level polymorphism
aside: Roc uses row level polymorphism constantly - e.g. every record and tag union have it :laughter_tears:
anyway, for the logging - what's the format you're using for the logs? is it just raw strings or something else?
I ask because if you use .inspect() to turn the "before" into a string, that shouldn't increase the refcount at all
I'm pretty sure (there were a LOT of go-arounds) the format is frame number, a handler id, a bitfield of changed fields, and the bytes of the old values or the whole row if over half is changed or the capture cost goes over a threshold.
It gets fomatted as readable strings on dump (when the program crashes or it's requested)
ah, have you tried formatting them as strings immediately and only storing those?
I think that should prevent the original data from becoming shared
The original version was doing copies of values and that probably would have worked. This one avoids it by doing the diffing on the Rust side. I had a variant where the diffing was done on the Roc side but that required two collections in the capture which hit the #10426 bug and turned the capture quadratic.
copies of values would work too, but eagerly making the strings (assuming you have logging enabled; if it's disabled of course I'd expect it to be a no-op) sounds more efficient to me because it avoids cloning the values only to turn them into strings later
should save a bunch of allocations to just make the string right there and that's it
I did it this way because the plan is to just leave it running and when something goes wrong tap into it and get the logged state transitions. I can double check but I thought the bytes were going into a fixed size ringbuffer so there isn't any allocation.
Karl said:
Rust glue top level lists leak their contents.
I don't believe there is any Rust in the puri todo app FYI. I do notice this thread just popped up, could be related
I mentioned it because that was my large leak and then double checked and saw you were in C where that doesn't apply.
Karl said:
I thought the bytes were going into a fixed size ringbuffer so there isn't any allocation.
I think the issue (if I understand it correctly) is that if you take the previous state (or parts of it) and send it to the host (or store it anywhere in Roc code) then that bumps its refcount because something else is holding onto it, making it ineligible for in-place mutation
if that's the issue, then either after the host copies the bytes to the ring buffer (assuming copying bytes is what it's doing) then the host should decrement the refcount so it can become eligible for in-place mutation again, or else on the roc side turn it into a string so its refcount never gets bumped in the first place
Jake Brownson said:
To make sure we're talking about the same thing, I'm talking about https://github.com/jbrownson/roc-puri built against
Roc compiler version release-fast-b6cdced9, I'm runningmake -C todo native-speed-runat the root of that repo
this turned out to surface 4 different bugs which work together to cause problems in this example. Fixes may take a little bit (maybe a day or two?) but thanks for the repro! :smiley:
Richard Feldman said:
if that's the issue
I had two separate issues and they're both reported. The one I'm mentioning to Jake is a glue issue: #10451 while the general one is #10426 which I understand as "a write increfs the record and that borrow (?) doesn't get decref'd until the last field so only the last field can write in place". I did have some issues with trying to avoid allocs in the dumb design by holding onto the original state and therefore keeping the reference count high and your .inspect() suggestion would have been good for that.
Richard Feldman said:
this turned out to surface 4 different bugs
I had Fable churning while I was in the hot tub to find a minimal repro and it didn't, that's probably why
ahh yeah I have a fix for the record issue incoming!
ok, record issue is fixed, and also @Jake Brownson the bugs I mentioned earlier that were making roc-puri builds pathological are also fixed!
I appreciate your prompt efforts. I'll move to head tomorrow.
I updated and confirmed the fixes. One of the files I had lying around trying to minimize happens to segfault the compiler so I submitted it: https://github.com/roc-lang/roc/issues/10527
I was hoping the speed build would improve the perf of the todo app more, not sure if it's the vibe-coded clay port or something dumb, but I'm going to leave it there and switch my focus back to my primary project, I got a nice sense of Roc and will definitely keep my eye on it moving forward
What kind of perf are you seeing? I'm getting a peak 2.3ms on a frame with 500 todos.
huh, mine stops holding 60fps once I get like 3, I'm on a mac
and my baseline is 90ns on a frame.
Maybe the large number of continuations?
yeah figured something like that, my knowledge in this area is super shallow but I know Haskell does a lot to make those things reasonably fast, but strange you're seeing different perf on the same code?
Sorry, not on your code. On my platform where the clay port is in Rust and Roc is producing a tree of draw instructions.
ahh yeah, I couldn't just use the Rust clay bindings because I wanted the contiuation based rendering, so yeah probably something Haskelly I'm doing, or just something dumb in the vibe coded sections
I've read through your source and have been trying your approach to widgets on my platform.
the clay port is 100% vibe, but backed by an oracle against the original C version for confidence it's reasonably correct at least
I spend a lot more time thinking about how to express abstractions honestly than I do about perf, so I make no claims that my model is good for perf, but I don't see any reason it's worse than others for any fundamental reasons
would benefit from caching/partial inval/incremental
I was really pleased how the drag/drop came out, some nice combinators popped out
I'll actually build+run your setup this afternoon and report back on the reason.
My guess is that it's the refcounted garbage collection. Your design probably has a whole lot of counters being worked.
yeah, probably right
I'll have to see how your drag/drop combinators compare to my normal set.
yeah, not something I've thought about before, but at the end of the day it's just a function + state like anything else. I also added a lot of nuance to how the clicking interacts w/ the todo editing that would be very difficult in traidtional UI
like double clicking starts the edit, but we intiailize the text box as though a single click happened, but since the text moves we make a dead zone until drag starts
can't do that if the click state is all internal and implicit
that's the kind of thing that makes an app feel super refined
I've been slow about getting back to you because I've been bogged down in getting the style system ready for a real widgets push (gradients, shadows, images, border effects, etc).
no worries at all, I had fun digging into it
Jake Brownson said:
I was hoping the speed build would improve the perf of the todo app more
Turns out it wasn't anything in your design, though it is allocation heavy compared to the id approach.
Perf pass says 96% of the time producing a frame was spent manipulating memory. The roc-ray platform you grabbed is using a debug allocator compiled ReleaseSafe so it's capturing a stacktrace for all ~500 malloc/realloc/free calls into the platform each frame. Switching to a ReleaseFast build of roc-ray stops the stackframe build and fixes the problem at a ~35x speedup.
Switching the platform over to the smp_allocatorinstead of the debug allocator gets a 45x speedup and yields 93 todos at 60fps.
Ahh interesting, yeah the whole platform thing was all the LLM
My Rust version is all based on Linebender, if I were to take this further on Roc I'd probably do a Linebender based platform
documented this finding in the repo, thanks for taking a look
That was also with a dev build. A release build does complete on my machine and is 13x faster and is 568 todos at 60 fps.
wow nice
That's kind of why I was wondering because IME Roc is pretty fast.
Karl said:
Jake Brownson said:
I was hoping the speed build would improve the perf of the todo app more
Turns out it wasn't anything in your design, though it is allocation heavy compared to the id approach.
Perf pass says 96% of the time producing a frame was spent manipulating memory. The
roc-rayplatform you grabbed is using a debug allocator compiledReleaseSafeso it's capturing a stacktrace for all ~500 malloc/realloc/free calls into the platform each frame. Switching to aReleaseFastbuild ofroc-raystops the stackframe build and fixes the problem at a ~35x speedup.Switching the platform over to the
smp_allocatorinstead of the debug allocator gets a 45x speedup and yields 93 todos at 60fps.
Luke made a series of PR to roc-ray for 0.8.3 that includes your exact remarks and it does make a big difference
https://github.com/lukewilliamboswell/roc-ray/pull/121
https://github.com/lukewilliamboswell/roc-ray/pull/123
Also this one brings perf benefits related to fewer allocations and more
https://github.com/lukewilliamboswell/roc-ray/pull/126
All in all, terrocotta is now reaching >1200fps on the toy examples.
Glad it was helpful. I think it's fun that all the GUI work is converged onto TEA running on Clay.
Jake Brownson said:
I spend a lot more time thinking about how to express abstractions honestly than I do about perf, so I make no claims that my model is good for perf, but I don't see any reason it's worse than others for any fundamental reasons
I took a quick look at roclay. I think one main difference is that even though roclay does not use the clay's original flat layout data structure. You have a nested struct which is not going to be as friendly to the CPU cache and you won't be able to reuse that allocation from frame to frame. So even though you have the same algorithm, it walks a fundamentally different data structure which is going to be slower. You can read here how I was able to use a nested declarative syntax, yet produce a flat layout for terrocotta in this architecture document https://github.com/obust/terrocotta/blob/main/docs/architecture.md#view
On the abstraction side, I think your "around" is quite interesting. I am curious to see how various UI features build on top of that. I have a somewhat similar plan for "middleware" on the rendering pipeline. However it is going to be fundamentally more limited since it applies only to the post solve layout tree https://github.com/obust/terrocotta/issues/37
also in terms of perf, text measurement caching is critical. I did not check if roclay/puri does caching but it made a big difference for terrocotta
I'm split differently than terrocotta and roclay in that my Clay port is in Rust and the Roc side sends a tree across the host boundary. The rationale is that I can potentially replace the host with one that implements a vdom and get to HTML that way.
Most of my effort has gone into the platform/runtime so I have a substantial portion of Tailwind implemented using a typed builder (borders, opacity, gradients, images, box shadows, text decoration, etc), an animation framework, a state diffing logging system for LLM debugging, etc. I think I'm pretty close to the point where I'm back to doing the JS framework equivalent stuff instead of doing the browser equivalent stuff.
Just this weekend I got far enough to get the main view of my transcription app ported over. The tauri version has had multiple rounds of perf work done with the goal of maintaining 100ms responsiveness over a 50k word transcript. Roc version got the accidental quadratics out of the model and is 4ms on an 80k transcript.
That's interesting. You might want to send ElementOp messages instead of the actual tree over the host boundary. This way in the case of native apps, you are not forced to build the actual tree which would hurt performance
https://github.com/obust/terrocotta/blob/main/docs/architecture.md#view
In the case of web, the platform can build the VDOM and diff it (or diff the ElementOp stream directly?)
I haven't looked at the code, but quick note that nested records should get flattened into one contiguous chunk of memory
there may be other perf tradeoffs related to how the records are organized, but nesting alone shouldn't cause a significant problem compared to having all the fields of both records in one big field; both scenarios should have zero heap allocations from the records themselves
Romain Lepert said:
you won't be able to reuse that allocation from frame to frame
My knowledge on clay is limited to the famous video about its algorithm. I haven't studied the original C code nor the vibe coded, but oracle verified ports. That said my understanding is the idea behind clay is to redo the calculation every frame in support of "immediate mode" UI. I guess you specifically said the _allocation_ though, so yeah might make sense to do some kind of arena thing? This is not my area of expertise, my core idea here is independent of how layout is done and I just needed _something_ to create an artifact, and from a user perspective I think there is a lot of benefit in the continuation based clay as it doesn't require an identity system. I make no claims about the performance costs or benefits of that.
Thanks for taking a look btw.
right, but here we nest using a list for children because each element might have an arbitrary number of children.
Suppose you have this hypothetical nested UI
box(Id("card"), { width: 300, height: 200 }, [
box(Id("header"), { width: 300, height: 40 }, [
box(Id("profile"), { width: 200, height: 40 }, []),
box(Id("actions"), { width: 100, height: 40 }, []),
]),
box(Id("body"), { width: 300, height: 160 }, []),
])
So here is the kind of simulated DRAM memory layout you get with nested tree vs flat contiguous list
| DRAM address | Node record |
|---|---|
| 40 | { id: "card", width: 300, height: 200, children: { ptr: 120, len: 2 } } |
| 120 | { id: "header", width: 300, height: 40, children: { ptr: 480, len: 2 } } |
| 121 | { id: "body", width: 300, height: 160, children: { ptr: 0, len: 0 } } |
| 480 | { id: "profile", width: 200, height: 40, children: { ptr: 0, len: 0 } } |
| 481 | { id: "actions", width: 100, height: 40, children: { ptr: 0, len: 0 } } |
node list
| DRAM address | nodes index |
Inline LayoutNode record |
|---|---|---|
| 120 | 0 | { id: "card", width: 300, height: 200, parent: none, child_start: 2, child_count: 2 } |
| 121 | 1 | { id: "header", width: 300, height: 40, parent: 0, child_start: 0, child_count: 2 } |
| 122 | 2 | { id: "profile", width: 200, height: 40, parent: 1, child_start: 0, child_count: 0 } |
| 123 | 3 | { id: "actions", width: 100, height: 40, parent: 1, child_start: 0, child_count: 0 } |
| 124 | 4 | { id: "body", width: 300, height: 160, parent: 0, child_start: 0, child_count: 0 } |
relationship list
| DRAM address | child_indices index |
Stored U64 |
Target node address |
|---|---|---|---|
| 600 | 0 | 2 | 122 |
| 601 | 1 | 3 | 123 |
| 602 | 2 | 1 | 121 |
| 603 | 3 | 4 | 124 |
Now the real value is two fold:
here are the clay layout solve passes and their corresponding traversal
| Pass | Traversal |
|---|---|
| Order floating roots by attachment dependency | DFS |
| Resolve X sizes | DFS |
| Wrap text | Linear forward |
| Refresh intrinsic sizes | Linear reverse |
| Resolve Y sizes | DFS |
| Update content sizes | Linear forward |
| Position nodes | DFS |
Nested tree memory access pattern
Flat contiguous memory access pattern
Because the memory access pattern is super simple you get
120 brings its surrounding cache line into L1. That cache line maybe contain however many subsequent nodesRomain Lepert said:
I think your "around" is quite interesting
Yeah that was a nice thing that fell out of this. It works well for the fine-tuned "double click to edit" functionality. I think having combinators like that is a side effect of the core idea of truly pure UI widgets.
Romain Lepert said:
So here is the kind of simulated DRAM memory layout you get with nested tree vs flat contiguous list
interesting analysis. I'm not a perf guy, and roclay was just a thing needed to demo the idea, not surprised there is a lot of room for improvement.
Jake Brownson said:
Romain Lepert said:
you won't be able to reuse that allocation from frame to frame
My knowledge on clay is limited to the famous video about its algorithm. I haven't studied the original C code nor the vibe coded, but oracle verified ports. That said my understanding is the idea behind clay is to redo the calculation every frame in support of "immediate mode" UI. I guess you specifically said the _allocation_ though, so yeah might make sense to do some kind of arena thing? This is not my area of expertise, my core idea here is independent of how layout is done and I just needed _something_ to create an artifact, and from a user perspective I think there is a lot of benefit in the continuation based clay as it doesn't require an identity system. I make no claims about the performance costs or benefits of that.
Thanks for taking a look btw.
Yes clay does one static arena allocation and only uses that. In terrocotta I use the standard List allocation which copies to a bigger buffer when it reaches the limit. I pay the price of copies in the first few frames but eventually the number of nodes becomes stable and there is no new allocation.
I'm not familiar with terrocotta, and not finding it on google, got a link?
oh I see one above
yes sorry, I mentioned it here #show and tell > Terrocotta
how do you track UI state like cursor position, scroll offset, focus?
I'll read your show and tell
I guess my point is that immediate mode UI requires a fast layout+render logic because you want to keep as much 8ms computing budget (120 fps) to your business logic. The way clay achieves this is through the data layout and exploiting this layout with a DFS solver. Without the data layout you loose most of the perf of clay
I'm not sure I understand what you mean by data layout, are you saying there is something fundamentally costly about the continuation API modification?
Are you talking about retaining/caching the layout between frames?
Not a perf guy so I may be missing something obvious
Jake Brownson said:
how do you track UI state like cursor position, scroll offset, focus?
I don't see this mentioned in the architecture.md. To me this is the most important question from a usability standpoint, we tend to hand-wave this and try to not treat it as state, which is the core idea in puri
The flat representation is critical for performance
Ahh okay, I see what you're saying in the layout thing, sounds like your clay port is way more thoughtful about perf than mine
again, my roclay is just vibe coded w/ an oracle to verify output vs the C version, not claiming any innovation there
The main thing Puri benefits from is not requiring stable identities across frames for UI elements, but might be able to do your perf stuff w/ a continuation thing too, might need some heap allocations though
Puri is not an answer to the full stack, it's the idea of separating state management from widget behavior/rendering, so maybe your layout work could pair w/ it
no, not the continuation API in particular. The problem is the nested tree structure. It is not good for the layout solver.
The example gives those DRAM memory access pattern:
Nested tree memory access pattern
- DFS: 40 → 120 → 480 → 481 → 121
- linear forward: 40 → 120 → 480 → 481 → 121
Flat contiguous memory access pattern
- DFS: node 120 (→ edge 602) → node 121 (→ edge 600) → node 122 (→ edge 601) → node 123 (→ edge 603) → node 124
- linear: 120 → 121 → 122 → 123 → 124
You see how in the second case the pattern is super simple. That means CPU can predict it and prefetch in advance. Then your CPU is doing pure compute and hides away all the loading from DRAM. That is what makes the Layout solve fast.
Are you talking about retaining/caching the layout between frames?
And yes, that is the second reason the clay approach is fast. you never re-allocate for your layout.
if you pair Puri w/ automatic state management/id system like retained or immediate/react style structural identity then you don't need the continuation based layout
Romain Lepert said:
you never re-allocate for your layout.
I'm still a bit confused here, you specifically are saying re-allocate, but do you mean re-compute the layout? I thought clay was designed to re-compute the layout every frame in the same way immediate model UI re-renders every frame as opposed to retained mode.
I could see a more thoughtful memory management approach where we re-compute the layout every frame, but don't re-allocate its memory and use some kind of arena thing.
I'm unfamiliar with the DRAM pattern ideas you're referencing, but I can def imagine a contiguous/flat allocation being more efficient.
how do you track UI state like cursor position, scroll offset, focus?
so this is my state that I keep between frames
https://github.com/obust/terrocotta/blob/main/package/Program.roc#L83
it stores not only the layout, but also some UI state like which nodes are hovered, focused, what is their scroll offset.
the whole UI runtime loop is here and pretty readable
https://github.com/obust/terrocotta/blob/main/package/Program.roc#L137
focused is a U64 which implies there is some kind of identity system to assign stable identities to widgets? Is it structural like React/most immediate mode things?
Where do we store cursor position in a text box? Do we only support one top-level scroll?
I could see a more thoughtful memory management approach where we re-compute the layout every frame, but don't re-allocate its memory and use some kind of arena thing.
yes, that is exactly what is happening
Awesome, yeah this is exactly why I think something like Puri is useful, though I included roclay in the repo it's not part of puri itself, if we want to innovate on layout or state management with something like Puri we don't have to reinvent widgets
Puri doesn't include many widgets of course as it's just a demo if the idea, but if we invested in building out widgets once we don't have to do it for every new state management or layout experiment
I don't claim the roclay name btw, it's a pretty great name for a roc clay port, someone is welcome to take it if they do a serious port vs my slop port
though I would encourage any clay port to a functional language to use the continuation modification to the API
Last updated: Aug 12 2026 at 12:35 UTC