sveltejs / svelte · Issue No. 18850
schedule_effect casts away a null that Batch.#process sets deliberately:
// src/internal/client/reactivity/batch.js
export function schedule_effect(effect) {
/** @type {Batch} */ (current_batch).schedule(effect);
}
#process() nulls current_batch before it flushes effects, and only repopulates it on the legacy-updates path:
// any writes should take effect in a subsequent batch
current_batch = null;
if (updates.length > 0) {
var batch = Batch.ensure();
…
}
…
flush_queued_effects(render_effects);
flush_queued_effects(effects);
If an effect flushed there reaches the self-invalidation block in update_reaction, it calls schedule_effect against that null and throws TypeError: Cannot read properties of null (reading 'schedule').
Three conditions have to line up:
Batch.#process → flush_queued_effects. In the reproduction the child is mounted by a state change, so its first run happens during the microtask batch flush rather than at mount.update_dependencies attaches the reaction to source.reactions only after fn() returns, so at write time mark_reactions cannot mark the effect dirty. It stays CLEAN and the source lands in untracked_writes — the $effect(() => x++) case described in the comment in sources.js.flushSync() runs after that write, within the same effect run. The write itself calls Batch.ensure(), which repopulates current_batch; flushSync's finally then nulls it again.update_reaction then reaches:
if (
is_runes() &&
untracked_writes !== null &&
!untracking &&
deps !== null &&
(reaction.f & (DERIVED | MAYBE_DIRTY | DIRTY)) === 0
) {
for (…) schedule_possible_effect_self_invalidation(untracked_writes[i], reaction);
}
The effect is CLEAN (condition 2) and is by now in source.reactions (attached by update_dependencies just above), so schedule_possible_effect_self_invalidation matches effect === reaction and calls schedule_effect.
Removing any single condition avoids the throw, verified individually:
flushSync() call.x unconditionally so it is an established dependency before the run that writes it. mark_reactions then marks the effect DIRTY and the (reaction.f & (DERIVED | MAYBE_DIRTY | DIRTY)) === 0 guard fails.#process.| version | result |
|---|---|
| 5.53.0 | works |
| 5.54.0 | throws |
| 5.57.1 | throws |
Introduced by #17805 (chore: refactor scheduling, merged 2026-03-08), which moved scheduling from the module-global queued_root_effects array onto current_batch. Before that, schedule_effect never touched current_batch. The function is unchanged on main as of today.
We hit this in production on a SvelteKit app: ~120 events over 7 days, across several releases, Chrome 152/153. Independently hit by another app on 5.56 with the same diagnosis: intent-hq/cloudlands-fe#2547.
Several open issues look like the same underlying invariant at different call sites: #18522 (batch.transfer_effects null after a top-level flushSync), #18799 (batch.increment null during async component init), and #18546 (scheduling bails after a batch is dropped). #18758's flushSync guard does not cover this one, since it fires only for a BRANCH_EFFECT/ROOT_EFFECT mid-update, and here flushSync is called inside a plain user $effect.
Happy to open one. Batch.ensure().schedule(effect) fixes the reproduction and leaves the effect tree reactive afterwards (I checked specifically, given #18546 describes a failure mode where the root goes permanently non-reactive). But since the four issues above may share a cause, you may prefer a single fix upstream of all of them rather than a guard at this call site. Say which direction you want and I'll put it together with a test.
https://github.com/ndri/svelte-repro-batch-schedule
pnpm install && pnpm dev, then click Mount the child. The child renders (x = 1) and an uncaught TypeError is reported on the page and in the console.
The essential part is two files:
<!-- App.svelte -->
<script>
import Child from './Child.svelte';
let show = $state(false);
</script>
<button onclick={() => (show = true)}>Mount the child</button>
{#if show}<Child />{/if}
<!-- Child.svelte -->
<script>
import { flushSync } from 'svelte';
let x = $state(0);
$effect(() => {
if (x === 0) {
x = 1;
flushSync();
}
});
</script>
<p>x = {x}</p>
Note the throw happens in the microtask that flushes the batch, so it does not propagate out of the click handler and a try/catch there will not catch it. The reproduction listens on window.onerror to display it.
TypeError: Cannot read properties of null (reading 'schedule')
in $effect
in Child.svelte
in App.svelte
Production build, symbolicated, from 5.57.0:
TypeError: Cannot read properties of null (reading 'schedule')
at schedule_effect (svelte/src/internal/client/reactivity/batch.js:1240:39)
at schedule_possible_effect_self_invalidation (svelte/src/internal/client/runtime.js:220:4)
at update_reaction (svelte/src/internal/client/runtime.js:274:5)
at update_effect (svelte/src/internal/client/runtime.js:490:18)
at flush_queued_effects (svelte/src/internal/client/reactivity/batch.js:1100:4)
at Batch.#process (svelte/src/internal/client/reactivity/batch.js:389:3)
at Batch.flush (svelte/src/internal/client/reactivity/batch.js:619:9)
at svelte/src/internal/client/reactivity/batch.js:866:13
at run_all (svelte/src/internal/shared/utils.js:47:9)
at svelte/src/internal/client/dom/task.js:10:2
System:
OS: macOS 26.7
CPU: (12) arm64 Apple M2 Max
Memory: 96.53 MB / 32.00 GB
Shell: 5.9 - /bin/zsh
Binaries:
Node: 24.11.1
npm: 11.6.0
pnpm: 10.15.0
bun: 1.3.7
Browsers:
Chrome: 153.0.8010.50
Firefox: 156.0
Safari: 27.0
npmPackages:
svelte: 5.57.1 => 5.57.1
annoyance
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.