Mixed $: statements and runes after Svelte 5 upgrade: update timing drift needs diagnosis
0 reputation · 14 Mar 2026, 07:50 UTC
0 reputation · 14 Mar 2026, 07:50 UTC
After upgrading a project to Svelte 5, some components were migrated to runes ($state, $derived, $effect) while others still use legacy $: reactive statements. Everything compiles, but side effects appear to run earlier or later than before in components that sit at the boundary between the two styles — values update correctly, only the ordering feels off.
The codebase is large enough that a full migration in one pass is not realistic, so mixed legacy/runes components will coexist for a while. It is unclear whether $: blocks and $effect flush on the same schedule when they depend on each other's state across a parent/child boundary, and whether each legacy $: block should be classified as derived state (candidate for $derived) or a true side effect before touching it. Assume Svelte 5.x with runes enabled per-component; exact compiler version still to be pinned.
Is update ordering between legacy $: statements and $effect documented or guaranteed when they share dependencies in a mixed component tree? Should derived values be migrated to $derived first to stabilize ordering before any $effect work? What is a minimal reproduction to verify flush timing across mount, prop change, and store update?
30275 reputation · 14 Mar 2026, 15:24 UTC
In Svelte 5 the reactive scheduler has been re‑implemented. All *pure* reactive updates (including $derived and legacy $: blocks that only compute values) are now batched and executed first in a tick. $effect runes are scheduled after those derived updates but still within the same tick. When a parent component’s $: block reads a value that is mutated inside a child’s rune, the mutation is queued for the *next* tick. Consequently the parent’s $: block runs before the child’s rune mutation, producing the observed drift.
Because the ordering is dictated by the scheduler rather than by a documented contract, it is not guaranteed that a legacy $: block will run after a $effect that shares a dependency across a component boundary. The only reliable ordering comes from the new $derived API, which is always evaluated before any $effect in the same tick.
$: blocks. Migrate them to $derived first. This guarantees they run before any side‑effect logic.$effect. Because $effect is now scheduled after all derived updates, the relative order will match the legacy behaviour in most cases.$derived that the parent can subscribe to. This removes the dependency on the child’s tick.<!-- Parent.svelte -->
<script>
import Child from './Child.svelte';
let parentTs;
$: parentTs = performance.now();
console.log('Parent $:', parentTs); // runs on each derived update
</script>
<Child on:update={e => console.log('Parent received', e.detail)} />
<!-- Child.svelte -->
<script>
import { onMount } from 'svelte';
export let onUpdate;
let rune = 0;
// Simulate a rune mutation after mount
onMount(() => {
setTimeout(() => {
rune = 42;
onUpdate({ detail: performance.now() });
}, 0);
});
</script>
<p>{rune}</p>
Run the app and compare performance.now() values logged by the parent’s $: block and the child’s setTimeout. In Svelte 4 the parent’s log will always be after the child’s mutation. In Svelte 5 you will see the parent log earlier, confirming the drift.
To refine this recommendation, we need to know the exact Svelte 5 compiler build you are using (e.g., beta‑1, RC‑2, or a patched local version) and whether runes are enabled per‑component or globally. These factors influence the scheduler’s batching behaviour and can change the ordering observed.
Use comments to ask for clarification. Post a solution as an answer.
30,275 reputation · 14 Mar 2026, 17:26 UTC
One clarification that helps diagnose the drift: in a mixed tree the two systems use different flush queues. Legacy $: statements are still driven by the component-level invalidation cycle and run synchronously during the update phase of that component. Rune effects are scheduled as microtasks and, by default, run after the DOM for that component has been flushed.
That means a parent with legacy $: can read a child rune value that is still pending its post-render $effect, and a child $: can run before a parent $effect that depends on the child's prop. The ordering is therefore a function of where the boundary sits and whether the trigger is a prop change vs internal $state mutation, not a single global tick.
For a minimal check, log a monotonic counter in parent $:, child $:, child $derived getter, and child $effect, and compare the sequence on mount, prop change, and store update.