Skip to content

Commit 8e2f4b5

Browse files
fix: unset batch before flushing queued effects (#16482)
* - add state changes resulting from an $effect to a separate new batch - schedule rerunning effects based on the sources that are dirty, not just rerunning them all blindly (excempting async effects which will have run by that time already) * test * better fix * tests * this fixes the last test somehow * fix #16477 * typo * copy over changeset from #16477 * copy over changeset from #16464 * changeset * dedupe * move flushing_sync check inside Batch.ensure * unused * flushing_sync -> is_flushing_sync * remove flush_effects method * dedupe declaration * tweak * tweak * update comment — it _does_ feel slightly wrong, but no wronger than the rest of this cursed function --------- Co-authored-by: Simon Holthausen <[email protected]>
1 parent 6837246 commit 8e2f4b5

File tree

14 files changed

+298
-92
lines changed

14 files changed

+298
-92
lines changed

.changeset/grumpy-boats-beg.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: keep input in sync when binding updated via effect

.changeset/shiny-walls-fix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: prevent infinite async loop

.changeset/thick-mice-kick.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: exclude derived writes from effect abort and rescheduling

packages/svelte/src/internal/client/reactivity/batch.js

Lines changed: 102 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,13 @@ import {
2121
is_updating_effect,
2222
set_is_updating_effect,
2323
set_signal_status,
24-
update_effect,
25-
write_version
24+
update_effect
2625
} from '../runtime.js';
2726
import * as e from '../errors.js';
2827
import { flush_tasks } from '../dom/task.js';
2928
import { DEV } from 'esm-env';
3029
import { invoke_error_boundary } from '../error-handling.js';
31-
import { old_values } from './sources.js';
30+
import { mark_reactions, old_values } from './sources.js';
3231
import { unlink_effect } from './effects.js';
3332
import { unset_context } from './async.js';
3433

@@ -70,13 +69,15 @@ let last_scheduled_effect = null;
7069

7170
let is_flushing = false;
7271

72+
let is_flushing_sync = false;
73+
7374
export class Batch {
7475
/**
7576
* The current values of any sources that are updated in this batch
7677
* They keys of this map are identical to `this.#previous`
7778
* @type {Map<Source, any>}
7879
*/
79-
#current = new Map();
80+
current = new Map();
8081

8182
/**
8283
* The values of any sources that are updated in this batch _before_ those updates took place.
@@ -156,7 +157,7 @@ export class Batch {
156157
*
157158
* @param {Effect[]} root_effects
158159
*/
159-
#process(root_effects) {
160+
process(root_effects) {
160161
queued_root_effects = [];
161162

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

172-
for (const [source, current] of this.#current) {
173+
for (const [source, current] of this.current) {
173174
current_values.set(source, { v: source.v, wv: source.wv });
174175
source.v = current;
175176
}
@@ -202,9 +203,22 @@ export class Batch {
202203
this.#effects = [];
203204
this.#block_effects = [];
204205

206+
// If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with
207+
// newly updated sources, which could lead to infinite loops when effects run over and over again.
208+
current_batch = null;
209+
205210
flush_queued_effects(render_effects);
206211
flush_queued_effects(effects);
207212

213+
// Reinstate the current batch if there was no new one created, as `process()` runs in a loop in `flush_effects()`.
214+
// That method expects `current_batch` to be set, and could run the loop again if effects result in new effects
215+
// being scheduled but without writes happening in which case no new batch is created.
216+
if (current_batch === null) {
217+
current_batch = this;
218+
} else {
219+
batches.delete(this);
220+
}
221+
208222
this.#deferred?.resolve();
209223
} else {
210224
// otherwise mark effects clean so they get scheduled on the next run
@@ -300,7 +314,7 @@ export class Batch {
300314
this.#previous.set(source, value);
301315
}
302316

303-
this.#current.set(source, source.v);
317+
this.current.set(source, source.v);
304318
}
305319

306320
activate() {
@@ -327,13 +341,13 @@ export class Batch {
327341

328342
flush() {
329343
if (queued_root_effects.length > 0) {
330-
this.flush_effects();
344+
flush_effects();
331345
} else {
332346
this.#commit();
333347
}
334348

335349
if (current_batch !== this) {
336-
// this can happen if a `flushSync` occurred during `this.flush_effects()`,
350+
// this can happen if a `flushSync` occurred during `flush_effects()`,
337351
// which is permitted in legacy mode despite being a terrible idea
338352
return;
339353
}
@@ -345,52 +359,6 @@ export class Batch {
345359
this.deactivate();
346360
}
347361

348-
flush_effects() {
349-
var was_updating_effect = is_updating_effect;
350-
is_flushing = true;
351-
352-
try {
353-
var flush_count = 0;
354-
set_is_updating_effect(true);
355-
356-
while (queued_root_effects.length > 0) {
357-
if (flush_count++ > 1000) {
358-
if (DEV) {
359-
var updates = new Map();
360-
361-
for (const source of this.#current.keys()) {
362-
for (const [stack, update] of source.updated ?? []) {
363-
var entry = updates.get(stack);
364-
365-
if (!entry) {
366-
entry = { error: update.error, count: 0 };
367-
updates.set(stack, entry);
368-
}
369-
370-
entry.count += update.count;
371-
}
372-
}
373-
374-
for (const update of updates.values()) {
375-
// eslint-disable-next-line no-console
376-
console.error(update.error);
377-
}
378-
}
379-
380-
infinite_loop_guard();
381-
}
382-
383-
this.#process(queued_root_effects);
384-
old_values.clear();
385-
}
386-
} finally {
387-
is_flushing = false;
388-
set_is_updating_effect(was_updating_effect);
389-
390-
last_scheduled_effect = null;
391-
}
392-
}
393-
394362
/**
395363
* Append and remove branches to/from the DOM
396364
*/
@@ -412,19 +380,8 @@ export class Batch {
412380
this.#pending -= 1;
413381

414382
if (this.#pending === 0) {
415-
for (const e of this.#render_effects) {
416-
set_signal_status(e, DIRTY);
417-
schedule_effect(e);
418-
}
419-
420-
for (const e of this.#effects) {
421-
set_signal_status(e, DIRTY);
422-
schedule_effect(e);
423-
}
424-
425-
for (const e of this.#block_effects) {
426-
set_signal_status(e, DIRTY);
427-
schedule_effect(e);
383+
for (const source of this.current.keys()) {
384+
mark_reactions(source, DIRTY, false);
428385
}
429386

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

448-
static ensure(autoflush = true) {
405+
static ensure() {
449406
if (current_batch === null) {
450407
const batch = (current_batch = new Batch());
451408
batches.add(current_batch);
452409

453-
if (autoflush) {
410+
if (!is_flushing_sync) {
454411
Batch.enqueue(() => {
455412
if (current_batch !== batch) {
456413
// a flushSync happened in the meantime
@@ -487,32 +444,85 @@ export function flushSync(fn) {
487444
e.flush_sync_in_effect();
488445
}
489446

490-
var result;
447+
var was_flushing_sync = is_flushing_sync;
448+
is_flushing_sync = true;
491449

492-
const batch = Batch.ensure(false);
450+
try {
451+
var result;
493452

494-
if (fn) {
495-
batch.flush_effects();
453+
if (fn) {
454+
flush_effects();
455+
result = fn();
456+
}
496457

497-
result = fn();
498-
}
458+
while (true) {
459+
flush_tasks();
499460

500-
while (true) {
501-
flush_tasks();
461+
if (queued_root_effects.length === 0) {
462+
current_batch?.flush();
502463

503-
if (queued_root_effects.length === 0) {
504-
if (batch === current_batch) {
505-
batch.flush();
464+
// we need to check again, in case we just updated an `$effect.pending()`
465+
if (queued_root_effects.length === 0) {
466+
// this would be reset in `flush_effects()` but since we are early returning here,
467+
// we need to reset it here as well in case the first time there's 0 queued root effects
468+
last_scheduled_effect = null;
469+
470+
return /** @type {T} */ (result);
471+
}
506472
}
507473

508-
// this would be reset in `batch.flush_effects()` but since we are early returning here,
509-
// we need to reset it here as well in case the first time there's 0 queued root effects
510-
last_scheduled_effect = null;
474+
flush_effects();
475+
}
476+
} finally {
477+
is_flushing_sync = was_flushing_sync;
478+
}
479+
}
480+
481+
function flush_effects() {
482+
var was_updating_effect = is_updating_effect;
483+
is_flushing = true;
484+
485+
try {
486+
var flush_count = 0;
487+
set_is_updating_effect(true);
488+
489+
while (queued_root_effects.length > 0) {
490+
var batch = Batch.ensure();
491+
492+
if (flush_count++ > 1000) {
493+
if (DEV) {
494+
var updates = new Map();
495+
496+
for (const source of batch.current.keys()) {
497+
for (const [stack, update] of source.updated ?? []) {
498+
var entry = updates.get(stack);
499+
500+
if (!entry) {
501+
entry = { error: update.error, count: 0 };
502+
updates.set(stack, entry);
503+
}
504+
505+
entry.count += update.count;
506+
}
507+
}
508+
509+
for (const update of updates.values()) {
510+
// eslint-disable-next-line no-console
511+
console.error(update.error);
512+
}
513+
}
514+
515+
infinite_loop_guard();
516+
}
511517

512-
return /** @type {T} */ (result);
518+
batch.process(queued_root_effects);
519+
old_values.clear();
513520
}
521+
} finally {
522+
is_flushing = false;
523+
set_is_updating_effect(was_updating_effect);
514524

515-
batch.flush_effects();
525+
last_scheduled_effect = null;
516526
}
517527
}
518528

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

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

550560
update_effect(effect);
551561

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

569579
// if state is written in a user effect, abort and re-schedule, lest we run
570580
// effects that should be removed as a result of the state change
571-
if (write_version > wv && (effect.f & USER_EFFECT) !== 0) {
581+
if (
582+
current_batch !== null &&
583+
current_batch.current.size > n &&
584+
(effect.f & USER_EFFECT) !== 0
585+
) {
572586
break;
573587
}
574588
}

packages/svelte/src/internal/client/reactivity/sources.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export function internal_set(source, value) {
179179

180180
source.v = value;
181181

182-
const batch = Batch.ensure();
182+
var batch = Batch.ensure();
183183
batch.capture(source, old_value);
184184

185185
if (DEV) {
@@ -301,9 +301,10 @@ export function increment(source) {
301301
/**
302302
* @param {Value} signal
303303
* @param {number} status should be DIRTY or MAYBE_DIRTY
304+
* @param {boolean} schedule_async
304305
* @returns {void}
305306
*/
306-
function mark_reactions(signal, status) {
307+
export function mark_reactions(signal, status, schedule_async = true) {
307308
var reactions = signal.reactions;
308309
if (reactions === null) return;
309310

@@ -323,14 +324,16 @@ function mark_reactions(signal, status) {
323324
continue;
324325
}
325326

327+
var should_schedule = (flags & DIRTY) === 0 && (schedule_async || (flags & ASYNC) === 0);
328+
326329
// don't set a DIRTY reaction to MAYBE_DIRTY
327-
if ((flags & DIRTY) === 0) {
330+
if (should_schedule) {
328331
set_signal_status(reaction, status);
329332
}
330333

331334
if ((flags & DERIVED) !== 0) {
332335
mark_reactions(/** @type {Derived} */ (reaction), MAYBE_DIRTY);
333-
} else if ((flags & DIRTY) === 0) {
336+
} else if (should_schedule) {
334337
schedule_effect(/** @type {Effect} */ (reaction));
335338
}
336339
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<script>
2+
const der = $derived(false);
3+
4+
$effect(() => {
5+
der
6+
});
7+
</script>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
async test() {}
5+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script>
2+
import Component from './Component.svelte';
3+
const arr = Array.from({length: 10001});
4+
</script>
5+
6+
{#each arr}
7+
<Component />
8+
{/each}

0 commit comments

Comments
 (0)