Skip to content

fix: unset batch before flushing queued effects #16482

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Jul 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/grumpy-boats-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: keep input in sync when binding updated via effect
5 changes: 5 additions & 0 deletions .changeset/shiny-walls-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: prevent infinite async loop
5 changes: 5 additions & 0 deletions .changeset/thick-mice-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: exclude derived writes from effect abort and rescheduling
190 changes: 102 additions & 88 deletions packages/svelte/src/internal/client/reactivity/batch.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,13 @@ import {
is_updating_effect,
set_is_updating_effect,
set_signal_status,
update_effect,
write_version
update_effect
} from '../runtime.js';
import * as e from '../errors.js';
import { flush_tasks } from '../dom/task.js';
import { DEV } from 'esm-env';
import { invoke_error_boundary } from '../error-handling.js';
import { old_values } from './sources.js';
import { mark_reactions, old_values } from './sources.js';
import { unlink_effect } from './effects.js';
import { unset_context } from './async.js';

Expand Down Expand Up @@ -70,13 +69,15 @@ let last_scheduled_effect = null;

let is_flushing = false;

let is_flushing_sync = false;

export class Batch {
/**
* The current values of any sources that are updated in this batch
* They keys of this map are identical to `this.#previous`
* @type {Map<Source, any>}
*/
#current = new Map();
current = new Map();

/**
* The values of any sources that are updated in this batch _before_ those updates took place.
Expand Down Expand Up @@ -156,7 +157,7 @@ export class Batch {
*
* @param {Effect[]} root_effects
*/
#process(root_effects) {
process(root_effects) {
queued_root_effects = [];

/** @type {Map<Source, { v: unknown, wv: number }> | null} */
Expand All @@ -169,7 +170,7 @@ export class Batch {
current_values = new Map();
batch_deriveds = new Map();

for (const [source, current] of this.#current) {
for (const [source, current] of this.current) {
current_values.set(source, { v: source.v, wv: source.wv });
source.v = current;
}
Expand Down Expand Up @@ -202,9 +203,22 @@ export class Batch {
this.#effects = [];
this.#block_effects = [];

// If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with
// newly updated sources, which could lead to infinite loops when effects run over and over again.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we've been hunting infinite loop problem in our code in 5.35.5 I am wondering if this comment means that there were indeed a possibility that svelte creates them? That'd be a relief to know

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should only happen in case of async though

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly! Will be interested to see if this version fixes things

current_batch = null;

flush_queued_effects(render_effects);
flush_queued_effects(effects);

// Reinstate the current batch if there was no new one created, as `process()` runs in a loop in `flush_effects()`.
// That method expects `current_batch` to be set, and could run the loop again if effects result in new effects
// being scheduled but without writes happening in which case no new batch is created.
if (current_batch === null) {
current_batch = this;
} else {
batches.delete(this);
}

this.#deferred?.resolve();
} else {
// otherwise mark effects clean so they get scheduled on the next run
Expand Down Expand Up @@ -300,7 +314,7 @@ export class Batch {
this.#previous.set(source, value);
}

this.#current.set(source, source.v);
this.current.set(source, source.v);
}

activate() {
Expand All @@ -327,13 +341,13 @@ export class Batch {

flush() {
if (queued_root_effects.length > 0) {
this.flush_effects();
flush_effects();
} else {
this.#commit();
}

if (current_batch !== this) {
// this can happen if a `flushSync` occurred during `this.flush_effects()`,
// this can happen if a `flushSync` occurred during `flush_effects()`,
// which is permitted in legacy mode despite being a terrible idea
return;
}
Expand All @@ -345,52 +359,6 @@ export class Batch {
this.deactivate();
}

flush_effects() {
var was_updating_effect = is_updating_effect;
is_flushing = true;

try {
var flush_count = 0;
set_is_updating_effect(true);

while (queued_root_effects.length > 0) {
if (flush_count++ > 1000) {
if (DEV) {
var updates = new Map();

for (const source of this.#current.keys()) {
for (const [stack, update] of source.updated ?? []) {
var entry = updates.get(stack);

if (!entry) {
entry = { error: update.error, count: 0 };
updates.set(stack, entry);
}

entry.count += update.count;
}
}

for (const update of updates.values()) {
// eslint-disable-next-line no-console
console.error(update.error);
}
}

infinite_loop_guard();
}

this.#process(queued_root_effects);
old_values.clear();
}
} finally {
is_flushing = false;
set_is_updating_effect(was_updating_effect);

last_scheduled_effect = null;
}
}

/**
* Append and remove branches to/from the DOM
*/
Expand All @@ -412,19 +380,8 @@ export class Batch {
this.#pending -= 1;

if (this.#pending === 0) {
for (const e of this.#render_effects) {
set_signal_status(e, DIRTY);
schedule_effect(e);
}

for (const e of this.#effects) {
set_signal_status(e, DIRTY);
schedule_effect(e);
}

for (const e of this.#block_effects) {
set_signal_status(e, DIRTY);
schedule_effect(e);
for (const source of this.current.keys()) {
mark_reactions(source, DIRTY, false);
}

this.#render_effects = [];
Expand All @@ -445,12 +402,12 @@ export class Batch {
return (this.#deferred ??= deferred()).promise;
}

static ensure(autoflush = true) {
static ensure() {
if (current_batch === null) {
const batch = (current_batch = new Batch());
batches.add(current_batch);

if (autoflush) {
if (!is_flushing_sync) {
Batch.enqueue(() => {
if (current_batch !== batch) {
// a flushSync happened in the meantime
Expand Down Expand Up @@ -487,32 +444,85 @@ export function flushSync(fn) {
e.flush_sync_in_effect();
}

var result;
var was_flushing_sync = is_flushing_sync;
is_flushing_sync = true;

const batch = Batch.ensure(false);
try {
var result;

if (fn) {
batch.flush_effects();
if (fn) {
flush_effects();
result = fn();
}

result = fn();
}
while (true) {
flush_tasks();

while (true) {
flush_tasks();
if (queued_root_effects.length === 0) {
current_batch?.flush();

if (queued_root_effects.length === 0) {
if (batch === current_batch) {
batch.flush();
// we need to check again, in case we just updated an `$effect.pending()`
if (queued_root_effects.length === 0) {
// this would be reset in `flush_effects()` but since we are early returning here,
// we need to reset it here as well in case the first time there's 0 queued root effects
last_scheduled_effect = null;

return /** @type {T} */ (result);
}
}

// this would be reset in `batch.flush_effects()` but since we are early returning here,
// we need to reset it here as well in case the first time there's 0 queued root effects
last_scheduled_effect = null;
flush_effects();
}
} finally {
is_flushing_sync = was_flushing_sync;
}
}

function flush_effects() {
var was_updating_effect = is_updating_effect;
is_flushing = true;

try {
var flush_count = 0;
set_is_updating_effect(true);

while (queued_root_effects.length > 0) {
var batch = Batch.ensure();

if (flush_count++ > 1000) {
if (DEV) {
var updates = new Map();

for (const source of batch.current.keys()) {
for (const [stack, update] of source.updated ?? []) {
var entry = updates.get(stack);

if (!entry) {
entry = { error: update.error, count: 0 };
updates.set(stack, entry);
}

entry.count += update.count;
}
}

for (const update of updates.values()) {
// eslint-disable-next-line no-console
console.error(update.error);
}
}

infinite_loop_guard();
}

return /** @type {T} */ (result);
batch.process(queued_root_effects);
old_values.clear();
}
} finally {
is_flushing = false;
set_is_updating_effect(was_updating_effect);

batch.flush_effects();
last_scheduled_effect = null;
}
}

Expand Down Expand Up @@ -545,7 +555,7 @@ function flush_queued_effects(effects) {
var effect = effects[i++];

if ((effect.f & (DESTROYED | INERT)) === 0 && is_dirty(effect)) {
var wv = write_version;
var n = current_batch ? current_batch.current.size : 0;

update_effect(effect);

Expand All @@ -568,7 +578,11 @@ function flush_queued_effects(effects) {

// if state is written in a user effect, abort and re-schedule, lest we run
// effects that should be removed as a result of the state change
if (write_version > wv && (effect.f & USER_EFFECT) !== 0) {
if (
current_batch !== null &&
current_batch.current.size > n &&
(effect.f & USER_EFFECT) !== 0
) {
break;
}
}
Expand Down
11 changes: 7 additions & 4 deletions packages/svelte/src/internal/client/reactivity/sources.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export function internal_set(source, value) {

source.v = value;

const batch = Batch.ensure();
var batch = Batch.ensure();
batch.capture(source, old_value);

if (DEV) {
Expand Down Expand Up @@ -301,9 +301,10 @@ export function increment(source) {
/**
* @param {Value} signal
* @param {number} status should be DIRTY or MAYBE_DIRTY
* @param {boolean} schedule_async
* @returns {void}
*/
function mark_reactions(signal, status) {
export function mark_reactions(signal, status, schedule_async = true) {
var reactions = signal.reactions;
if (reactions === null) return;

Expand All @@ -323,14 +324,16 @@ function mark_reactions(signal, status) {
continue;
}

var should_schedule = (flags & DIRTY) === 0 && (schedule_async || (flags & ASYNC) === 0);

// don't set a DIRTY reaction to MAYBE_DIRTY
if ((flags & DIRTY) === 0) {
if (should_schedule) {
set_signal_status(reaction, status);
}

if ((flags & DERIVED) !== 0) {
mark_reactions(/** @type {Derived} */ (reaction), MAYBE_DIRTY);
} else if ((flags & DIRTY) === 0) {
} else if (should_schedule) {
schedule_effect(/** @type {Effect} */ (reaction));
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
const der = $derived(false);

$effect(() => {
der
});
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { test } from '../../test';

export default test({
async test() {}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script>
import Component from './Component.svelte';
const arr = Array.from({length: 10001});
</script>

{#each arr}
<Component />
{/each}
Loading
Loading