Skip to content

Commit 7e40186

Browse files
authored
fix: run batch until complete (#16971)
at present we only call batch.increment() when something async happens if we're not inside a pending boundary, but that's incorrect — it means that a batch is committed before everything resolves. When work inside a pending boundary does resolve, the batch becomes a zombie. At the same time, we don't handle effects inside pending boundaries correctly. They should be deferred until the boundary (and all its parents) are ready. This PR attempts to fix that — during traversal, when we exit a pending boundary, any effects that were collected get deferred until the next flush. We also distinguish between batch.#pending (any ongoing async work) and batch.#blocking_pending (any async work that should prevent effects outside pending boundaries from being flushed).
1 parent 4b32d6d commit 7e40186

File tree

19 files changed

+326
-114
lines changed

19 files changed

+326
-114
lines changed

.changeset/mighty-mice-call.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 batches alive until all async work is complete

packages/svelte/src/internal/client/dom/blocks/async.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @import { TemplateNode, Value } from '#client' */
22
import { flatten } from '../../reactivity/async.js';
3+
import { Batch, current_batch } from '../../reactivity/batch.js';
34
import { get } from '../../runtime.js';
45
import {
56
hydrate_next,
@@ -18,8 +19,11 @@ import { get_boundary } from './boundary.js';
1819
*/
1920
export function async(node, expressions, fn) {
2021
var boundary = get_boundary();
22+
var batch = /** @type {Batch} */ (current_batch);
23+
var blocking = !boundary.is_pending();
2124

2225
boundary.update_pending_count(1);
26+
batch.increment(blocking);
2327

2428
var was_hydrating = hydrating;
2529

@@ -44,6 +48,7 @@ export function async(node, expressions, fn) {
4448
fn(node, ...values);
4549
} finally {
4650
boundary.update_pending_count(-1);
51+
batch.decrement(blocking);
4752
}
4853

4954
if (was_hydrating) {

packages/svelte/src/internal/client/dom/blocks/boundary.js

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -291,13 +291,6 @@ export class Boundary {
291291
this.#anchor.before(this.#offscreen_fragment);
292292
this.#offscreen_fragment = null;
293293
}
294-
295-
// TODO this feels like a little bit of a kludge, but until we
296-
// overhaul the boundary/batch relationship it's probably
297-
// the most pragmatic solution available to us
298-
queue_micro_task(() => {
299-
Batch.ensure().flush();
300-
});
301294
}
302295
}
303296

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

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -218,10 +218,10 @@ export function unset_context() {
218218
export async function async_body(anchor, fn) {
219219
var boundary = get_boundary();
220220
var batch = /** @type {Batch} */ (current_batch);
221-
var pending = boundary.is_pending();
221+
var blocking = !boundary.is_pending();
222222

223223
boundary.update_pending_count(1);
224-
if (!pending) batch.increment();
224+
batch.increment(blocking);
225225

226226
var active = /** @type {Effect} */ (active_effect);
227227

@@ -254,12 +254,7 @@ export async function async_body(anchor, fn) {
254254
}
255255

256256
boundary.update_pending_count(-1);
257-
258-
if (pending) {
259-
batch.flush();
260-
} else {
261-
batch.decrement();
262-
}
257+
batch.decrement(blocking);
263258

264259
unset_context();
265260
}

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

Lines changed: 109 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
RENDER_EFFECT,
1212
ROOT_EFFECT,
1313
MAYBE_DIRTY,
14-
DERIVED
14+
DERIVED,
15+
BOUNDARY_EFFECT
1516
} from '#client/constants';
1617
import { async_mode_flag } from '../../flags/index.js';
1718
import { deferred, define_property } from '../../shared/utils.js';
@@ -31,6 +32,16 @@ import { invoke_error_boundary } from '../error-handling.js';
3132
import { old_values, source, update } from './sources.js';
3233
import { inspect_effect, unlink_effect } from './effects.js';
3334

35+
/**
36+
* @typedef {{
37+
* parent: EffectTarget | null;
38+
* effect: Effect | null;
39+
* effects: Effect[];
40+
* render_effects: Effect[];
41+
* block_effects: Effect[];
42+
* }} EffectTarget
43+
*/
44+
3445
/** @type {Set<Batch>} */
3546
const batches = new Set();
3647

@@ -65,6 +76,8 @@ let is_flushing = false;
6576
export let is_flushing_sync = false;
6677

6778
export class Batch {
79+
committed = false;
80+
6881
/**
6982
* The current values of any sources that are updated in this batch
7083
* They keys of this map are identical to `this.#previous`
@@ -91,33 +104,18 @@ export class Batch {
91104
*/
92105
#pending = 0;
93106

107+
/**
108+
* The number of async effects that are currently in flight, _not_ inside a pending boundary
109+
*/
110+
#blocking_pending = 0;
111+
94112
/**
95113
* A deferred that resolves when the batch is committed, used with `settled()`
96114
* TODO replace with Promise.withResolvers once supported widely enough
97115
* @type {{ promise: Promise<void>, resolve: (value?: any) => void, reject: (reason: unknown) => void } | null}
98116
*/
99117
#deferred = null;
100118

101-
/**
102-
* Template effects and `$effect.pre` effects, which run when
103-
* a batch is committed
104-
* @type {Effect[]}
105-
*/
106-
#render_effects = [];
107-
108-
/**
109-
* The same as `#render_effects`, but for `$effect` (which runs after)
110-
* @type {Effect[]}
111-
*/
112-
#effects = [];
113-
114-
/**
115-
* Block effects, which may need to re-run on subsequent flushes
116-
* in order to update internal sources (e.g. each block items)
117-
* @type {Effect[]}
118-
*/
119-
#block_effects = [];
120-
121119
/**
122120
* Deferred effects (which run after async work has completed) that are DIRTY
123121
* @type {Effect[]}
@@ -148,41 +146,37 @@ export class Batch {
148146

149147
this.apply();
150148

149+
/** @type {EffectTarget} */
150+
var target = {
151+
parent: null,
152+
effect: null,
153+
effects: [],
154+
render_effects: [],
155+
block_effects: []
156+
};
157+
151158
for (const root of root_effects) {
152-
this.#traverse_effect_tree(root);
159+
this.#traverse_effect_tree(root, target);
153160
}
154161

155-
// if there is no outstanding async work, commit
156-
if (this.#pending === 0) {
157-
// TODO we need this because we commit _then_ flush effects...
158-
// maybe there's a way we can reverse the order?
159-
var previous_batch_sources = batch_values;
162+
this.#resolve();
160163

161-
this.#commit();
162-
163-
var render_effects = this.#render_effects;
164-
var effects = this.#effects;
165-
166-
this.#render_effects = [];
167-
this.#effects = [];
168-
this.#block_effects = [];
164+
if (this.#blocking_pending > 0) {
165+
this.#defer_effects(target.effects);
166+
this.#defer_effects(target.render_effects);
167+
this.#defer_effects(target.block_effects);
168+
} else {
169+
// TODO append/detach blocks here, not in #commit
169170

170171
// If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with
171172
// newly updated sources, which could lead to infinite loops when effects run over and over again.
172173
previous_batch = this;
173174
current_batch = null;
174175

175-
batch_values = previous_batch_sources;
176-
flush_queued_effects(render_effects);
177-
flush_queued_effects(effects);
176+
flush_queued_effects(target.render_effects);
177+
flush_queued_effects(target.effects);
178178

179179
previous_batch = null;
180-
181-
this.#deferred?.resolve();
182-
} else {
183-
this.#defer_effects(this.#render_effects);
184-
this.#defer_effects(this.#effects);
185-
this.#defer_effects(this.#block_effects);
186180
}
187181

188182
batch_values = null;
@@ -192,8 +186,9 @@ export class Batch {
192186
* Traverse the effect tree, executing effects or stashing
193187
* them for later execution as appropriate
194188
* @param {Effect} root
189+
* @param {EffectTarget} target
195190
*/
196-
#traverse_effect_tree(root) {
191+
#traverse_effect_tree(root, target) {
197192
root.f ^= CLEAN;
198193

199194
var effect = root.first;
@@ -205,15 +200,25 @@ export class Batch {
205200

206201
var skip = is_skippable_branch || (flags & INERT) !== 0 || this.skipped_effects.has(effect);
207202

203+
if ((effect.f & BOUNDARY_EFFECT) !== 0 && effect.b?.is_pending()) {
204+
target = {
205+
parent: target,
206+
effect,
207+
effects: [],
208+
render_effects: [],
209+
block_effects: []
210+
};
211+
}
212+
208213
if (!skip && effect.fn !== null) {
209214
if (is_branch) {
210215
effect.f ^= CLEAN;
211216
} else if ((flags & EFFECT) !== 0) {
212-
this.#effects.push(effect);
217+
target.effects.push(effect);
213218
} else if (async_mode_flag && (flags & RENDER_EFFECT) !== 0) {
214-
this.#render_effects.push(effect);
219+
target.render_effects.push(effect);
215220
} else if (is_dirty(effect)) {
216-
if ((effect.f & BLOCK_EFFECT) !== 0) this.#block_effects.push(effect);
221+
if ((effect.f & BLOCK_EFFECT) !== 0) target.block_effects.push(effect);
217222
update_effect(effect);
218223
}
219224

@@ -229,6 +234,17 @@ export class Batch {
229234
effect = effect.next;
230235

231236
while (effect === null && parent !== null) {
237+
if (parent === target.effect) {
238+
// TODO rather than traversing into pending boundaries and deferring the effects,
239+
// could we just attach the effects _to_ the pending boundary and schedule them
240+
// once the boundary is ready?
241+
this.#defer_effects(target.effects);
242+
this.#defer_effects(target.render_effects);
243+
this.#defer_effects(target.block_effects);
244+
245+
target = /** @type {EffectTarget} */ (target.parent);
246+
}
247+
232248
effect = parent.next;
233249
parent = parent.parent;
234250
}
@@ -246,8 +262,6 @@ export class Batch {
246262
// mark as clean so they get scheduled if they depend on pending async state
247263
set_signal_status(e, CLEAN);
248264
}
249-
250-
effects.length = 0;
251265
}
252266

253267
/**
@@ -283,8 +297,8 @@ export class Batch {
283297
// this can happen if a new batch was created during `flush_effects()`
284298
return;
285299
}
286-
} else if (this.#pending === 0) {
287-
this.#commit();
300+
} else {
301+
this.#resolve();
288302
}
289303

290304
this.deactivate();
@@ -300,24 +314,37 @@ export class Batch {
300314
}
301315
}
302316

303-
/**
304-
* Append and remove branches to/from the DOM
305-
*/
306-
#commit() {
307-
for (const fn of this.#callbacks) {
308-
fn();
317+
#resolve() {
318+
if (this.#blocking_pending === 0) {
319+
// append/remove branches
320+
for (const fn of this.#callbacks) fn();
321+
this.#callbacks.clear();
309322
}
310323

311-
this.#callbacks.clear();
324+
if (this.#pending === 0) {
325+
this.#commit();
326+
}
327+
}
312328

329+
#commit() {
313330
// If there are other pending batches, they now need to be 'rebased' —
314331
// in other words, we re-run block/async effects with the newly
315332
// committed state, unless the batch in question has a more
316333
// recent value for a given source
317334
if (batches.size > 1) {
318335
this.#previous.clear();
319336

320-
let is_earlier = true;
337+
var previous_batch_values = batch_values;
338+
var is_earlier = true;
339+
340+
/** @type {EffectTarget} */
341+
var dummy_target = {
342+
parent: null,
343+
effect: null,
344+
effects: [],
345+
render_effects: [],
346+
block_effects: []
347+
};
321348

322349
for (const batch of batches) {
323350
if (batch === this) {
@@ -359,27 +386,43 @@ export class Batch {
359386
batch.apply();
360387

361388
for (const root of queued_root_effects) {
362-
batch.#traverse_effect_tree(root);
389+
batch.#traverse_effect_tree(root, dummy_target);
363390
}
364391

392+
// TODO do we need to do anything with `target`? defer block effects?
393+
365394
queued_root_effects = [];
366395
batch.deactivate();
367396
}
368397
}
369398
}
370399

371400
current_batch = null;
401+
batch_values = previous_batch_values;
372402
}
373403

404+
this.committed = true;
374405
batches.delete(this);
406+
407+
this.#deferred?.resolve();
375408
}
376409

377-
increment() {
410+
/**
411+
*
412+
* @param {boolean} blocking
413+
*/
414+
increment(blocking) {
378415
this.#pending += 1;
416+
if (blocking) this.#blocking_pending += 1;
379417
}
380418

381-
decrement() {
419+
/**
420+
*
421+
* @param {boolean} blocking
422+
*/
423+
decrement(blocking) {
382424
this.#pending -= 1;
425+
if (blocking) this.#blocking_pending -= 1;
383426

384427
for (const e of this.#dirty_effects) {
385428
set_signal_status(e, DIRTY);
@@ -391,6 +434,9 @@ export class Batch {
391434
schedule_effect(e);
392435
}
393436

437+
this.#dirty_effects = [];
438+
this.#maybe_dirty_effects = [];
439+
394440
this.flush();
395441
}
396442

0 commit comments

Comments
 (0)