From 7c1408d6df8a3bc26cbda8ccf44c5fd200524b96 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 11:32:23 -0400 Subject: [PATCH 01/48] chore: run boundary async effects in the context of the current batch --- .changeset/selfish-pets-teach.md | 5 ++++ .../src/internal/client/reactivity/batch.js | 26 +++---------------- 2 files changed, 9 insertions(+), 22 deletions(-) create mode 100644 .changeset/selfish-pets-teach.md diff --git a/.changeset/selfish-pets-teach.md b/.changeset/selfish-pets-teach.md new file mode 100644 index 000000000000..d78fea8f9fb8 --- /dev/null +++ b/.changeset/selfish-pets-teach.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: run boundary async effects in the context of the current batch diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 2956e7ed6afe..fd2a6d9f5dcf 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -97,13 +97,6 @@ export class Batch { */ #deferred = null; - /** - * Async effects inside a newly-created `` - * — these do not prevent the batch from committing - * @type {Effect[]} - */ - #boundary_async_effects = []; - /** * Template effects and `$effect.pre` effects, which run when * a batch is committed @@ -158,8 +151,7 @@ export class Batch { this.#traverse_effect_tree(root); } - // if we didn't start any new async work, and no async work - // is outstanding from a previous flush, commit + // if there is no outstanding async work, commit if (this.#pending === 0) { // TODO we need this because we commit _then_ flush effects... // maybe there's a way we can reverse the order? @@ -193,12 +185,6 @@ export class Batch { } batch_values = null; - - for (const effect of this.#boundary_async_effects) { - update_effect(effect); - } - - this.#boundary_async_effects = []; } /** @@ -225,13 +211,9 @@ export class Batch { this.#effects.push(effect); } else if (async_mode_flag && (flags & RENDER_EFFECT) !== 0) { this.#render_effects.push(effect); - } else if ((flags & CLEAN) === 0) { - if ((flags & ASYNC) !== 0 && effect.b?.is_pending()) { - this.#boundary_async_effects.push(effect); - } else if (is_dirty(effect)) { - if ((effect.f & BLOCK_EFFECT) !== 0) this.#block_effects.push(effect); - update_effect(effect); - } + } else if (is_dirty(effect)) { + if ((effect.f & BLOCK_EFFECT) !== 0) this.#block_effects.push(effect); + update_effect(effect); } var child = effect.first; From c3db9ac13779d642b9eb4471b5fb1d381c8b2516 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 16:12:43 -0400 Subject: [PATCH 02/48] WIP --- .../internal/client/dom/blocks/boundary.js | 17 +-- .../src/internal/client/reactivity/async.js | 10 +- .../src/internal/client/reactivity/batch.js | 114 ++++++++++++------ .../internal/client/reactivity/deriveds.js | 15 +-- 4 files changed, 87 insertions(+), 69 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index ca6437e3841f..039eded21bd9 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -87,7 +87,7 @@ export class Boundary { /** @type {DocumentFragment | null} */ #offscreen_fragment = null; - #local_pending_count = 0; + local_pending_count = 0; #pending_count = 0; #is_creating_fallback = false; @@ -103,12 +103,12 @@ export class Boundary { #effect_pending_update = () => { if (this.#effect_pending) { - internal_set(this.#effect_pending, this.#local_pending_count); + internal_set(this.#effect_pending, this.local_pending_count); } }; #effect_pending_subscriber = createSubscriber(() => { - this.#effect_pending = source(this.#local_pending_count); + this.#effect_pending = source(this.local_pending_count); if (DEV) { tag(this.#effect_pending, '$effect.pending()'); @@ -285,13 +285,6 @@ export class Boundary { this.#anchor.before(this.#offscreen_fragment); this.#offscreen_fragment = null; } - - // TODO this feels like a little bit of a kludge, but until we - // overhaul the boundary/batch relationship it's probably - // the most pragmatic solution available to us - queue_micro_task(() => { - Batch.ensure().flush(); - }); } } @@ -304,7 +297,7 @@ export class Boundary { update_pending_count(d) { this.#update_pending_count(d); - this.#local_pending_count += d; + this.local_pending_count += d; effect_pending_updates.add(this.#effect_pending_update); } @@ -363,7 +356,7 @@ export class Boundary { // If the failure happened while flushing effects, current_batch can be null Batch.ensure(); - this.#local_pending_count = 0; + this.local_pending_count = 0; if (this.#failed_effect !== null) { pause_effect(this.#failed_effect, () => { diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 45c78ff926b9..b1e817794939 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -202,10 +202,9 @@ export function unset_context() { export async function async_body(fn) { var boundary = get_boundary(); var batch = /** @type {Batch} */ (current_batch); - var pending = boundary.is_pending(); boundary.update_pending_count(1); - if (!pending) batch.increment(); + batch.increment(); var active = /** @type {Effect} */ (active_effect); @@ -238,12 +237,7 @@ export async function async_body(fn) { } boundary.update_pending_count(-1); - - if (pending) { - batch.flush(); - } else { - batch.decrement(); - } + batch.decrement(); unset_context(); } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index fd2a6d9f5dcf..e76c92119c97 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -11,7 +11,8 @@ import { RENDER_EFFECT, ROOT_EFFECT, MAYBE_DIRTY, - DERIVED + DERIVED, + BOUNDARY_EFFECT } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property } from '../../shared/utils.js'; @@ -30,6 +31,16 @@ import { invoke_error_boundary } from '../error-handling.js'; import { old_values } from './sources.js'; import { unlink_effect } from './effects.js'; +/** + * @typedef {{ + * parent: EffectTarget | null; + * effect: Effect | null; + * effects: Effect[]; + * render_effects: Effect[]; + * block_effects: Effect[]; + * }} EffectTarget + */ + /** @type {Set} */ const batches = new Set(); @@ -97,26 +108,6 @@ export class Batch { */ #deferred = null; - /** - * Template effects and `$effect.pre` effects, which run when - * a batch is committed - * @type {Effect[]} - */ - #render_effects = []; - - /** - * The same as `#render_effects`, but for `$effect` (which runs after) - * @type {Effect[]} - */ - #effects = []; - - /** - * Block effects, which may need to re-run on subsequent flushes - * in order to update internal sources (e.g. each block items) - * @type {Effect[]} - */ - #block_effects = []; - /** * Deferred effects (which run after async work has completed) that are DIRTY * @type {Effect[]} @@ -155,33 +146,26 @@ export class Batch { if (this.#pending === 0) { // TODO we need this because we commit _then_ flush effects... // maybe there's a way we can reverse the order? - var previous_batch_sources = batch_values; + // var previous_batch_sources = batch_values; this.#commit(); - var render_effects = this.#render_effects; - var effects = this.#effects; - - this.#render_effects = []; - 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. previous_batch = this; current_batch = null; - batch_values = previous_batch_sources; - flush_queued_effects(render_effects); - flush_queued_effects(effects); + // batch_values = previous_batch_sources; + // flush_queued_effects(target.render_effects); + // flush_queued_effects(target.effects); previous_batch = null; this.#deferred?.resolve(); } else { - this.#defer_effects(this.#render_effects); - this.#defer_effects(this.#effects); - this.#defer_effects(this.#block_effects); + // this.#defer_effects(target.render_effects); + // this.#defer_effects(target.effects); + // this.#defer_effects(target.block_effects); } batch_values = null; @@ -195,6 +179,17 @@ export class Batch { #traverse_effect_tree(root) { root.f ^= CLEAN; + var should_defer = false; + + /** @type {EffectTarget} */ + var target = { + parent: null, + effect: null, + effects: [], + render_effects: [], + block_effects: [] + }; + var effect = root.first; while (effect !== null) { @@ -204,15 +199,25 @@ export class Batch { var skip = is_skippable_branch || (flags & INERT) !== 0 || this.skipped_effects.has(effect); + if ((effect.f & BOUNDARY_EFFECT) !== 0 && effect.b?.is_pending()) { + target = { + parent: target, + effect, + effects: [], + render_effects: [], + block_effects: [] + }; + } + if (!skip && effect.fn !== null) { if (is_branch) { effect.f ^= CLEAN; } else if ((flags & EFFECT) !== 0) { - this.#effects.push(effect); + target.effects.push(effect); } else if (async_mode_flag && (flags & RENDER_EFFECT) !== 0) { - this.#render_effects.push(effect); + target.render_effects.push(effect); } else if (is_dirty(effect)) { - if ((effect.f & BLOCK_EFFECT) !== 0) this.#block_effects.push(effect); + if ((effect.f & BLOCK_EFFECT) !== 0) target.block_effects.push(effect); update_effect(effect); } @@ -228,10 +233,41 @@ export class Batch { effect = effect.next; while (effect === null && parent !== null) { + if (parent.b !== null) { + var ready = parent.b.local_pending_count === 0; + + if (target.parent === null) { + should_defer ||= !ready; + } else if (parent === target.effect) { + if (ready) { + // TODO can this happen? + target.parent.effects.push(...target.effects); + target.parent.render_effects.push(...target.render_effects); + target.parent.block_effects.push(...target.block_effects); + } else { + this.#defer_effects(target.effects); + this.#defer_effects(target.render_effects); + this.#defer_effects(target.block_effects); + } + + target = /** @type {EffectTarget} */ (target.parent); + } + } + effect = parent.next; parent = parent.parent; } } + + if (should_defer) { + this.#defer_effects(target.effects); + this.#defer_effects(target.render_effects); + this.#defer_effects(target.block_effects); + } else { + // TODO append/detach blocks here as well + flush_queued_effects(target.render_effects); + flush_queued_effects(target.effects); + } } /** @@ -245,8 +281,6 @@ export class Batch { // mark as clean so they get scheduled if they depend on pending async state set_signal_status(e, CLEAN); } - - effects.length = 0; } /** diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 6aa9a1d9d920..e9d5edca05a3 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -136,17 +136,14 @@ export function async_derived(fn, location) { if (DEV) current_async_effect = null; var batch = /** @type {Batch} */ (current_batch); - var pending = boundary.is_pending(); if (should_suspend) { boundary.update_pending_count(1); - if (!pending) { - batch.increment(); + batch.increment(); - deferreds.get(batch)?.reject(STALE_REACTION); - deferreds.delete(batch); // delete to ensure correct order in Map iteration below - deferreds.set(batch, d); - } + deferreds.get(batch)?.reject(STALE_REACTION); + deferreds.delete(batch); // delete to ensure correct order in Map iteration below + deferreds.set(batch, d); } /** @@ -156,7 +153,7 @@ export function async_derived(fn, location) { const handler = (value, error = undefined) => { current_async_effect = null; - if (!pending) batch.activate(); + batch.activate(); if (error) { if (error !== STALE_REACTION) { @@ -193,7 +190,7 @@ export function async_derived(fn, location) { if (should_suspend) { boundary.update_pending_count(-1); - if (!pending) batch.decrement(); + batch.decrement(); } }; From 1e2958e5ac70d9f7174068936c487ac0295d3852 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 16:43:29 -0400 Subject: [PATCH 03/48] reinstate kludge --- packages/svelte/src/internal/client/dom/blocks/boundary.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 039eded21bd9..b47857d84303 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -285,6 +285,13 @@ export class Boundary { this.#anchor.before(this.#offscreen_fragment); this.#offscreen_fragment = null; } + + // TODO this feels like a little bit of a kludge, but until we + // overhaul the boundary/batch relationship it's probably + // the most pragmatic solution available to us + queue_micro_task(() => { + Batch.ensure().flush(); + }); } } From ae0038c9536f7d7db0ec05394a1437dcb74b9e60 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 17:04:41 -0400 Subject: [PATCH 04/48] fix test --- packages/svelte/src/internal/client/reactivity/batch.js | 4 ++-- .../tests/runtime-runes/samples/async-abort-signal/_config.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index e76c92119c97..c429b23c98c5 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -160,8 +160,6 @@ export class Batch { // flush_queued_effects(target.effects); previous_batch = null; - - this.#deferred?.resolve(); } else { // this.#defer_effects(target.render_effects); // this.#defer_effects(target.effects); @@ -405,6 +403,8 @@ export class Batch { } batches.delete(this); + + this.#deferred?.resolve(); } increment() { diff --git a/packages/svelte/tests/runtime-runes/samples/async-abort-signal/_config.js b/packages/svelte/tests/runtime-runes/samples/async-abort-signal/_config.js index a947a91ab881..af49b1779c01 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-abort-signal/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-abort-signal/_config.js @@ -6,7 +6,7 @@ export default test({ const [reset, resolve] = target.querySelectorAll('button'); reset.click(); - await settled(); + await tick(); assert.deepEqual(logs, ['aborted']); resolve.click(); From 18ecf0138291f4e09a44cfc9a6facd2e683b04d9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 17:28:35 -0400 Subject: [PATCH 05/48] WIP --- .../src/internal/client/reactivity/async.js | 6 ++++-- .../src/internal/client/reactivity/batch.js | 21 ++++++++++++++++--- .../internal/client/reactivity/deriveds.js | 6 ++++-- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index b1e817794939..a223d1b5beba 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -203,8 +203,10 @@ export async function async_body(fn) { var boundary = get_boundary(); var batch = /** @type {Batch} */ (current_batch); + var blocking = !boundary.is_pending(); + boundary.update_pending_count(1); - batch.increment(); + batch.increment(blocking); var active = /** @type {Effect} */ (active_effect); @@ -237,7 +239,7 @@ export async function async_body(fn) { } boundary.update_pending_count(-1); - batch.decrement(); + batch.decrement(blocking); unset_context(); } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index c429b23c98c5..413cf63284cf 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -101,6 +101,11 @@ export class Batch { */ #pending = 0; + /** + * The number of async effects that are currently in flight, _not_ inside a pending boundary + */ + #blocking_pending = 0; + /** * A deferred that resolves when the batch is committed, used with `settled()` * TODO replace with Promise.withResolvers once supported widely enough @@ -257,7 +262,7 @@ export class Batch { } } - if (should_defer) { + if (this.#blocking_pending > 0) { this.#defer_effects(target.effects); this.#defer_effects(target.render_effects); this.#defer_effects(target.block_effects); @@ -407,12 +412,22 @@ export class Batch { this.#deferred?.resolve(); } - increment() { + /** + * + * @param {boolean} blocking + */ + increment(blocking) { this.#pending += 1; + if (blocking) this.#blocking_pending += 1; } - decrement() { + /** + * + * @param {boolean} blocking + */ + decrement(blocking) { this.#pending -= 1; + if (blocking) this.#blocking_pending -= 1; for (const e of this.#dirty_effects) { set_signal_status(e, DIRTY); diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index e9d5edca05a3..1989220abe9c 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -138,8 +138,10 @@ export function async_derived(fn, location) { var batch = /** @type {Batch} */ (current_batch); if (should_suspend) { + var blocking = !boundary.is_pending(); + boundary.update_pending_count(1); - batch.increment(); + batch.increment(blocking); deferreds.get(batch)?.reject(STALE_REACTION); deferreds.delete(batch); // delete to ensure correct order in Map iteration below @@ -190,7 +192,7 @@ export function async_derived(fn, location) { if (should_suspend) { boundary.update_pending_count(-1); - batch.decrement(); + batch.decrement(blocking); } }; From 739f5fc1c6f6c1788894b6880648f19c20261174 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 18:16:21 -0400 Subject: [PATCH 06/48] WIP --- .../src/internal/client/reactivity/batch.js | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 413cf63284cf..1e5bbc3e4aba 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -182,8 +182,6 @@ export class Batch { #traverse_effect_tree(root) { root.f ^= CLEAN; - var should_defer = false; - /** @type {EffectTarget} */ var target = { parent: null, @@ -236,25 +234,15 @@ export class Batch { effect = effect.next; while (effect === null && parent !== null) { - if (parent.b !== null) { - var ready = parent.b.local_pending_count === 0; - - if (target.parent === null) { - should_defer ||= !ready; - } else if (parent === target.effect) { - if (ready) { - // TODO can this happen? - target.parent.effects.push(...target.effects); - target.parent.render_effects.push(...target.render_effects); - target.parent.block_effects.push(...target.block_effects); - } else { - this.#defer_effects(target.effects); - this.#defer_effects(target.render_effects); - this.#defer_effects(target.block_effects); - } - - target = /** @type {EffectTarget} */ (target.parent); - } + if (parent === target.effect) { + // TODO rather than traversing into pending boundaries and deferring the effects, + // could we just attach the effects _to_ the pending boundary and schedule them + // once the boundary is ready? + this.#defer_effects(target.effects); + this.#defer_effects(target.render_effects); + this.#defer_effects(target.block_effects); + + target = /** @type {EffectTarget} */ (target.parent); } effect = parent.next; From d8df737f56e4cca980409360959137be9208e4d2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 19:50:10 -0400 Subject: [PATCH 07/48] WIP --- .../src/internal/client/reactivity/batch.js | 71 +++++++++++-------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 1e5bbc3e4aba..ce58d05087b9 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -143,8 +143,17 @@ export class Batch { this.apply(); + /** @type {EffectTarget} */ + var target = { + parent: null, + effect: null, + effects: [], + render_effects: [], + block_effects: [] + }; + for (const root of root_effects) { - this.#traverse_effect_tree(root); + this.#traverse_effect_tree(root, target); } // if there is no outstanding async work, commit @@ -155,22 +164,33 @@ export class Batch { this.#commit(); - // 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. - previous_batch = this; - current_batch = null; - // batch_values = previous_batch_sources; // flush_queued_effects(target.render_effects); // flush_queued_effects(target.effects); - - previous_batch = null; } else { // this.#defer_effects(target.render_effects); // this.#defer_effects(target.effects); // this.#defer_effects(target.block_effects); } + if (this.#blocking_pending > 0) { + this.#defer_effects(target.effects); + this.#defer_effects(target.render_effects); + this.#defer_effects(target.block_effects); + } else { + // TODO append/detach blocks here as well, not in #commit + + // 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. + previous_batch = this; + current_batch = null; + + flush_queued_effects(target.render_effects); + flush_queued_effects(target.effects); + + previous_batch = null; + } + batch_values = null; } @@ -178,19 +198,11 @@ export class Batch { * Traverse the effect tree, executing effects or stashing * them for later execution as appropriate * @param {Effect} root + * @param {EffectTarget} target */ - #traverse_effect_tree(root) { + #traverse_effect_tree(root, target) { root.f ^= CLEAN; - /** @type {EffectTarget} */ - var target = { - parent: null, - effect: null, - effects: [], - render_effects: [], - block_effects: [] - }; - var effect = root.first; while (effect !== null) { @@ -249,16 +261,6 @@ export class Batch { parent = parent.parent; } } - - if (this.#blocking_pending > 0) { - this.#defer_effects(target.effects); - this.#defer_effects(target.render_effects); - this.#defer_effects(target.block_effects); - } else { - // TODO append/detach blocks here as well - flush_queued_effects(target.render_effects); - flush_queued_effects(target.effects); - } } /** @@ -343,6 +345,15 @@ export class Batch { let is_earlier = true; + /** @type {EffectTarget} */ + var dummy_target = { + parent: null, + effect: null, + effects: [], + render_effects: [], + block_effects: [] + }; + for (const batch of batches) { if (batch === this) { is_earlier = false; @@ -383,9 +394,11 @@ export class Batch { batch.apply(); for (const root of queued_root_effects) { - batch.#traverse_effect_tree(root); + batch.#traverse_effect_tree(root, dummy_target); } + // TODO do we need to do anything with `target`? defer block effects? + queued_root_effects = []; batch.deactivate(); } From c054cd4f3f419745624d7dcd537c48f506b449f8 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 20:10:28 -0400 Subject: [PATCH 08/48] remove kludge --- packages/svelte/src/internal/client/dom/blocks/async.js | 6 ++++++ packages/svelte/src/internal/client/dom/blocks/boundary.js | 7 ------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js index 6df0739918ed..a1f6c6a308e5 100644 --- a/packages/svelte/src/internal/client/dom/blocks/async.js +++ b/packages/svelte/src/internal/client/dom/blocks/async.js @@ -1,5 +1,6 @@ /** @import { TemplateNode, Value } from '#client' */ import { flatten } from '../../reactivity/async.js'; +import { Batch, current_batch } from '../../reactivity/batch.js'; import { get } from '../../runtime.js'; import { hydrate_next, @@ -18,8 +19,12 @@ import { get_boundary } from './boundary.js'; */ export function async(node, expressions, fn) { var boundary = get_boundary(); + var batch = /** @type {Batch} */ (current_batch); + + var blocking = !boundary.is_pending(); boundary.update_pending_count(1); + batch.increment(blocking); var was_hydrating = hydrating; @@ -44,6 +49,7 @@ export function async(node, expressions, fn) { fn(node, ...values); } finally { boundary.update_pending_count(-1); + batch.decrement(blocking); } if (was_hydrating) { diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index b47857d84303..039eded21bd9 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -285,13 +285,6 @@ export class Boundary { this.#anchor.before(this.#offscreen_fragment); this.#offscreen_fragment = null; } - - // TODO this feels like a little bit of a kludge, but until we - // overhaul the boundary/batch relationship it's probably - // the most pragmatic solution available to us - queue_micro_task(() => { - Batch.ensure().flush(); - }); } } From bba5314dab7c3832bdce0c4a484bb9b20e4ac887 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 20:17:13 -0400 Subject: [PATCH 09/48] restore batch_values after commit --- packages/svelte/src/internal/client/reactivity/batch.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index ce58d05087b9..b7c78b519660 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -343,7 +343,8 @@ export class Batch { if (batches.size > 1) { this.#previous.clear(); - let is_earlier = true; + var previous_batch_values = batch_values; + var is_earlier = true; /** @type {EffectTarget} */ var dummy_target = { @@ -406,6 +407,7 @@ export class Batch { } current_batch = null; + batch_values = previous_batch_values; } batches.delete(this); From 46d61eeec65df4a9c94721de1ff52a3d12b8240a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 21:31:57 -0400 Subject: [PATCH 10/48] make private --- .../svelte/src/internal/client/dom/blocks/boundary.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 039eded21bd9..4945cc16d0ad 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -87,7 +87,7 @@ export class Boundary { /** @type {DocumentFragment | null} */ #offscreen_fragment = null; - local_pending_count = 0; + #local_pending_count = 0; #pending_count = 0; #is_creating_fallback = false; @@ -103,12 +103,12 @@ export class Boundary { #effect_pending_update = () => { if (this.#effect_pending) { - internal_set(this.#effect_pending, this.local_pending_count); + internal_set(this.#effect_pending, this.#local_pending_count); } }; #effect_pending_subscriber = createSubscriber(() => { - this.#effect_pending = source(this.local_pending_count); + this.#effect_pending = source(this.#local_pending_count); if (DEV) { tag(this.#effect_pending, '$effect.pending()'); @@ -297,7 +297,7 @@ export class Boundary { update_pending_count(d) { this.#update_pending_count(d); - this.local_pending_count += d; + this.#local_pending_count += d; effect_pending_updates.add(this.#effect_pending_update); } @@ -356,7 +356,7 @@ export class Boundary { // If the failure happened while flushing effects, current_batch can be null Batch.ensure(); - this.local_pending_count = 0; + this.#local_pending_count = 0; if (this.#failed_effect !== null) { pause_effect(this.#failed_effect, () => { From 90e3148a562e7d2f546cf80040a83c02e6920ce2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 16 Oct 2025 21:33:42 -0400 Subject: [PATCH 11/48] tidy up --- .../src/internal/client/reactivity/batch.js | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index b7c78b519660..b74ce0ba9b2e 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -158,19 +158,9 @@ export class Batch { // if there is no outstanding async work, commit if (this.#pending === 0) { - // TODO we need this because we commit _then_ flush effects... - // maybe there's a way we can reverse the order? - // var previous_batch_sources = batch_values; - + // commit before flushing effects, since that may result in + // another batch being created this.#commit(); - - // batch_values = previous_batch_sources; - // flush_queued_effects(target.render_effects); - // flush_queued_effects(target.effects); - } else { - // this.#defer_effects(target.render_effects); - // this.#defer_effects(target.effects); - // this.#defer_effects(target.block_effects); } if (this.#blocking_pending > 0) { @@ -178,7 +168,7 @@ export class Batch { this.#defer_effects(target.render_effects); this.#defer_effects(target.block_effects); } else { - // TODO append/detach blocks here as well, not in #commit + // TODO append/detach blocks here, not in #commit // 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. From e31956de853e90cc02a4925222e8baa405e42494 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 20 Oct 2025 17:58:37 -0400 Subject: [PATCH 12/48] fix tests --- .../samples/async-binding-update-while-focused-2/main.svelte | 2 +- .../samples/async-binding-update-while-focused-3/main.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte index e2f01a66c892..c0e4d862a8e5 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte @@ -12,7 +12,7 @@ diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/main.svelte index 566ea60ec5cf..8f5e2862eb05 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/main.svelte @@ -12,7 +12,7 @@ From fdc511416e553032f4d5967e02e73b7563d936e8 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 20 Oct 2025 20:01:21 -0400 Subject: [PATCH 13/48] update test --- .../samples/async-block-reject-each-during-init/_config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/svelte/tests/runtime-runes/samples/async-block-reject-each-during-init/_config.js b/packages/svelte/tests/runtime-runes/samples/async-block-reject-each-during-init/_config.js index c5dae7fee294..ca5fd9ca8974 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-block-reject-each-during-init/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-block-reject-each-during-init/_config.js @@ -24,5 +24,7 @@ export default test({

1

` ); - } + }, + + expect_unhandled_rejections: true }); From db41cc46cb85d24e1bc15da334f028314b059b5a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 20 Oct 2025 20:14:22 -0400 Subject: [PATCH 14/48] reset #dirty_effects and #maybe_dirty_effects --- packages/svelte/src/internal/client/reactivity/batch.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index a9973b465b45..215b992cc419 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -433,6 +433,9 @@ export class Batch { schedule_effect(e); } + this.#dirty_effects = []; + this.#maybe_dirty_effects = []; + this.flush(); } From cfa87a9c3762823a509fd38563391556dfbc2d88 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 20 Oct 2025 20:47:20 -0400 Subject: [PATCH 15/48] add test --- .../async-effect-after-boundary/Child.svelte | 5 +++++ .../async-effect-after-boundary/_config.js | 16 ++++++++++++++ .../async-effect-after-boundary/main.svelte | 22 +++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/Child.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/main.svelte diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/Child.svelte new file mode 100644 index 000000000000..65a225431b18 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/Child.svelte @@ -0,0 +1,5 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/_config.js new file mode 100644 index 000000000000..f7b6c513d4cc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/_config.js @@ -0,0 +1,16 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [shift] = target.querySelectorAll('button'); + + await tick(); + assert.deepEqual(logs, []); + + shift.click(); + await tick(); + + assert.deepEqual(logs, ['in effect']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/main.svelte new file mode 100644 index 000000000000..edfd3c4d10ac --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-after-boundary/main.svelte @@ -0,0 +1,22 @@ + + + + + +

{await push('hello')}

+ + + {#snippet pending()} +

loading...

+ {/snippet} +
From ee1b1140d36b572cd6ff67067c6d5e66fbae7f4b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 20 Oct 2025 21:24:27 -0400 Subject: [PATCH 16/48] WIP --- packages/svelte/src/index-client.js | 2 +- packages/svelte/src/index-server.js | 4 + .../src/internal/client/reactivity/batch.js | 78 ++++++++++++++++--- packages/svelte/types/index.d.ts | 15 ++-- 4 files changed, 81 insertions(+), 18 deletions(-) diff --git a/packages/svelte/src/index-client.js b/packages/svelte/src/index-client.js index 337cbb500b39..4fcfff980dd8 100644 --- a/packages/svelte/src/index-client.js +++ b/packages/svelte/src/index-client.js @@ -241,7 +241,7 @@ function init_update_callbacks(context) { return (l.u ??= { a: [], b: [], m: [] }); } -export { flushSync } from './internal/client/reactivity/batch.js'; +export { flushSync, fork } from './internal/client/reactivity/batch.js'; export { createContext, getContext, diff --git a/packages/svelte/src/index-server.js b/packages/svelte/src/index-server.js index 223ce6a4cde1..61b0d98c0650 100644 --- a/packages/svelte/src/index-server.js +++ b/packages/svelte/src/index-server.js @@ -33,6 +33,10 @@ export function unmount() { e.lifecycle_function_unavailable('unmount'); } +export function fork() { + e.lifecycle_function_unavailable('fork'); +} + export async function tick() {} export async function settled() {} diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 215b992cc419..17ba2c9ed49d 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -114,6 +114,13 @@ export class Batch { */ #deferred = null; + /** + * A deferred that resolves when a fork is ready + * TODO replace with Promise.withResolvers once supported widely enough + * @type {{ promise: Promise, resolve: (value?: any) => void, reject: (reason: unknown) => void } | null} + */ + #fork_deferred = null; + /** * Deferred effects (which run after async work has completed) that are DIRTY * @type {Effect[]} @@ -133,6 +140,8 @@ export class Batch { */ skipped_effects = new Set(); + is_fork = false; + /** * * @param {Effect[]} root_effects @@ -159,17 +168,25 @@ export class Batch { // if there is no outstanding async work, commit if (this.#pending === 0) { - // commit before flushing effects, since that may result in - // another batch being created - this.#commit(); + if (this.is_fork) { + this.#fork_deferred?.resolve(); + } else { + // commit before flushing effects, since that may result in + // another batch being created + this.#commit(); + } } - if (this.#blocking_pending > 0) { + if (this.#blocking_pending > 0 || this.is_fork) { this.#defer_effects(target.effects); this.#defer_effects(target.render_effects); this.#defer_effects(target.block_effects); } else { - // TODO append/detach blocks here, not in #commit + for (const fn of this.#callbacks) { + fn(); + } + + this.#callbacks.clear(); // 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. @@ -301,7 +318,7 @@ export class Batch { return; } } else if (this.#pending === 0) { - this.#commit(); + this.process([]); // TODO this feels awkward } this.deactivate(); @@ -321,12 +338,6 @@ export class Batch { * Append and remove branches to/from the DOM */ #commit() { - for (const fn of this.#callbacks) { - fn(); - } - - this.#callbacks.clear(); - // If there are other pending batches, they now need to be 'rebased' — // in other words, we re-run block/async effects with the newly // committed state, unless the batch in question has a more @@ -423,6 +434,10 @@ export class Batch { this.#pending -= 1; if (blocking) this.#blocking_pending -= 1; + this.revive(); + } + + revive() { for (const e of this.#dirty_effects) { set_signal_status(e, DIRTY); schedule_effect(e); @@ -448,6 +463,10 @@ export class Batch { return (this.#deferred ??= deferred()).promise; } + fork_settled() { + return (this.#fork_deferred ??= deferred()).promise; + } + static ensure() { if (current_batch === null) { const batch = (current_batch = new Batch()); @@ -795,3 +814,38 @@ export function eager(fn) { export function clear() { batches.clear(); } + +/** + * @param {() => void} fn + * @returns {Promise<{ commit: () => void, discard: () => void }>} + */ +export function fork(fn) { + /** @type {Promise<{ commit: () => void, discard: () => void }>} */ + const promise = new Promise((fulfil) => { + // TODO does qmt guarantee this will run outside a batch? + // because it needs to + queue_micro_task(async () => { + const batch = Batch.ensure(); + batch.is_fork = true; + + fn(); + await batch.fork_settled(); + + // TODO revert state changes + + fulfil({ + commit: () => { + // TODO reapply state changes + batch.is_fork = false; + batch.activate(); + batch.revive(); + }, + discard: () => { + batches.delete(batch); + } + }); + }); + }); + + return promise; +} diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index d260b738c3cf..0823fd942bdd 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -434,11 +434,6 @@ declare module 'svelte' { * @deprecated Use [`$effect`](https://svelte.dev/docs/svelte/$effect) instead * */ export function afterUpdate(fn: () => void): void; - /** - * Synchronously flush any pending updates. - * Returns void if no callback is provided, otherwise returns the result of calling the callback. - * */ - export function flushSync(fn?: (() => T) | undefined): T; /** * Create a snippet programmatically * */ @@ -448,6 +443,16 @@ declare module 'svelte' { }): Snippet; /** Anything except a function */ type NotFunction = T extends Function ? never : T; + /** + * Synchronously flush any pending updates. + * Returns void if no callback is provided, otherwise returns the result of calling the callback. + * */ + export function flushSync(fn?: (() => T) | undefined): T; + + export function fork(fn: () => void): Promise<{ + commit: () => void; + discard: () => void; + }>; /** * Returns a `[get, set]` pair of functions for working with context in a type-safe way. * From 925dbebdd64467cf677c86594b4764c609349f89 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 21 Oct 2025 12:23:17 -0700 Subject: [PATCH 17/48] add test, fix block resolution --- .../src/internal/client/reactivity/batch.js | 28 ++++----- .../samples/async-block-resolve/_config.js | 63 +++++++++++++++++++ .../samples/async-block-resolve/main.svelte | 36 +++++++++++ 3 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-block-resolve/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-block-resolve/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 215b992cc419..f35003db10d8 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -157,12 +157,7 @@ export class Batch { this.#traverse_effect_tree(root, target); } - // if there is no outstanding async work, commit - if (this.#pending === 0) { - // commit before flushing effects, since that may result in - // another batch being created - this.#commit(); - } + this.#resolve(); if (this.#blocking_pending > 0) { this.#defer_effects(target.effects); @@ -300,8 +295,8 @@ export class Batch { // this can happen if a new batch was created during `flush_effects()` return; } - } else if (this.#pending === 0) { - this.#commit(); + } else { + this.#resolve(); } this.deactivate(); @@ -317,16 +312,19 @@ export class Batch { } } - /** - * Append and remove branches to/from the DOM - */ - #commit() { - for (const fn of this.#callbacks) { - fn(); + #resolve() { + if (this.#blocking_pending === 0) { + // append/remove branches + for (const fn of this.#callbacks) fn(); + this.#callbacks.clear(); } - this.#callbacks.clear(); + if (this.#pending === 0) { + this.#commit(); + } + } + #commit() { // If there are other pending batches, they now need to be 'rebased' — // in other words, we re-run block/async effects with the newly // committed state, unless the batch in question has a more diff --git a/packages/svelte/tests/runtime-runes/samples/async-block-resolve/_config.js b/packages/svelte/tests/runtime-runes/samples/async-block-resolve/_config.js new file mode 100644 index 000000000000..ee403290bc6a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-block-resolve/_config.js @@ -0,0 +1,63 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [increment, shift] = target.querySelectorAll('button'); + + shift.click(); + await tick(); + + shift.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

even

+

0

+ ` + ); + + increment.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

even

+

0

+ ` + ); + + shift.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

odd

+

loading...

+ ` + ); + + shift.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

odd

+

1

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-block-resolve/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-block-resolve/main.svelte new file mode 100644 index 000000000000..73fe83889a87 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-block-resolve/main.svelte @@ -0,0 +1,36 @@ + + + + + + + {#if await push(count) % 2 === 0} +

even

+ {:else} +

odd

+ {/if} + + {#key count} + +

{await push(count)}

+ + {#snippet pending()} +

loading...

+ {/snippet} +
+ {/key} + + {#snippet pending()} +

loading...

+ {/snippet} +
From bf9065448a8a9ceb0cb044cfcb6a9dd9532bef8c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 21 Oct 2025 12:44:34 -0700 Subject: [PATCH 18/48] bring async-effect-after-await test from defer-effects-in-pending-boundary branch --- .../samples/async-effect-after-await/Child.svelte | 6 +++++- .../samples/async-effect-after-await/_config.js | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/Child.svelte index 682f7a063179..758ebc00040c 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/Child.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/Child.svelte @@ -1,7 +1,11 @@ diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/_config.js index 81548a25ea67..0908b6a9feb9 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-after-await/_config.js @@ -3,7 +3,8 @@ import { test } from '../../test'; export default test({ async test({ assert, logs }) { + assert.deepEqual(logs, []); await tick(); - assert.deepEqual(logs, ['hello']); + assert.deepEqual(logs, ['before', 'after']); } }); From 14ba41e7fd47f7abbf565ac42e7f7a874bc8c7ce Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 21 Oct 2025 17:12:35 -0700 Subject: [PATCH 19/48] avoid reawakening committed batches --- .../src/internal/client/reactivity/async.js | 2 +- .../src/internal/client/reactivity/batch.js | 3 ++ .../internal/client/reactivity/deriveds.js | 12 +++++- .../samples/async-resolve-stale/_config.js | 4 ++ .../samples/async-resolve-stale/main.svelte | 40 ++++++++++--------- 5 files changed, 41 insertions(+), 20 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 24ff4793eac2..a30886833685 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -1,5 +1,5 @@ /** @import { Effect, TemplateNode, Value } from '#client' */ -import { DESTROYED } from '#client/constants'; +import { DESTROYED, STALE_REACTION } from '#client/constants'; import { DEV } from 'esm-env'; import { component_context, diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 5414dd2a5426..91635bd5d2b4 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -76,6 +76,8 @@ let is_flushing = false; export let is_flushing_sync = false; export class Batch { + committed = false; + /** * The current values of any sources that are updated in this batch * They keys of this map are identical to `this.#previous` @@ -399,6 +401,7 @@ export class Batch { batch_values = previous_batch_values; } + this.committed = true; batches.delete(this); this.#deferred?.resolve(); diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 1989220abe9c..06ae0f6d7a2e 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -127,7 +127,17 @@ export function async_derived(fn, location) { // If this code is changed at some point, make sure to still access the then property // of fn() to read any signals it might access, so that we track them as dependencies. // We call `unset_context` to undo any `save` calls that happen inside `fn()` - Promise.resolve(fn()).then(d.resolve, d.reject).then(unset_context); + Promise.resolve(fn()) + .then(d.resolve, d.reject) + .then(() => { + if (batch === current_batch && batch.committed) { + // if the batch was rejected as stale, we need to cleanup + // after any `$.save(...)` calls inside `fn()` + batch.deactivate(); + } + + unset_context(); + }); } catch (error) { d.reject(error); unset_context(); diff --git a/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/_config.js b/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/_config.js index 50bb414afc8b..c02abb59c633 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/_config.js @@ -21,5 +21,9 @@ export default test({ input.dispatchEvent(new Event('input', { bubbles: true })); await macrotask(6); assert.htmlEqual(target.innerHTML, ' 3 | 12'); + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + await macrotask(); + assert.htmlEqual(target.innerHTML, ' 4 | '); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/main.svelte index dc4a157928a3..2a36942ff256 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-resolve-stale/main.svelte @@ -1,28 +1,32 @@ + + + + + +

count: {count}

+

eager: {$state.eager(count)}

+ + + {#if await push(count) % 2 === 0} +

even

+ {:else} +

odd

+ {/if} + + {#snippet pending()} +

loading...

+ {/snippet} +
From a2b892bb36a919d6c11cad2d7d47b80ae074397d Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 09:22:04 -0400 Subject: [PATCH 28/48] Update feature description for fork API --- .changeset/small-geckos-camp.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/small-geckos-camp.md diff --git a/.changeset/small-geckos-camp.md b/.changeset/small-geckos-camp.md new file mode 100644 index 000000000000..622cbbbfa053 --- /dev/null +++ b/.changeset/small-geckos-camp.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +feat: experimental `fork` API From 53cc91dd0c28e278c7d1ce9749ea4646005b67fc Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 09:30:48 -0400 Subject: [PATCH 29/48] error if missing experimental flag --- .../98-reference/.generated/client-errors.md | 6 ++++++ packages/svelte/messages/client-errors/errors.md | 4 ++++ packages/svelte/src/internal/client/errors.js | 16 ++++++++++++++++ .../src/internal/client/reactivity/batch.js | 4 ++++ 4 files changed, 30 insertions(+) diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 8fdb7770aa9b..9b3ec7dd71c4 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -130,6 +130,12 @@ $effect(() => { Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency. +### experimental_async_fork + +``` +Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` +``` + ### flush_sync_in_effect ``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index 57ecca048977..5de398432aab 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -100,6 +100,10 @@ $effect(() => { Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency. +## experimental_async_fork + +> Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` + ## flush_sync_in_effect > Cannot use `flushSync` inside an effect diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 937971da5e0b..730de76d2c60 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -439,4 +439,20 @@ export function svelte_boundary_reset_onerror() { } else { throw new Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`); } +} + +/** + * Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` + * @returns {never} + */ +export function experimental_async_fork() { + if (DEV) { + const error = new Error(`experimental_async_fork\nCannot use \`fork(...)\` unless the \`experimental.async\` compiler option is \`true\`\nhttps://svelte.dev/e/experimental_async_fork`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/experimental_async_fork`); + } } \ No newline at end of file diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index c28ad97ffcd1..147a0d191313 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -877,6 +877,10 @@ export function eager(fn) { * @returns {{ commit: () => void, discard: () => void }} */ export function fork(fn) { + if (!async_mode_flag) { + e.experimental_async_fork(); + } + if (current_batch !== null) { throw new Error('cannot fork here'); // TODO better error } From 8285fc6c4f0c0273e005ee5a659de00077f03756 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 09:35:35 -0400 Subject: [PATCH 30/48] rename inspect effects to eager effects, run them in prod --- .../svelte/src/internal/client/constants.js | 2 +- .../svelte/src/internal/client/dev/inspect.js | 4 +-- packages/svelte/src/internal/client/proxy.js | 8 ++--- .../src/internal/client/reactivity/batch.js | 26 +++++++------- .../internal/client/reactivity/deriveds.js | 8 ++--- .../src/internal/client/reactivity/effects.js | 8 ++--- .../src/internal/client/reactivity/sources.js | 34 +++++++++---------- 7 files changed, 45 insertions(+), 45 deletions(-) diff --git a/packages/svelte/src/internal/client/constants.js b/packages/svelte/src/internal/client/constants.js index 1f35add2a88a..0fb4a4438924 100644 --- a/packages/svelte/src/internal/client/constants.js +++ b/packages/svelte/src/internal/client/constants.js @@ -16,7 +16,7 @@ export const DESTROYED = 1 << 14; export const EFFECT_RAN = 1 << 15; /** 'Transparent' effects do not create a transition boundary */ export const EFFECT_TRANSPARENT = 1 << 16; -export const INSPECT_EFFECT = 1 << 17; +export const EAGER_EFFECT = 1 << 17; export const HEAD_EFFECT = 1 << 18; export const EFFECT_PRESERVED = 1 << 19; export const USER_EFFECT = 1 << 20; diff --git a/packages/svelte/src/internal/client/dev/inspect.js b/packages/svelte/src/internal/client/dev/inspect.js index db7ab0d976b7..09150d6ee4e7 100644 --- a/packages/svelte/src/internal/client/dev/inspect.js +++ b/packages/svelte/src/internal/client/dev/inspect.js @@ -1,6 +1,6 @@ import { UNINITIALIZED } from '../../../constants.js'; import { snapshot } from '../../shared/clone.js'; -import { inspect_effect, render_effect, validate_effect } from '../reactivity/effects.js'; +import { eager_effect, render_effect, validate_effect } from '../reactivity/effects.js'; import { untrack } from '../runtime.js'; import { get_stack } from './tracing.js'; @@ -19,7 +19,7 @@ export function inspect(get_value, inspector, show_stack = false) { // stack traces. As a consequence, reading the value might result // in an error (an `$inspect(object.property)` will run before the // `{#if object}...{/if}` that contains it) - inspect_effect(() => { + eager_effect(() => { try { var value = get_value(); } catch (e) { diff --git a/packages/svelte/src/internal/client/proxy.js b/packages/svelte/src/internal/client/proxy.js index dae3791eb0c7..9baacacd0df7 100644 --- a/packages/svelte/src/internal/client/proxy.js +++ b/packages/svelte/src/internal/client/proxy.js @@ -19,8 +19,8 @@ import { state as source, set, increment, - flush_inspect_effects, - set_inspect_effects_deferred + flush_eager_effects, + set_eager_effects_deferred } from './reactivity/sources.js'; import { PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants'; import { UNINITIALIZED } from '../../constants.js'; @@ -421,9 +421,9 @@ function inspectable_array(array) { * @param {any[]} args */ return function (...args) { - set_inspect_effects_deferred(); + set_eager_effects_deferred(); var result = value.apply(this, args); - flush_inspect_effects(); + flush_eager_effects(); return result; }; } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 147a0d191313..bae1cba100c7 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -13,7 +13,7 @@ import { MAYBE_DIRTY, DERIVED, BOUNDARY_EFFECT, - INSPECT_EFFECT + EAGER_EFFECT } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property } from '../../shared/utils.js'; @@ -33,14 +33,14 @@ import { flush_tasks, queue_micro_task } from '../dom/task.js'; import { DEV } from 'esm-env'; import { invoke_error_boundary } from '../error-handling.js'; import { - flush_inspect_effects, - inspect_effects, + flush_eager_effects, + eager_effects, old_values, - set_inspect_effects, + set_eager_effects, source, update } from './sources.js'; -import { inspect_effect, unlink_effect } from './effects.js'; +import { eager_effect, unlink_effect } from './effects.js'; /** * @typedef {{ @@ -745,14 +745,14 @@ function mark_effects(value, sources, marked, checked) { * @param {Value} value * @param {Set} effects */ -function mark_inspect_effects(value, effects) { +function mark_eager_effects(value, effects) { if (value.reactions !== null) { for (const reaction of value.reactions) { const flags = reaction.f; if ((flags & DERIVED) !== 0) { - mark_inspect_effects(/** @type {Derived} */ (reaction), effects); - } else if ((flags & INSPECT_EFFECT) !== 0) { + mark_eager_effects(/** @type {Derived} */ (reaction), effects); + } else if ((flags & EAGER_EFFECT) !== 0) { set_signal_status(reaction, DIRTY); effects.add(/** @type {Effect} */ (reaction)); } @@ -841,7 +841,7 @@ export function eager(fn) { get(version); - inspect_effect(() => { + eager_effect(() => { if (initial) { // the first time this runs, we create an inspect effect // that will run eagerly whenever the expression changes @@ -918,14 +918,14 @@ export function fork(fn) { // trigger any `$state.eager(...)` expressions with the new state flushSync(() => { /** @type {Set} */ - const inspect_effects = new Set(); + const eager_effects = new Set(); for (const source of batch.current.keys()) { - mark_inspect_effects(source, inspect_effects); + mark_eager_effects(source, eager_effects); } - set_inspect_effects(inspect_effects); - flush_inspect_effects(); + set_eager_effects(eager_effects); + flush_eager_effects(); }); batch.revive(); diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 5a3dee4b7feb..b6a50acc4d42 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -28,7 +28,7 @@ import { equals, safe_equals } from './equality.js'; import * as e from '../errors.js'; import * as w from '../warnings.js'; import { async_effect, destroy_effect, teardown } from './effects.js'; -import { inspect_effects, internal_set, set_inspect_effects, source } from './sources.js'; +import { eager_effects, internal_set, set_eager_effects, source } from './sources.js'; import { get_stack } from '../dev/tracing.js'; import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { Boundary } from '../dom/blocks/boundary.js'; @@ -318,8 +318,8 @@ export function execute_derived(derived) { set_active_effect(get_derived_parent_effect(derived)); if (DEV) { - let prev_inspect_effects = inspect_effects; - set_inspect_effects(new Set()); + let prev_eager_effects = eager_effects; + set_eager_effects(new Set()); try { if (stack.includes(derived)) { e.derived_references_self(); @@ -332,7 +332,7 @@ export function execute_derived(derived) { value = update_reaction(derived); } finally { set_active_effect(prev_active_effect); - set_inspect_effects(prev_inspect_effects); + set_eager_effects(prev_eager_effects); stack.pop(); } } else { diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index bfbb95a8db7c..bd262258dc26 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -27,7 +27,7 @@ import { DERIVED, UNOWNED, CLEAN, - INSPECT_EFFECT, + EAGER_EFFECT, HEAD_EFFECT, MAYBE_DIRTY, EFFECT_PRESERVED, @@ -88,7 +88,7 @@ function create_effect(type, fn, sync, push = true) { if (DEV) { // Ensure the parent is never an inspect effect - while (parent !== null && (parent.f & INSPECT_EFFECT) !== 0) { + while (parent !== null && (parent.f & EAGER_EFFECT) !== 0) { parent = parent.parent; } } @@ -242,8 +242,8 @@ export function user_pre_effect(fn) { } /** @param {() => void | (() => void)} fn */ -export function inspect_effect(fn) { - return create_effect(INSPECT_EFFECT, fn, true); +export function eager_effect(fn) { + return create_effect(EAGER_EFFECT, fn, true); } /** diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 593533d62956..9534e718a5a9 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -22,7 +22,7 @@ import { DERIVED, DIRTY, BRANCH_EFFECT, - INSPECT_EFFECT, + EAGER_EFFECT, UNOWNED, MAYBE_DIRTY, BLOCK_EFFECT, @@ -39,7 +39,7 @@ import { proxy } from '../proxy.js'; import { execute_derived } from './deriveds.js'; /** @type {Set} */ -export let inspect_effects = new Set(); +export let eager_effects = new Set(); /** @type {Map} */ export const old_values = new Map(); @@ -47,14 +47,14 @@ export const old_values = new Map(); /** * @param {Set} v */ -export function set_inspect_effects(v) { - inspect_effects = v; +export function set_eager_effects(v) { + eager_effects = v; } -let inspect_effects_deferred = false; +let eager_effects_deferred = false; -export function set_inspect_effects_deferred() { - inspect_effects_deferred = true; +export function set_eager_effects_deferred() { + eager_effects_deferred = true; } /** @@ -146,9 +146,9 @@ export function set(source, value, should_proxy = false) { active_reaction !== null && // since we are untracking the function inside `$inspect.with` we need to add this check // to ensure we error if state is set inside an inspect effect - (!untracking || (active_reaction.f & INSPECT_EFFECT) !== 0) && + (!untracking || (active_reaction.f & EAGER_EFFECT) !== 0) && is_runes() && - (active_reaction.f & (DERIVED | BLOCK_EFFECT | ASYNC | INSPECT_EFFECT)) !== 0 && + (active_reaction.f & (DERIVED | BLOCK_EFFECT | ASYNC | EAGER_EFFECT)) !== 0 && !current_sources?.includes(source) ) { e.state_unsafe_mutation(); @@ -235,18 +235,18 @@ export function internal_set(source, value) { } } - if (DEV && !batch.is_fork && inspect_effects.size > 0 && !inspect_effects_deferred) { - flush_inspect_effects(); + if (!batch.is_fork && eager_effects.size > 0 && !eager_effects_deferred) { + flush_eager_effects(); } } return value; } -export function flush_inspect_effects() { - inspect_effects_deferred = false; +export function flush_eager_effects() { + eager_effects_deferred = false; - const inspects = Array.from(inspect_effects); + const inspects = Array.from(eager_effects); for (const effect of inspects) { // Mark clean inspect-effects as maybe dirty and then check their dirtiness @@ -260,7 +260,7 @@ export function flush_inspect_effects() { } } - inspect_effects.clear(); + eager_effects.clear(); } /** @@ -320,8 +320,8 @@ function mark_reactions(signal, status) { if (!runes && reaction === active_effect) continue; // Inspect effects need to run immediately, so that the stack trace makes sense - if (DEV && (flags & INSPECT_EFFECT) !== 0) { - inspect_effects.add(reaction); + if (DEV && (flags & EAGER_EFFECT) !== 0) { + eager_effects.add(reaction); continue; } From de02329053c74a65ae0c1d0830a11f8ee0190516 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 09:53:14 -0400 Subject: [PATCH 31/48] regenerate --- packages/svelte/src/internal/client/errors.js | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 730de76d2c60..ccdbfc35041a 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -229,6 +229,22 @@ export function effect_update_depth_exceeded() { } } +/** + * Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` + * @returns {never} + */ +export function experimental_async_fork() { + if (DEV) { + const error = new Error(`experimental_async_fork\nCannot use \`fork(...)\` unless the \`experimental.async\` compiler option is \`true\`\nhttps://svelte.dev/e/experimental_async_fork`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/experimental_async_fork`); + } +} + /** * Cannot use `flushSync` inside an effect * @returns {never} @@ -439,20 +455,4 @@ export function svelte_boundary_reset_onerror() { } else { throw new Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`); } -} - -/** - * Cannot use `fork(...)` unless the `experimental.async` compiler option is `true` - * @returns {never} - */ -export function experimental_async_fork() { - if (DEV) { - const error = new Error(`experimental_async_fork\nCannot use \`fork(...)\` unless the \`experimental.async\` compiler option is \`true\`\nhttps://svelte.dev/e/experimental_async_fork`); - - error.name = 'Svelte error'; - - throw error; - } else { - throw new Error(`https://svelte.dev/e/experimental_async_fork`); - } } \ No newline at end of file From 4bb399b48e0181f02073b9bdeca708927e998c77 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 10:36:47 -0400 Subject: [PATCH 32/48] Apply suggestions from code review Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- packages/svelte/src/internal/client/reactivity/async.js | 2 +- packages/svelte/src/internal/client/reactivity/batch.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index c5713982bf6c..fb836df98914 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -1,5 +1,5 @@ /** @import { Effect, TemplateNode, Value } from '#client' */ -import { DESTROYED, STALE_REACTION } from '#client/constants'; +import { DESTROYED } from '#client/constants'; import { DEV } from 'esm-env'; import { component_context, diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index bae1cba100c7..de0bbe0a7e2f 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -843,7 +843,7 @@ export function eager(fn) { eager_effect(() => { if (initial) { - // the first time this runs, we create an inspect effect + // the first time this runs, we create an eager effect // that will run eagerly whenever the expression changes var previous_batch_values = batch_values; From 69f74cf5cca5ad0072f3d8a66fccf14b6191660c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 10:38:30 -0400 Subject: [PATCH 33/48] tidy up --- packages/svelte/src/internal/client/reactivity/batch.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index de0bbe0a7e2f..89d5ad635bd8 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -171,7 +171,9 @@ export class Batch { this.#traverse_effect_tree(root, target); } - this.#resolve(); + if (!this.is_fork) { + this.#resolve(); + } if (this.#blocking_pending > 0 || this.is_fork) { this.#defer_effects(target.effects); @@ -332,13 +334,13 @@ export class Batch { } #resolve() { - if (this.#blocking_pending === 0 && !this.is_fork) { + if (this.#blocking_pending === 0) { // append/remove branches for (const fn of this.#callbacks) fn(); this.#callbacks.clear(); } - if (this.#pending === 0 && !this.is_fork) { + if (this.#pending === 0) { this.#commit(); } } From 1d026b9288de2c2650c4589fccd96ba24531ac5f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 10:45:50 -0400 Subject: [PATCH 34/48] add some minimal prose. probably don't need to go super deep here as it's not really meant for non-framework authors --- packages/svelte/src/internal/client/reactivity/batch.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 89d5ad635bd8..6c8f24b3c9b6 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -875,6 +875,13 @@ export function eager(fn) { } /** + * Creates a 'fork', in which state changes are evaluated but not applied to the DOM. + * This is useful for speculatively loading data (for example) when you suspect that + * the user is about to take some action. + * + * Frameworks like SvelteKit can use this to preload data when the user touches or + * hovers over a link, making any subsequent navigation feel instantaneous. + * * @param {() => void} fn * @returns {{ commit: () => void, discard: () => void }} */ From b213aeac71fd9689ae0a08245effd930e8a90356 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 10:47:45 -0400 Subject: [PATCH 35/48] bit more detail --- packages/svelte/src/internal/client/reactivity/batch.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 6c8f24b3c9b6..e724279117a9 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -882,6 +882,10 @@ export function eager(fn) { * Frameworks like SvelteKit can use this to preload data when the user touches or * hovers over a link, making any subsequent navigation feel instantaneous. * + * The `fn` parameter is a synchronous function that modifies some state. The + * state changes will be reverted after the fork is initialised, then reapplied + * if and when the fork is eventually committed. + * * @param {() => void} fn * @returns {{ commit: () => void, discard: () => void }} */ From 7a66321441b5f35f4446c8013fd9f57b25a2c27f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 10:50:30 -0400 Subject: [PATCH 36/48] add a fork_timing error, regenerate --- .../98-reference/.generated/client-errors.md | 6 ++++++ packages/svelte/messages/client-errors/errors.md | 4 ++++ packages/svelte/src/internal/client/errors.js | 16 ++++++++++++++++ .../src/internal/client/reactivity/batch.js | 2 +- packages/svelte/types/index.d.ts | 14 +++++++++++++- 5 files changed, 40 insertions(+), 2 deletions(-) diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index 9b3ec7dd71c4..d7a91fae0186 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -146,6 +146,12 @@ The `flushSync()` function can be used to flush any pending effects synchronousl This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6. +### fork_timing + +``` +Cannot create a fork inside an effect or when state changes are pending +``` + ### get_abort_signal_outside_reaction ``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index 5de398432aab..40e6d82089b1 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -112,6 +112,10 @@ The `flushSync()` function can be used to flush any pending effects synchronousl This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6. +## fork_timing + +> Cannot create a fork inside an effect or when state changes are pending + ## get_abort_signal_outside_reaction > `getAbortSignal()` can only be called inside an effect or derived diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index ccdbfc35041a..4916e630d7dd 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -455,4 +455,20 @@ export function svelte_boundary_reset_onerror() { } else { throw new Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`); } +} + +/** + * Cannot create a fork inside an effect or when state changes are pending + * @returns {never} + */ +export function fork_timing() { + if (DEV) { + const error = new Error(`fork_timing\nCannot create a fork inside an effect or when state changes are pending\nhttps://svelte.dev/e/fork_timing`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/fork_timing`); + } } \ No newline at end of file diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index e724279117a9..bf40a208611b 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -895,7 +895,7 @@ export function fork(fn) { } if (current_batch !== null) { - throw new Error('cannot fork here'); // TODO better error + e.fork_timing(); } const batch = Batch.ensure(); diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 3a6817979097..93c9fadb5510 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -448,7 +448,19 @@ declare module 'svelte' { * Returns void if no callback is provided, otherwise returns the result of calling the callback. * */ export function flushSync(fn?: (() => T) | undefined): T; - + /** + * Creates a 'fork', in which state changes are evaluated but not applied to the DOM. + * This is useful for speculatively loading data (for example) when you suspect that + * the user is about to take some action. + * + * Frameworks like SvelteKit can use this to preload data when the user touches or + * hovers over a link, making any subsequent navigation feel instantaneous. + * + * The `fn` parameter is a synchronous function that modifies some state. The + * state changes will be reverted after the fork is initialised, then reapplied + * if and when the fork is eventually committed. + * + * */ export function fork(fn: () => void): { commit: () => void; discard: () => void; From 60ea2b2bb274b500b4a9ee23e03f39cc8ca0bf28 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 10:56:36 -0400 Subject: [PATCH 37/48] unused --- packages/svelte/src/internal/client/reactivity/batch.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index bf40a208611b..99fd32cb2d61 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -180,12 +180,6 @@ export class Batch { this.#defer_effects(target.render_effects); this.#defer_effects(target.block_effects); } else { - for (const fn of this.#callbacks) { - fn(); - } - - this.#callbacks.clear(); - // 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. previous_batch = this; From ae95b5fd64b53cd14efec778f042b3a5463b4dbd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 11:19:00 -0400 Subject: [PATCH 38/48] add note --- packages/svelte/src/internal/client/reactivity/batch.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 99fd32cb2d61..17c017728c20 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -922,7 +922,11 @@ export function fork(fn) { source.v = value; } - // trigger any `$state.eager(...)` expressions with the new state + // trigger any `$state.eager(...)` expressions with the new state. + // eager effects don't get scheduled like other effects, so we + // can't just encounter them during traversal, we need to + // proactively flush them + // TODO maybe there's a better implementation? flushSync(() => { /** @type {Set} */ const eager_effects = new Set(); From 4f35c019fc110fe2f4c74cb293f26f270195345b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 11:22:27 -0400 Subject: [PATCH 39/48] add fork_discarded error --- .../98-reference/.generated/client-errors.md | 6 +++ .../svelte/messages/client-errors/errors.md | 4 ++ packages/svelte/src/internal/client/errors.js | 48 ++++++++++++------- .../src/internal/client/reactivity/batch.js | 2 +- 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index d7a91fae0186..d47d0eb5cd65 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -146,6 +146,12 @@ The `flushSync()` function can be used to flush any pending effects synchronousl This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6. +### fork_discarded + +``` +Cannot commit a fork that was already committed or discarded (including as a result of a different fork being committed) +``` + ### fork_timing ``` diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index 40e6d82089b1..44afef58b4fc 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -112,6 +112,10 @@ The `flushSync()` function can be used to flush any pending effects synchronousl This restriction only applies when using the `experimental.async` option, which will be active by default in Svelte 6. +## fork_discarded + +> Cannot commit a fork that was already committed or discarded (including as a result of a different fork being committed) + ## fork_timing > Cannot create a fork inside an effect or when state changes are pending diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 4916e630d7dd..8341688ea652 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -261,6 +261,38 @@ export function flush_sync_in_effect() { } } +/** + * Cannot create a fork inside an effect or when state changes are pending + * @returns {never} + */ +export function fork_timing() { + if (DEV) { + const error = new Error(`fork_timing\nCannot create a fork inside an effect or when state changes are pending\nhttps://svelte.dev/e/fork_timing`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/fork_timing`); + } +} + +/** + * Cannot commit a fork that was already committed or discarded (including as a result of a different fork being committed) + * @returns {never} + */ +export function fork_discarded() { + if (DEV) { + const error = new Error(`fork_discarded\nCannot commit a fork that was already committed or discarded (including as a result of a different fork being committed)\nhttps://svelte.dev/e/fork_discarded`); + + error.name = 'Svelte error'; + + throw error; + } else { + throw new Error(`https://svelte.dev/e/fork_discarded`); + } +} + /** * `getAbortSignal()` can only be called inside an effect or derived * @returns {never} @@ -455,20 +487,4 @@ export function svelte_boundary_reset_onerror() { } else { throw new Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`); } -} - -/** - * Cannot create a fork inside an effect or when state changes are pending - * @returns {never} - */ -export function fork_timing() { - if (DEV) { - const error = new Error(`fork_timing\nCannot create a fork inside an effect or when state changes are pending\nhttps://svelte.dev/e/fork_timing`); - - error.name = 'Svelte error'; - - throw error; - } else { - throw new Error(`https://svelte.dev/e/fork_timing`); - } } \ No newline at end of file diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 17c017728c20..35c2d309184b 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -907,7 +907,7 @@ export function fork(fn) { return { commit: async () => { if (!batches.has(batch)) { - throw new Error('Cannot commit this batch'); // TODO better error + e.fork_discarded(); } batch.is_fork = false; From 2664bb416280e8b2b1dbd60b6397e4c139c54d70 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 11:31:22 -0400 Subject: [PATCH 40/48] require users to discard forks --- .../98-reference/.generated/client-errors.md | 2 +- packages/svelte/messages/client-errors/errors.md | 2 +- packages/svelte/src/internal/client/errors.js | 16 ++++++++-------- .../src/internal/client/reactivity/batch.js | 8 +++----- packages/svelte/types/index.d.ts | 3 +++ 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/documentation/docs/98-reference/.generated/client-errors.md b/documentation/docs/98-reference/.generated/client-errors.md index d47d0eb5cd65..74a0674dbab2 100644 --- a/documentation/docs/98-reference/.generated/client-errors.md +++ b/documentation/docs/98-reference/.generated/client-errors.md @@ -149,7 +149,7 @@ This restriction only applies when using the `experimental.async` option, which ### fork_discarded ``` -Cannot commit a fork that was already committed or discarded (including as a result of a different fork being committed) +Cannot commit a fork that was already committed or discarded ``` ### fork_timing diff --git a/packages/svelte/messages/client-errors/errors.md b/packages/svelte/messages/client-errors/errors.md index 44afef58b4fc..b5fe51539d86 100644 --- a/packages/svelte/messages/client-errors/errors.md +++ b/packages/svelte/messages/client-errors/errors.md @@ -114,7 +114,7 @@ This restriction only applies when using the `experimental.async` option, which ## fork_discarded -> Cannot commit a fork that was already committed or discarded (including as a result of a different fork being committed) +> Cannot commit a fork that was already committed or discarded ## fork_timing diff --git a/packages/svelte/src/internal/client/errors.js b/packages/svelte/src/internal/client/errors.js index 8341688ea652..2a433ed8f9b7 100644 --- a/packages/svelte/src/internal/client/errors.js +++ b/packages/svelte/src/internal/client/errors.js @@ -262,34 +262,34 @@ export function flush_sync_in_effect() { } /** - * Cannot create a fork inside an effect or when state changes are pending + * Cannot commit a fork that was already committed or discarded * @returns {never} */ -export function fork_timing() { +export function fork_discarded() { if (DEV) { - const error = new Error(`fork_timing\nCannot create a fork inside an effect or when state changes are pending\nhttps://svelte.dev/e/fork_timing`); + const error = new Error(`fork_discarded\nCannot commit a fork that was already committed or discarded\nhttps://svelte.dev/e/fork_discarded`); error.name = 'Svelte error'; throw error; } else { - throw new Error(`https://svelte.dev/e/fork_timing`); + throw new Error(`https://svelte.dev/e/fork_discarded`); } } /** - * Cannot commit a fork that was already committed or discarded (including as a result of a different fork being committed) + * Cannot create a fork inside an effect or when state changes are pending * @returns {never} */ -export function fork_discarded() { +export function fork_timing() { if (DEV) { - const error = new Error(`fork_discarded\nCannot commit a fork that was already committed or discarded (including as a result of a different fork being committed)\nhttps://svelte.dev/e/fork_discarded`); + const error = new Error(`fork_timing\nCannot create a fork inside an effect or when state changes are pending\nhttps://svelte.dev/e/fork_timing`); error.name = 'Svelte error'; throw error; } else { - throw new Error(`https://svelte.dev/e/fork_discarded`); + throw new Error(`https://svelte.dev/e/fork_timing`); } } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 35c2d309184b..e96f90afb55f 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -880,6 +880,9 @@ export function eager(fn) { * state changes will be reverted after the fork is initialised, then reapplied * if and when the fork is eventually committed. * + * When it becomes clear that a fork will _not_ be committed (e.g. because the + * user navigated elsewhere), it must be discarded to avoid leaking memory. + * * @param {() => void} fn * @returns {{ commit: () => void, discard: () => void }} */ @@ -912,11 +915,6 @@ export function fork(fn) { batch.is_fork = false; - // delete all other forks - for (const b of batches) { - if (b.is_fork) batches.delete(b); - } - // apply changes for (const [source, value] of batch.current) { source.v = value; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 93c9fadb5510..a05fd6758708 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -460,6 +460,9 @@ declare module 'svelte' { * state changes will be reverted after the fork is initialised, then reapplied * if and when the fork is eventually committed. * + * When it becomes clear that a fork will _not_ be committed (e.g. because the + * user navigated elsewhere), it must be discarded to avoid leaking memory. + * * */ export function fork(fn: () => void): { commit: () => void; From cd4da0e77d42cba27bd5929740e051c9b797eff2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 11:48:08 -0400 Subject: [PATCH 41/48] add docs --- .../19-await-expressions.md | 38 +++++++++++++++++++ packages/svelte/src/index.d.ts | 16 ++++++++ .../src/internal/client/reactivity/batch.js | 4 +- packages/svelte/types/index.d.ts | 3 +- 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/documentation/docs/03-template-syntax/19-await-expressions.md b/documentation/docs/03-template-syntax/19-await-expressions.md index 1c613af87091..e10a9ac0c447 100644 --- a/documentation/docs/03-template-syntax/19-await-expressions.md +++ b/documentation/docs/03-template-syntax/19-await-expressions.md @@ -135,6 +135,44 @@ If a `` with a `pending` snippet is encountered during SSR, tha > [!NOTE] In the future, we plan to add a streaming implementation that renders the content in the background. +## Forking + +The [`fork(...)`](svelte#fork) API, added in 5.42, makes it possible to run `await` expressions that you _expect_ to happen in the near future. This is mainly intended for frameworks like SvelteKit to implement preloading when users signal an intent to navigate (for example). + +```svelte + + + + +{#if open} + + open = false} /> +{/if} +``` + ## Caveats As an experimental feature, the details of how `await` is handled (and related APIs like `$effect.pending()`) are subject to breaking changes outside of a semver major release, though we intend to keep such changes to a bare minimum. diff --git a/packages/svelte/src/index.d.ts b/packages/svelte/src/index.d.ts index 38e60866898f..a1782f5b61a5 100644 --- a/packages/svelte/src/index.d.ts +++ b/packages/svelte/src/index.d.ts @@ -352,4 +352,20 @@ export type MountOptions = Record props: Props; }); +/** + * Represents work that is happening off-screen, such as data being preloaded + * in anticipation of the user navigating + * @since 5.42 + */ +export interface Fork { + /** + * Commit the fork. The promise will resolve once the state change has been applied + */ + commit(): Promise; + /** + * Discard the fork + */ + discard(): void; +} + export * from './index-client.js'; diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index e96f90afb55f..de2816c333be 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -1,3 +1,4 @@ +/** @import { Fork } from 'svelte' */ /** @import { Derived, Effect, Reaction, Source, Value } from '#client' */ import { BLOCK_EFFECT, @@ -884,7 +885,8 @@ export function eager(fn) { * user navigated elsewhere), it must be discarded to avoid leaking memory. * * @param {() => void} fn - * @returns {{ commit: () => void, discard: () => void }} + * @returns {Fork} + * @since 5.42 */ export function fork(fn) { if (!async_mode_flag) { diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index a05fd6758708..15ad4643576b 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -463,7 +463,8 @@ declare module 'svelte' { * When it becomes clear that a fork will _not_ be committed (e.g. because the * user navigated elsewhere), it must be discarded to avoid leaking memory. * - * */ + * @since 5.42 + */ export function fork(fn: () => void): { commit: () => void; discard: () => void; From bc56dbdb0625e08cef845fa8ec4a2fb563704bf3 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 11:49:28 -0400 Subject: [PATCH 42/48] regenerate --- packages/svelte/types/index.d.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 15ad4643576b..5e3ca77eb5cd 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -348,6 +348,22 @@ declare module 'svelte' { */ props: Props; }); + + /** + * Represents work that is happening off-screen, such as data being preloaded + * in anticipation of the user navigating + * @since 5.42 + */ + export interface Fork { + /** + * Commit the fork. The promise will resolve once the state change has been applied + */ + commit(): Promise; + /** + * Discard the fork + */ + discard(): void; + } /** * Returns an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that aborts when the current [derived](https://svelte.dev/docs/svelte/$derived) or [effect](https://svelte.dev/docs/svelte/$effect) re-runs or is destroyed. * @@ -465,10 +481,7 @@ declare module 'svelte' { * * @since 5.42 */ - export function fork(fn: () => void): { - commit: () => void; - discard: () => void; - }; + export function fork(fn: () => void): Fork; /** * Returns a `[get, set]` pair of functions for working with context in a type-safe way. * From d043b3c1a4f70ebe14348a894b1bda9992b03481 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 24 Oct 2025 12:08:19 -0400 Subject: [PATCH 43/48] tweak docs --- .../docs/03-template-syntax/19-await-expressions.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/documentation/docs/03-template-syntax/19-await-expressions.md b/documentation/docs/03-template-syntax/19-await-expressions.md index e10a9ac0c447..ae882ddd6f9b 100644 --- a/documentation/docs/03-template-syntax/19-await-expressions.md +++ b/documentation/docs/03-template-syntax/19-await-expressions.md @@ -137,7 +137,7 @@ If a `` with a `pending` snippet is encountered during SSR, tha ## Forking -The [`fork(...)`](svelte#fork) API, added in 5.42, makes it possible to run `await` expressions that you _expect_ to happen in the near future. This is mainly intended for frameworks like SvelteKit to implement preloading when users signal an intent to navigate (for example). +The [`fork(...)`](svelte#fork) API, added in 5.42, makes it possible to run `await` expressions that you _expect_ to happen in the near future. This is mainly intended for frameworks like SvelteKit to implement preloading when (for example) users signal an intent to navigate. ```svelte -