Skip to content

Commit 53417ea

Browse files
authored
fix: preserve dirty status of deferred effects (#16487)
* don't mark_reactions inside decrement, it can cause infinite loops * revert mark_reactions changes * preserve DIRTY/MAYBE_DIRTY status of deferred effects * changeset * tweak
1 parent f882095 commit 53417ea

File tree

5 files changed

+101
-21
lines changed

5 files changed

+101
-21
lines changed

.changeset/cool-insects-argue.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: preserve dirty status of deferred effects

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

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
INERT,
1111
RENDER_EFFECT,
1212
ROOT_EFFECT,
13-
USER_EFFECT
13+
USER_EFFECT,
14+
MAYBE_DIRTY
1415
} from '#client/constants';
1516
import { async_mode_flag } from '../../flags/index.js';
1617
import { deferred, define_property } from '../../shared/utils.js';
@@ -27,7 +28,7 @@ import * as e from '../errors.js';
2728
import { flush_tasks } from '../dom/task.js';
2829
import { DEV } from 'esm-env';
2930
import { invoke_error_boundary } from '../error-handling.js';
30-
import { mark_reactions, old_values } from './sources.js';
31+
import { old_values } from './sources.js';
3132
import { unlink_effect } from './effects.js';
3233
import { unset_context } from './async.js';
3334

@@ -146,6 +147,18 @@ export class Batch {
146147
*/
147148
#block_effects = [];
148149

150+
/**
151+
* Deferred effects (which run after async work has completed) that are DIRTY
152+
* @type {Effect[]}
153+
*/
154+
#dirty_effects = [];
155+
156+
/**
157+
* Deferred effects that are MAYBE_DIRTY
158+
* @type {Effect[]}
159+
*/
160+
#maybe_dirty_effects = [];
161+
149162
/**
150163
* A set of branches that still exist, but will be destroyed when this batch
151164
* is committed — we skip over these during `process`
@@ -221,10 +234,9 @@ export class Batch {
221234

222235
this.#deferred?.resolve();
223236
} else {
224-
// otherwise mark effects clean so they get scheduled on the next run
225-
for (const e of this.#render_effects) set_signal_status(e, CLEAN);
226-
for (const e of this.#effects) set_signal_status(e, CLEAN);
227-
for (const e of this.#block_effects) set_signal_status(e, CLEAN);
237+
this.#defer_effects(this.#render_effects);
238+
this.#defer_effects(this.#effects);
239+
this.#defer_effects(this.#block_effects);
228240
}
229241

230242
if (current_values) {
@@ -271,15 +283,15 @@ export class Batch {
271283
if (!skip && effect.fn !== null) {
272284
if (is_branch) {
273285
effect.f ^= CLEAN;
274-
} else if ((flags & EFFECT) !== 0) {
275-
this.#effects.push(effect);
276-
} else if (async_mode_flag && (flags & RENDER_EFFECT) !== 0) {
277-
this.#render_effects.push(effect);
278-
} else if (is_dirty(effect)) {
279-
if ((flags & ASYNC) !== 0) {
286+
} else if ((flags & CLEAN) === 0) {
287+
if ((flags & EFFECT) !== 0) {
288+
this.#effects.push(effect);
289+
} else if (async_mode_flag && (flags & RENDER_EFFECT) !== 0) {
290+
this.#render_effects.push(effect);
291+
} else if ((flags & ASYNC) !== 0) {
280292
var effects = effect.b?.pending ? this.#boundary_async_effects : this.#async_effects;
281293
effects.push(effect);
282-
} else {
294+
} else if (is_dirty(effect)) {
283295
if ((effect.f & BLOCK_EFFECT) !== 0) this.#block_effects.push(effect);
284296
update_effect(effect);
285297
}
@@ -303,6 +315,21 @@ export class Batch {
303315
}
304316
}
305317

318+
/**
319+
* @param {Effect[]} effects
320+
*/
321+
#defer_effects(effects) {
322+
for (const e of effects) {
323+
const target = (e.f & DIRTY) !== 0 ? this.#dirty_effects : this.#maybe_dirty_effects;
324+
target.push(e);
325+
326+
// mark as clean so they get scheduled if they depend on pending async state
327+
set_signal_status(e, CLEAN);
328+
}
329+
330+
effects.length = 0;
331+
}
332+
306333
/**
307334
* Associate a change to a given source with the current
308335
* batch, noting its previous and current values
@@ -380,8 +407,14 @@ export class Batch {
380407
this.#pending -= 1;
381408

382409
if (this.#pending === 0) {
383-
for (const source of this.current.keys()) {
384-
mark_reactions(source, DIRTY, false);
410+
for (const e of this.#dirty_effects) {
411+
set_signal_status(e, DIRTY);
412+
schedule_effect(e);
413+
}
414+
415+
for (const e of this.#maybe_dirty_effects) {
416+
set_signal_status(e, MAYBE_DIRTY);
417+
schedule_effect(e);
385418
}
386419

387420
this.#render_effects = [];

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

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -301,10 +301,9 @@ export function increment(source) {
301301
/**
302302
* @param {Value} signal
303303
* @param {number} status should be DIRTY or MAYBE_DIRTY
304-
* @param {boolean} schedule_async
305304
* @returns {void}
306305
*/
307-
export function mark_reactions(signal, status, schedule_async = true) {
306+
function mark_reactions(signal, status) {
308307
var reactions = signal.reactions;
309308
if (reactions === null) return;
310309

@@ -324,16 +323,14 @@ export function mark_reactions(signal, status, schedule_async = true) {
324323
continue;
325324
}
326325

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

334331
if ((flags & DERIVED) !== 0) {
335332
mark_reactions(/** @type {Derived} */ (reaction), MAYBE_DIRTY);
336-
} else if (should_schedule) {
333+
} else {
337334
schedule_effect(/** @type {Effect} */ (reaction));
338335
}
339336
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { tick } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
async test({ assert, target, logs }) {
6+
await tick();
7+
8+
const [increment] = target.querySelectorAll('button');
9+
10+
assert.deepEqual(logs, [false]);
11+
assert.htmlEqual(target.innerHTML, '<button>increment</button><p>0</p>');
12+
13+
increment.click();
14+
await tick();
15+
assert.deepEqual(logs, [false]);
16+
assert.htmlEqual(target.innerHTML, '<button>increment</button><p>1</p>');
17+
18+
increment.click();
19+
await tick();
20+
assert.deepEqual(logs, [false, true]);
21+
assert.htmlEqual(target.innerHTML, '<button>increment</button><p>2</p>');
22+
23+
increment.click();
24+
await tick();
25+
assert.deepEqual(logs, [false, true]);
26+
assert.htmlEqual(target.innerHTML, '<button>increment</button><p>3</p>');
27+
}
28+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script lang="ts">
2+
let count = $state(0);
3+
let two_or_larger = $derived(count >= 2);
4+
5+
$effect(() => {
6+
console.log(two_or_larger);
7+
});
8+
</script>
9+
10+
<svelte:boundary>
11+
<button onclick={() => count += 1}>increment</button>
12+
<p>{await count}</p>
13+
14+
{#snippet pending()}
15+
<p>loading...</p>
16+
{/snippet}
17+
</svelte:boundary>

0 commit comments

Comments
 (0)