How Svelte 5 Runes will Fix Long-Standing Svelte Bugs

Written — Updated

Svelte 5's rune system has brought a lot of excitement, but also some consternation. While overall it's a big improvement, certain patterns seem like they'll be less ergonomic, and Svelte may feel less "magical" than it used to.

As it's still in progress, hopefully some of these concerns will be alleviated as runes are refined and best practices are established. But the biggest improvement IMO will be that some long-standing, hard-to-fix bugs in Svelte will be gone.

Large Svelte Apps

Many people may not experience these bugs, as they only show up with more complex code. So first, a bit about my experience. The Carevoyance SvelteKit app contains over 600 individual Svelte components and a bunch of supporting Javascript code. The app originated in 2014 and was initially written in AngularJS (aka Angular 1). Svelte came onto my radar just after Svelte 3 was released, and after some prototyping, it was clear that the move to Svelte would be a huge improvement. The ability to easily integrate Svelte into the existing Angular app, and to somewhat easily render Angular templates from Svelte, also made the migration much easier.

Of course, nothing is perfect, and we did hit a few wrinkles that even now aren't fixed in Svelte 4. But they will be in 5! Let's take a look.

Loss of Reactivity with Multiple Nested Slots

There are a few Github issues about this one already, and unfortunately it looks very tricky to fix. In Svelte 3.12, the state tracking system was updated to use a "dirty" bitset, where each number in the bitset corresponded to a particular variable which some reactive code or template reference depends on. This brought a much needed performance increase, but also leads to complexity when using slots.

When using slots, Svelte passes a portion of its dirty bitset from the parent component into the child, so that the child will know when to rerender the slot. This usually works well, but sometimes when using nested components with multiple layers of slots, something gets lost. Changes aren't reflected immediately, sometimes being a tick behind or lost completely.

These REPLs demonstrates the problem in Svelte 3 and 4:



(I didn't write these; they came from the authors of various Github issues).

Svelte 5's rune system completely changes how this works. The code generated by the compiler plays much less of a role in dependency tracking now. Instead each rune tracks everything that depends on it and this works across modules. This also means that slots are vastly simplified, with no more need to pass a subset of the dirty bitset between components.

Below I've ported the above REPLs to Svelte 5. Notice that everything works!

REPL 1, Svelte 5 version

REPL 2, Svelte 5 version

Hidden Circular Dependencies can Lose Updates

This one is due to a combination of how Svelte topologically sorts things, and how the "dirty" bitset is managed.

As described above, Svelte components set a bit in their dirty bitset when a store or other reactive dependency changes, and this normally would cause any statements that rely on that store to rerun. But at the end of running a component's reactive statements, Svelte clears out the entire bitset. Normally this prevents infinite loops if a statement both reads and writes the same value, and Svelte's reordering of reactive statements in causal order makes everything work fine.

The bug here occurs when a component calls a function in some external code which updates a store, and some other reactive statements in the component also rely on that store.

Let's say reactive statement A is the one that calls the external function, and statement B is the one that reads from the store that the external function updates.

Since this chain of causality isn't wholly inside a single component, Svelte doesn't know that it needs to place B after A — their order in the component may be related to other dependencies, but the causal link from the store is not considered.

There's also a luck factor here, since the relative placement of the two statements in the compiled code can affect if the bug occurs. I've seen cases where trivial changes like switching how we wrote an if statement, or merging two reactive statements into one, was enough to cause the bug to occur or go away.

This is difficult to replicate in a simple REPL, but it happens most often with complex layouts that have a set of components and some shared state between them.

When you get lucky with the order of the compiled output statements, the process is something like this:
  1. Reactive statement "A" runs, and calls the external function, which then updates the store.
  2. The listener in the Svelte component runs and updates $store, using the internal invalidate function to set the bit in the dirty bitset.
  3. Reactive statement "B" that depends on $store is checked. It runs because the store was updated.
  4. This cycle of running reactive code ends, so Svelte clears the bitset.
But if the two reactive statements are swapped, the order of events is something like this:
  1. Reactive statement "B" that depends on $store is checked. It doesn't run because the store wasn't updated.
  2. Reactive statement "A" runs, and calls the external function, which then updates the store.
  3. The listener in the Svelte component runs and updates $store, using the internal invalidate function to set the bit in the dirty bitset.
  4. This cycle of running reactive code ends, so Svelte clears the bitset.
  5. Statement "B" is not immediately run again, and so the values that it computes become out of sync.

Keep in mind that you have no control over the order in which A and B run, at least with regard to the store's value.

The way to work around this is to wait a tick somewhere in the chain, so that the store's update doesn't happen until after the current reactive cycle ends. But this solution is usually only reached after a long period of tricky debugging and walking through both the compiled output and internal Svelte code to see what's happening.

The good news again is that Svelte 5 will fix this, and for the same reasons mentioned in the previous section. The compiler is not reordering statements anymore, nor does the compiler try to figure out which statements should run when; it's all done at runtime instead in a fashion that works fine with cross-module reactivity.

The Upshot

Svelte 5's runes system ultimately moves a lot of the state-tracking complexity out of the compiler and into the runtime. Not only does this fix the above bugs, but it will make it much easier to test and verify this code without the need to convince the compiler to generate various edge cases. It should be quite feasible to use more advanced testing methodologies such as model-based testing too, should the need arise.

I do agree that there are some ergonomic questions remaining with Svelte 5. How will it work to edit complex nested objects? Will we constantly write lots of boilerplate to add accessors to things? Fortunately the Svelte team is listening to the community's concerns, and I believe that the final release of Svelte 5 will be a good experience for the developer, even if it's not quite as magical as Svelte 4. But being able to write bug-free code is paramount.

Three years ago, @swyx wrote, Svelte for Sites, React for Apps. I don't know if he still stands by those words as Svelte and its ecosystem have come a long way since then (and Shawn has been a big part of that!), but I do think that Svelte 5 will allow us to be more confident in saying "Svelte for Sites, and Svelte for Apps!"

Thanks for reading! If you have any questions or comments, please send me a note on Twitter.