Stream: ideas

Topic: Thoughts on UI


view this post on Zulip Karl (Jul 27 2026 at 05:11):

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.

view this post on Zulip Karl (Jul 27 2026 at 05:12):

The LLM in the room

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.

view this post on Zulip Matthieu Pizenberg (Jul 27 2026 at 07:53):

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:

view this post on Zulip Anton (Jul 27 2026 at 12:37):

I love elm-ui too

view this post on Zulip Richard Feldman (Jul 27 2026 at 12:44):

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?

view this post on Zulip Richard Feldman (Jul 27 2026 at 12:48):

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?

view this post on Zulip Karl (Jul 27 2026 at 17:53):

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.

view this post on Zulip Karl (Jul 27 2026 at 18:17):

The Grand Split: Retained versus Immediate

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.

view this post on Zulip Karl (Jul 27 2026 at 20:34):

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

view this post on Zulip Richard Feldman (Jul 27 2026 at 21:49):

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?

view this post on Zulip Richard Feldman (Jul 27 2026 at 21:49):

I want to help, but I need way more context than this haha :sweat_smile:

view this post on Zulip Richard Feldman (Jul 27 2026 at 21:49):

about the specifics of the approach you're trying, I mean

view this post on Zulip Jake Brownson (Jul 28 2026 at 17:33):

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.

view this post on Zulip Jake Brownson (Jul 28 2026 at 17:42):

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?

view this post on Zulip Karl (Jul 28 2026 at 17:51):

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.

view this post on Zulip Karl (Jul 28 2026 at 18:00):

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

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:02):

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 :)

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:03):

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

view this post on Zulip Karl (Jul 28 2026 at 18:05):

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.

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:07):

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

view this post on Zulip Karl (Jul 28 2026 at 18:08):

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.

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:09):

IIRC Casey talks about like assigning manually chosen identities for things, little names, but that doesn't scale to a general dynamic solution

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:09):

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

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:09):

Linebender is Ralphs organization for all this btw

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:10):

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

view this post on Zulip Karl (Jul 28 2026 at 18:11):

Reading through it now.

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:14):

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

view this post on Zulip Karl (Jul 28 2026 at 18:14):

Pretty sure he just handwaves it.

view this post on Zulip Karl (Jul 28 2026 at 18:23):

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.

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:25):

I'm confused, what is that from?

view this post on Zulip Karl (Jul 28 2026 at 18:26):

Sorry, this is the immediate-mode todo example from my work-in-progress platform.

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:26):

Ahh got it, yeah looks very similar to egui

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:28):

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

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:28):

I hit a lot of issues w/ that in my egui thing

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:28):

This was the bug that was the straw that made me give up on it: https://github.com/emilk/egui/issues/2142

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:29):

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

view this post on Zulip Karl (Jul 28 2026 at 18:29):

That is one thing this approach doesn't have. All the builders are pure so this producing a tree that the host evaluates.

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:29):

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

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:30):

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

view this post on Zulip Karl (Jul 28 2026 at 18:31):

I did notice the very careful focus handling in your todo example.

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:32):

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

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:34):

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

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:35):

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

view this post on Zulip Karl (Jul 28 2026 at 18:41):

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.

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:42):

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

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:43):

We have a lot of answers, but haven't found the right question

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:44):

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

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:44):

I used to run the Santa Monica Haskell group before I moved back to PNW, and he was a member

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:45):

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

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:46):

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

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:46):

really it's do that's important, could have do for specific monads w/o HKT

view this post on Zulip Karl (Jul 28 2026 at 18:49):

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.

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:50):

yeah, it's not a bug if it doesn't make it to state somewhere :)

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:51):

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

view this post on Zulip Karl (Jul 28 2026 at 18:52):

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.

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:52):

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

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:53):

I think the motivation is that you can use more expressive names

view this post on Zulip Karl (Jul 28 2026 at 18:53):

No like having a variable number of children/attributes.

view this post on Zulip Karl (Jul 28 2026 at 18:53):

Or optional fields in a struct (which is coming).

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:53):

ahh okay, the way I'd do that in like Typescript is offer a map/record w/ defaults and you can {...defaults color:blue}

view this post on Zulip Karl (Jul 28 2026 at 18:54):

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.

view this post on Zulip Karl (Jul 28 2026 at 18:55):

I will have thoughts about puri but I have to think through consequences so it'll be at least a day or two.

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:56):

Love it, thanks for taking a look

view this post on Zulip Jake Brownson (Jul 28 2026 at 18:56):

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

view this post on Zulip Richard Feldman (Jul 28 2026 at 19:06):

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:

view this post on Zulip Richard Feldman (Jul 28 2026 at 19:12):

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

view this post on Zulip Richard Feldman (Jul 28 2026 at 19:12):

that's true whether it's a variable number of children/attributes or optional fields

view this post on Zulip Richard Feldman (Jul 28 2026 at 19:13):

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

view this post on Zulip Karl (Jul 28 2026 at 19:21):

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 ),.

view this post on Zulip Richard Feldman (Jul 28 2026 at 20:33):

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.

view this post on Zulip Richard Feldman (Jul 28 2026 at 20:34):

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

view this post on Zulip Richard Feldman (Jul 28 2026 at 20:37):

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

view this post on Zulip Richard Feldman (Jul 28 2026 at 20:37):

that prediction could be wrong though!

view this post on Zulip Jake Brownson (Jul 28 2026 at 23:12):

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.

view this post on Zulip Luke Boswell (Jul 28 2026 at 23:14):

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?

view this post on Zulip Jake Brownson (Jul 28 2026 at 23:15):

bad, sorry for the Americanism, it just goes up over 10GB of memory and eventually crashes

view this post on Zulip Jake Brownson (Jul 28 2026 at 23:15):

I'm using a recent nightly

view this post on Zulip Jake Brownson (Jul 28 2026 at 23:16):

roc --version
Roc compiler version release-fast-b6cdced9

view this post on Zulip Richard Feldman (Jul 28 2026 at 23:31):

if you can push a link to a commit that reproduces it, I can take a look!

view this post on Zulip Jake Brownson (Jul 28 2026 at 23:32):

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.

view this post on Zulip Jake Brownson (Jul 28 2026 at 23:33):

I strongly suspect it's my Haskelly style stuff

view this post on Zulip Luke Boswell (Jul 28 2026 at 23:35):

perfect for flushing out bugs in the compiler :grinning_face_with_smiling_eyes:

view this post on Zulip Jake Brownson (Jul 28 2026 at 23:35):

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

view this post on Zulip Jake Brownson (Jul 28 2026 at 23:37):

Didn't mean to turn this into a bug report, was going to look for a minimal case first hah, but okay

view this post on Zulip Karl (Jul 28 2026 at 23:39):

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.

view this post on Zulip Luke Boswell (Jul 28 2026 at 23:39):

yeah, making a minimal repro and GH issue is always helpful. :thumbs_up:

view this post on Zulip Jake Brownson (Jul 28 2026 at 23:39):

sure why not

view this post on Zulip Luke Boswell (Jul 28 2026 at 23:40):

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

view this post on Zulip Jake Brownson (Jul 28 2026 at 23:41):

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.

view this post on Zulip Luke Boswell (Jul 28 2026 at 23:43):

No worries, happy to help if you need with setting up a linebender platform foundation etc.

view this post on Zulip Karl (Jul 28 2026 at 23:45):

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.

view this post on Zulip Jake Brownson (Jul 28 2026 at 23:46):

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

view this post on Zulip Luke Boswell (Jul 28 2026 at 23:57):

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.

view this post on Zulip Luke Boswell (Jul 29 2026 at 00:00):

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.

view this post on Zulip Jake Brownson (Jul 29 2026 at 00:07):

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

view this post on Zulip Luke Boswell (Jul 29 2026 at 00:08):

Directory-qualified local modules are missing in the Zig compiler

filed at https://github.com/roc-lang/roc/issues/10448

view this post on Zulip Luke Boswell (Jul 29 2026 at 00:08):

Yeah I've been reading your notes and thinking about any issues or things to improve

view this post on Zulip Jake Brownson (Jul 29 2026 at 00:08):

hah I didn't mean for that to be read yet, but you guys are too quick

view this post on Zulip Jake Brownson (Jul 29 2026 at 00:09):

not complaining

view this post on Zulip Luke Boswell (Jul 29 2026 at 00:12):

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

view this post on Zulip Jake Brownson (Jul 29 2026 at 00:12):

Also just noticed the puri todo app has a serious memory leak, like 10GB after 10m

view this post on Zulip Jake Brownson (Jul 29 2026 at 00:13):

could be a bug in the app I suppose, closing over stuff it shouldn't? my LLM doesn't think so

view this post on Zulip Luke Boswell (Jul 29 2026 at 00:13):

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

view this post on Zulip Jake Brownson (Jul 29 2026 at 00:13):

I saw something about a historical feature, maybe pre-zig

view this post on Zulip Jake Brownson (Jul 29 2026 at 00:13):

obviously in Haskell I would use typeclasses

view this post on Zulip Jake Brownson (Jul 29 2026 at 00:14):

or Rust traits

view this post on Zulip Jake Brownson (Jul 29 2026 at 00:31):

Karl said:

would you try my local-fixes branch of the Roc compiler?

No noticeable difference building the todo demo w/ this branch.

view this post on Zulip Karl (Jul 29 2026 at 00:43):

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 no impl to hang a decref on, and the correct code generator is simply never invoked.

view this post on Zulip Karl (Jul 29 2026 at 02:54):

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.

view this post on Zulip Richard Feldman (Jul 29 2026 at 03:18):

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.

view this post on Zulip Richard Feldman (Jul 29 2026 at 03:19):

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

view this post on Zulip Karl (Jul 29 2026 at 03:22):

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.

view this post on Zulip Richard Feldman (Jul 29 2026 at 03:24):

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:

view this post on Zulip Richard Feldman (Jul 29 2026 at 03:25):

anyway, for the logging - what's the format you're using for the logs? is it just raw strings or something else?

view this post on Zulip Richard Feldman (Jul 29 2026 at 03:25):

I ask because if you use .inspect() to turn the "before" into a string, that shouldn't increase the refcount at all

view this post on Zulip Karl (Jul 29 2026 at 03:27):

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.

view this post on Zulip Karl (Jul 29 2026 at 03:28):

It gets fomatted as readable strings on dump (when the program crashes or it's requested)

view this post on Zulip Richard Feldman (Jul 29 2026 at 03:34):

ah, have you tried formatting them as strings immediately and only storing those?

view this post on Zulip Richard Feldman (Jul 29 2026 at 03:35):

I think that should prevent the original data from becoming shared

view this post on Zulip Karl (Jul 29 2026 at 03:39):

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.

view this post on Zulip Richard Feldman (Jul 29 2026 at 03:54):

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

view this post on Zulip Richard Feldman (Jul 29 2026 at 03:54):

should save a bunch of allocations to just make the string right there and that's it

view this post on Zulip Karl (Jul 29 2026 at 03:57):

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.

view this post on Zulip Jake Brownson (Jul 29 2026 at 04:09):

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

view this post on Zulip Karl (Jul 29 2026 at 04:18):

I mentioned it because that was my large leak and then double checked and saw you were in C where that doesn't apply.

view this post on Zulip Richard Feldman (Jul 29 2026 at 04:21):

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

view this post on Zulip Richard Feldman (Jul 29 2026 at 04:22):

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

view this post on Zulip Richard Feldman (Jul 29 2026 at 04:23):

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 running make -C todo native-speed-run at 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:

view this post on Zulip Karl (Jul 29 2026 at 04:32):

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.

view this post on Zulip Jake Brownson (Jul 29 2026 at 04:33):

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

view this post on Zulip Richard Feldman (Jul 29 2026 at 04:37):

ahh yeah I have a fix for the record issue incoming!

view this post on Zulip Richard Feldman (Jul 30 2026 at 04:07):

ok, record issue is fixed, and also @Jake Brownson the bugs I mentioned earlier that were making roc-puri builds pathological are also fixed!

view this post on Zulip Karl (Jul 30 2026 at 04:08):

I appreciate your prompt efforts. I'll move to head tomorrow.

view this post on Zulip Jake Brownson (Aug 01 2026 at 19:57):

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

view this post on Zulip Jake Brownson (Aug 01 2026 at 19:58):

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

view this post on Zulip Karl (Aug 01 2026 at 19:59):

What kind of perf are you seeing? I'm getting a peak 2.3ms on a frame with 500 todos.

view this post on Zulip Jake Brownson (Aug 01 2026 at 20:00):

huh, mine stops holding 60fps once I get like 3, I'm on a mac

view this post on Zulip Karl (Aug 01 2026 at 20:00):

and my baseline is 90ns on a frame.

Maybe the large number of continuations?

view this post on Zulip Jake Brownson (Aug 01 2026 at 20:01):

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?

view this post on Zulip Karl (Aug 01 2026 at 20:02):

Sorry, not on your code. On my platform where the clay port is in Rust and Roc is producing a tree of draw instructions.

view this post on Zulip Jake Brownson (Aug 01 2026 at 20:03):

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

view this post on Zulip Karl (Aug 01 2026 at 20:03):

I've read through your source and have been trying your approach to widgets on my platform.

view this post on Zulip Jake Brownson (Aug 01 2026 at 20:03):

the clay port is 100% vibe, but backed by an oracle against the original C version for confidence it's reasonably correct at least

view this post on Zulip Jake Brownson (Aug 01 2026 at 20:05):

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

view this post on Zulip Jake Brownson (Aug 01 2026 at 20:05):

would benefit from caching/partial inval/incremental

view this post on Zulip Jake Brownson (Aug 01 2026 at 20:06):

I was really pleased how the drag/drop came out, some nice combinators popped out

view this post on Zulip Karl (Aug 01 2026 at 20:06):

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.

view this post on Zulip Jake Brownson (Aug 01 2026 at 20:06):

yeah, probably right

view this post on Zulip Karl (Aug 01 2026 at 20:07):

I'll have to see how your drag/drop combinators compare to my normal set.

view this post on Zulip Jake Brownson (Aug 01 2026 at 20:08):

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

view this post on Zulip Jake Brownson (Aug 01 2026 at 20:09):

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

view this post on Zulip Jake Brownson (Aug 01 2026 at 20:09):

can't do that if the click state is all internal and implicit

view this post on Zulip Jake Brownson (Aug 01 2026 at 20:09):

that's the kind of thing that makes an app feel super refined

view this post on Zulip Karl (Aug 01 2026 at 20:10):

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).

view this post on Zulip Jake Brownson (Aug 01 2026 at 20:11):

no worries at all, I had fun digging into it

view this post on Zulip Karl (Aug 01 2026 at 21:44):

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.

view this post on Zulip Jake Brownson (Aug 01 2026 at 21:45):

Ahh interesting, yeah the whole platform thing was all the LLM

view this post on Zulip Jake Brownson (Aug 01 2026 at 21:46):

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

view this post on Zulip Jake Brownson (Aug 01 2026 at 22:03):

documented this finding in the repo, thanks for taking a look

view this post on Zulip Karl (Aug 01 2026 at 22:05):

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.

view this post on Zulip Jake Brownson (Aug 01 2026 at 22:06):

wow nice

view this post on Zulip Karl (Aug 01 2026 at 22:06):

That's kind of why I was wondering because IME Roc is pretty fast.

view this post on Zulip Romain Lepert (Aug 03 2026 at 10:47):

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-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.

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.

view this post on Zulip Karl (Aug 03 2026 at 10:51):

Glad it was helpful. I think it's fun that all the GUI work is converged onto TEA running on Clay.

view this post on Zulip Romain Lepert (Aug 03 2026 at 10:58):

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

view this post on Zulip Romain Lepert (Aug 03 2026 at 11:05):

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

view this post on Zulip Karl (Aug 03 2026 at 11:15):

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.

view this post on Zulip Romain Lepert (Aug 03 2026 at 12:05):

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?)

view this post on Zulip Richard Feldman (Aug 03 2026 at 12:46):

I haven't looked at the code, but quick note that nested records should get flattened into one contiguous chunk of memory

view this post on Zulip Richard Feldman (Aug 03 2026 at 12:48):

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

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:01):

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.

view this post on Zulip Romain Lepert (Aug 03 2026 at 17:02):

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

Nested tree version

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 } }

Flat contiguous version

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

Pros of flat contiguous

Now the real value is two fold:

Layout solve

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

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:07):

Romain 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.

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:09):

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.

view this post on Zulip Romain Lepert (Aug 03 2026 at 17:09):

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.

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:10):

I'm not familiar with terrocotta, and not finding it on google, got a link?

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:11):

oh I see one above

view this post on Zulip Romain Lepert (Aug 03 2026 at 17:11):

yes sorry, I mentioned it here #show and tell > Terrocotta

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:14):

how do you track UI state like cursor position, scroll offset, focus?

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:14):

I'll read your show and tell

view this post on Zulip Romain Lepert (Aug 03 2026 at 17:15):

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

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:16):

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?

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:17):

Are you talking about retaining/caching the layout between frames?

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:17):

Not a perf guy so I may be missing something obvious

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:21):

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

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:22):

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

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:22):

again, my roclay is just vibe coded w/ an oracle to verify output vs the C version, not claiming any innovation there

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:23):

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

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:25):

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

view this post on Zulip Romain Lepert (Aug 03 2026 at 17:26):

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

Flat contiguous memory access pattern

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.

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:26):

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

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:30):

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.

view this post on Zulip Romain Lepert (Aug 03 2026 at 17:32):

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

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:33):

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?

view this post on Zulip Romain Lepert (Aug 03 2026 at 17:34):

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

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:35):

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

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:36):

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

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:40):

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

view this post on Zulip Jake Brownson (Aug 03 2026 at 17:41):

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