Skip to content

Roadmap: 2023-24 #1830

Open
Open
@gbj

Description

@gbj

(This supersedes the roadmaps in #1147 and #501.)

Big Picture

Depending on how you count, Leptos is somewhere near its 1-year anniversary! I began serious work on it in July 2022, and the 0.0.1 release was October 7. It's incredible to see what's happened since then with 183+ contributors, and 60+ releases. Of course there are still some bugs and rough edges, but after one year, this community-driven, volunteer-built framework has achieved feature parity with the biggest names in the frontend ecosystem. Thank you all, for everything you've done.

Releasing 0.5.0 was a huge effort for me, and the result of a ton of work by a lot of contributors. The process of rethinking and building it began in early April with #802, so it's taken ~6 months—half the public lifetime of Leptos—to land this successfully. And with the exception of some perennials ("make the reactive system Send"), the work laid out in #1147 is essentially done.

So: What's next?

Smaller Things

Polishing 0.5

This section probably goes without saying: we had a lot of beta testing for this release but I also continued making changes throughout that process (lol), so there will be some bugs and rough edges to smooth out in 0.5.1, 0.5.2, etc.

There have also already been PRs adding nice new features!

And some cool ideas unlocked by ongoing development:

  • autotracking async memos (like create_resource, but without explicit dependencies; I made a working demo in a few minutes)
  • splitting apart the "data serialization from server" and "integrating async stuff" functions of resources, to allow serializing synchronous server data easily

Building the Ecosystem (Leptoberfest!)

Of course it will take a little time for our ecosystem libraries to settle down and adapt to 0.5. Many of them have already done this work—kudos! All of these libraries are maintained by volunteers, so please be patient and gracious to them.

The larger the ecosystem of libraries and apps grows, the harder it becomes to make semver-breaking changes, so expect the pace of change to be more sedate this year than it was last year.

The core is pretty stable at this point but there's lots that we can do to make Leptos better by contributing to these ecosystem libraries. To that end, I'm announcing Leptoberfest, a light-hearted community hackathon during the month of October (broadly defined) focusing on supporting our community ecosystem.

Good places to start:

If you're a library maintainer and I missed you — apologies, and ping me and I'll add you.

If you make a contribution to close an issue (bug or feature request!) this fall, please fill out this form to let us know. I'll be publishing a list of all our Leptoberfest contributors at the end of the month (so, some time in early November/when I get to it!) @benwis and I also have a supply of Leptos stickers we can send out as rewards. If you have ideas for other fun rewards, let me know. (We don't have much cash, but we love you!)

On a personal note: As a maintainer, I have found that the feature requests never end. I welcome them, but they can become a distraction from deeper work! I will be slowing down a little in terms of how much time I can dedicate to adding incremental new features to the library. If you see an issue marked feature request, and it's a feature you'd like to see, please consider making a PR!

Bigger Things

Apart from bugs/support/etc., most of my Leptos work this year is going to be exploring two areas for the future. I am a little burnt out after the last year, and everything it's entailed. I have also found that the best antidote to burn-out and the resentment that comes to it is to do the deep, exploratory work that I really find exciting.

Continuing Islands Exploration

The experimental-islands feature included in 0.5 reflects work at the cutting edge of what frontend web frameworks are exploring right now. As it stands, our islands approach is very similar to Astro (before its recent View Transitions support): it allows you to build a traditional server-rendered, multi-page app and pretty seamlessly integrate islands of interactivity.

Incremental Feature Improvements

There are some small improvements that will be easy to add. For example, we can do something very much like Astro's View Transitions approach:

  • add client-side routing for islands apps by fetching subsequent navigations from the server and replacing the HTML document with the new one
  • add animated transitions between the old and new document using the View Transitions API
  • support explicit persistent islands, i.e., islands that you can mark with unique IDs (something like persist:searchbar on the component in the view), which can be copied over from the old to the new document without losing their current state

All of these can be done as opt-in incremental improvements to the current paradigm with no breaking changes.

Things I'm Not Sold On

There are other, larger/architectural improvements that can improve performance significantly, and remove the need for manually marking persistent islands. Specifically, the fact that we use a nested router means that, when navigating, we can actually go to the server and ask only for a certain chunk of the page, and swap that HTML out without needing to fetch or replace the rest of the page.

If this sounds like something you could do with HTMX or Hotwire or whatever, then yes, this is like the router automatically knowing how to do something you could set up manually with HTMX.

However once you start thinking it through (which I have!) you start to realize this raises some real issues. For example, we currently support passing context through the route tree, including through an Outlet. But if a nested route can be refetched indepedently of the whole page, the parent route won't run. This is great from a performance perspective but it means you can no longer pass context through the router on the server. And it turns out that basically the entire router is built on context...

This is essentially why React Server Components need to come with a whole cache layer: if you can't provide data via context on the server, you end up making the same API requests at multiple levels of the app, which means you really want to provide a request-local cache, etc., etc., etc.,

Essentially we have the opportunity to marginally improve performance at the expense of some pretty big breaking changes to the whole mental model. I'm just not sure it's worth pushing that far in this direction. Needless to say I'll continue watching Solid's progress pretty closely.

I don't anticipate making any of these breaking changes without a lot more input, discussion, thought, and research.

The Most Exciting Thing: Rendering Exploration

Leptos has taken two distinct approaches to rendering.

0.0

The 0.0 renderer was built on pure HTML templating. The view macro then worked like the template macro does now, or like SolidJS, Vue Vapor (forthcoming), or Svelte 5 (forthcoming) work, compiling your view to three things:

  • an HTML <template> element that created once, then cloned whenever you need to create the view
  • a series of DOM node traversal instructions (.firstChild and .nextSibling) that walked over that cloned tree
  • a series of create_effect calls that set up the reactive system to update those nodes directly

In ssr mode, the view macro literally compiled your view to a String.

Pros

  • Super fast element creation
  • Smaller WASM binaries (storing your view as static HTML is smaller than as WASM instructions to build DOM elements)
  • Minimal extra junk in the DOM (hydration IDs, comment markers, etc.)
  • Extremely fast/lightweight server-side rendering

Cons

  • Lots of bugs and odd edge cases that were not handled well. ("Have you tried wrapping it in a <div>?")
  • Relied on view macro, couldn't use a builder syntax or ordinary Rust code
  • Harder to work with dynamic views, fragments, etc.; components returned Element (one HTML element) or Vec<Element> (several of them!) without the nice flexibility of -> impl IntoView.
  • Very limited rust-analyzer/syntax highlighting support in the view macro.

Apart from these issues, which probably could've been fixed incrementally, I've come to think there was a fundamental limitation with this approach. Not only did it mean writing much of the framework's rendering logic in a proc macro, which is really hard to debug; doing it in a proc macro meant that we needed to do all of that without access to type information, just by transforming a stream of tokens to other tokens.

For example, we had no way of knowing the type of a block included from outside the view:

let b = "Some text."
view! {
	<p>"before" {b} "and after"</p>
}

The view macro has no idea whether a is a string, or an element, or a component invocation, or (). This caused many of the early bugs and meant that we needed to add additional comment markers and runtime mechanisms to prevent issues.

0.1-0.5

Our current renderer is largely the outstanding work of @jquesada2016 and me collaborating to address those issues. It replaced the old view macro with a more dynamic renderer. There's now a View type that's an enum of different possible views, type erasure by default with -> impl IntoView, much better support for fragments, etc. Most of our growth and success over the last six months was unlocked by this rewrite. The view macro expands to a runtime builder syntax for elements.

Pros

  • Much better rust-analyzer and syntax highlighting support in the view
  • Pure Rust builder syntax available
  • Increased runtime flexibility
  • Much more robust/fewer weird panics (believe it or not)

Cons

This approach basically works! If I never changed it, things would be fine. It is kind of hard to maintain, there are some weird edge cases that I've sunk hours into to no avail, it's kind of chunky, the HTML is a little gross; but it's all fine.

So let's rewrite it.

The Future of Rendering

I have been extremely inspired by the work being done on Xilem, and by Raph Levien's talks on it. I'm also totally uninspired.

For context: blog post, really good talk that lays out four basic paradigms for UI. Seriously, reading/watching these will make you better at what you do. See also the Xilem repo and the Xilem-inspired Concoct.

The Xilem architecture, in my reading, tries to address two pieces of the UI question. How do we build up a view tree to render stuff (natively or in the DOM)? And how do we drive changes to that view tree? It proposes a statically-typed view tree with changes driven by React-like components, in which event callbacks take a mutable reference to the state of that component.

The idea of a statically-typed view tree similar to SwiftUI is just so perfectly suited to Rust and the way its trait composition works that it blew my mind. Xilem is built on a trait with build and rebuild functions, and an associated State type that retains state. Here's an example:

impl<'a> Render for &'a str {
    type State = (Text, &'a str)

    fn build(self) -> Self::State {
        let node = document().create_text_node(self);
        (node, self)
    }

    fn rebuild(self, state: &mut Self::State) {
        let (node, prev) = state;
        if &self != prev {
            node.set_data(self);
            *prev = self;
        }
    }
}

impl<A, B, C> Render for (A, B, C)
where
    A: Render,
    B: Render,
    C: Render,
{
    type State = (A::State, B::State, C::State);
    fn build(self) -> Self::State {
        let (a, b, c) = self;
        (a.build(), b.build(), c.build())
    }
    fn rebuild(self, state: &mut Self::State) {
        let (a, b, c) = self;
        let (view_a, view_b, view_c) = state;
        a.rebuild(view_a);
        b.rebuild(view_b);
        c.rebuild(view_c)
    }
}

If you don't get it, it's okay. You just haven't spent as much time mucking around in the renderer as I have. Building this all up through trait composition, and through the composition of smaller parts, is freaking amazing.

But the use of component-grained reactivity is just fundamentally uninteresting to me.

I played around a little with a Xilem-like renderer and finished with mix results: I was able to create a really tiny framework with an Elm state architecture and very small WASM binary sizes. But when I tried to build something based on Leptos reactivity and the same model, I ended up with something very similar to binary size and performance to Leptos. Not worth a rewrite.

Then it hit me. The fundamental problem with 0.0 was that, when we were walking the DOM, we were generating that DOM walk in a proc macro: we didn't know anything about the types of the view. But if we use trait composition, we do. Using traits means that we can write the whole renderer in plain Rust, with almost nothing in the view macro except a very simple expansion. And we can drive the hydration walk by doing it at a time when we actually understand what types we're dealing with, only advancing the cursor when we hit actual text or an actual element!

impl<'a> RenderHtml for &'a str
{
    fn to_html(&self, buf: &mut String, position: &PositionState) {
        // add a comment node to separate from previous sibling, if any
        if matches!(position.get(), Position::NextChild | Position::LastChild) {
            buf.push_str("<!>")
        }
        buf.push_str(self);
    }

    fn hydrate<const FROM_SERVER: bool>(
        self,
        cursor: &Cursor<R>,
        position: &PositionState,
    ) -> Self::State {
        if position.get() == Position::FirstChild {
            cursor.child();
        } else {
            cursor.sibling();
        }

        let node = cursor.current();

		/* some other stuff that's not important! */

        (node, self)
    }
}

Again, if it's not clear why this is amazing, no worries. Let me think about that. But it is really, really good.

It's also possible to make the view rendering library generic over the rendering layer, unlocking universal rendering or custom renderers (#1743) and allowing for the creation of a TestingDom implementation that allows you to run native cargo test tests of components without a headless browser, a future leptos-native, or whatever. And the rendering library can actually be totally detached from the reactivity layer: the renderer doesn't care what calls the rebuild() function, so the Leptos-specific functionality is very much separable from the rest.

Pros

  • Much more "pure Rust" code with normal rules of mutability, ownership, etc instead of interior mutability and Rc everywhere.
  • Still supports a native Rust syntax
  • Smaller WASM binary sizes (about a 20% reduction in the examples I've built so far)
  • Smaller/cleaner HTML files (many, many fewer hydration IDs and comments)
  • Much faster SSR (my current benchmarks are about 4-5x faster than current Leptos)
  • Faster hydration due to DOM walk
  • More maintainable than current leptos_dom
  • More testable than current leptos_dom
  • Remove a bunch of the view macro's logic/the server optimizations
  • Significantly "smooths out" the worst case of performance: i.e., "re-rendering" a whole chunk of the view inside a dynamic move || block goes from being "this is bad, recreates the whole DOM" to "not ideal but kind of fine, efficiently diffs with fewer allocations than a VDOM"

Cons

  • ???

My goal is for this to be a drop-in replacement for most use cases. i.e., if you're just using the view macro to build components, this new renderer should not require you to rewrite your code. If you're doing fancier stuff with custom IntoView implementations and so on, there will of course be some changes.

As you can tell, I'm very excited about work in this direction. It has been giving me a sense of energy and excitement about the project and its future. None of it is even close to feature parity with current Leptos, let alone ready for production. But I have built enough of it that I'm convinced that it's the next step.

I really believe in building open source in the open, so I'm making all this exploration public at https://github.com/gbj/tachys Feel free to follow along and check things out.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions