diff --git a/.changeset/big-webs-sing.md b/.changeset/big-webs-sing.md
new file mode 100644
index 0000000000..946a41d881
--- /dev/null
+++ b/.changeset/big-webs-sing.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: leave stale promises to wait for a later resolution, instead of rejecting
diff --git a/.changeset/cruel-boxes-serve.md b/.changeset/cruel-boxes-serve.md
new file mode 100644
index 0000000000..592cec4d01
--- /dev/null
+++ b/.changeset/cruel-boxes-serve.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: reapply context after transforming error during SSR
diff --git a/.changeset/easy-singers-retire.md b/.changeset/easy-singers-retire.md
new file mode 100644
index 0000000000..4420286e13
--- /dev/null
+++ b/.changeset/easy-singers-retire.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: don't rebase just-created batches
diff --git a/.changeset/fine-bushes-marry.md b/.changeset/fine-bushes-marry.md
new file mode 100644
index 0000000000..ccba53babe
--- /dev/null
+++ b/.changeset/fine-bushes-marry.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+chore: allow `null` for `pending` in typings
diff --git a/.changeset/flat-shrimps-worry.md b/.changeset/flat-shrimps-worry.md
new file mode 100644
index 0000000000..a5f76a0f9d
--- /dev/null
+++ b/.changeset/flat-shrimps-worry.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: flush eager effects in production
diff --git a/.changeset/fresh-stars-grin.md b/.changeset/fresh-stars-grin.md
new file mode 100644
index 0000000000..3d56792d1e
--- /dev/null
+++ b/.changeset/fresh-stars-grin.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: rethrow error of failed iterable after calling `return()`
diff --git a/.changeset/modern-tables-fetch.md b/.changeset/modern-tables-fetch.md
new file mode 100644
index 0000000000..89543910fa
--- /dev/null
+++ b/.changeset/modern-tables-fetch.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: resolve stale deriveds with latest value
diff --git a/.changeset/public-mammals-float.md b/.changeset/public-mammals-float.md
new file mode 100644
index 0000000000..d890c9e070
--- /dev/null
+++ b/.changeset/public-mammals-float.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+chore: remove unnecessary `increment_pending` calls
diff --git a/.changeset/quiet-teams-pick.md b/.changeset/quiet-teams-pick.md
new file mode 100644
index 0000000000..ed046168be
--- /dev/null
+++ b/.changeset/quiet-teams-pick.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: correctly compile component member expressions for SSR
diff --git a/.changeset/red-crabs-ring.md b/.changeset/red-crabs-ring.md
new file mode 100644
index 0000000000..82b53c5464
--- /dev/null
+++ b/.changeset/red-crabs-ring.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: reset `source.updated` stack traces after `flush`
diff --git a/.changeset/shiny-squids-whisper.md b/.changeset/shiny-squids-whisper.md
new file mode 100644
index 0000000000..a8d2d7378c
--- /dev/null
+++ b/.changeset/shiny-squids-whisper.md
@@ -0,0 +1,5 @@
+---
+"svelte": patch
+---
+
+fix: replacing async 'blocking' strategy with 'merging'
diff --git a/.changeset/stupid-baboons-fall.md b/.changeset/stupid-baboons-fall.md
new file mode 100644
index 0000000000..66895ad015
--- /dev/null
+++ b/.changeset/stupid-baboons-fall.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: abort running obsolete async branches
diff --git a/.changeset/tough-knives-smell.md b/.changeset/tough-knives-smell.md
new file mode 100644
index 0000000000..7687188c1a
--- /dev/null
+++ b/.changeset/tough-knives-smell.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: wrap `Promise.all` in `save` during SSR
diff --git a/.changeset/twelve-cooks-speak.md b/.changeset/twelve-cooks-speak.md
new file mode 100644
index 0000000000..d4fcd5c339
--- /dev/null
+++ b/.changeset/twelve-cooks-speak.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: ignore false-positive errors of `$inspect` dependencies
diff --git a/documentation/docs/07-misc/02-testing.md b/documentation/docs/07-misc/02-testing.md
index c1bc69c1c2..0ad82a19b5 100644
--- a/documentation/docs/07-misc/02-testing.md
+++ b/documentation/docs/07-misc/02-testing.md
@@ -38,6 +38,21 @@ You can now write unit tests for code inside your `.js/.ts` files:
```js
/// file: multiplier.svelte.test.js
+// @filename: multiplier.svelte.ts
+export function multiplier(initial: number, k: number) {
+ let count = $state(initial);
+
+ return {
+ get value() {
+ return count * k;
+ },
+ set: (c: number) => {
+ count = c;
+ }
+ };
+}
+// @filename: multiplier.svelte.test.js
+// ---cut---
import { flushSync } from 'svelte';
import { expect, test } from 'vitest';
import { multiplier } from './multiplier.svelte.js';
@@ -80,6 +95,16 @@ Since Vitest processes your test files the same way as your source files, you ca
```js
/// file: multiplier.svelte.test.js
+// @filename: multiplier.svelte.ts
+export function multiplier(getCount: () => number, k: number) {
+ return {
+ get value() {
+ return getCount() * k;
+ }
+ };
+}
+// @filename: multiplier.svelte.test.js
+// ---cut---
import { flushSync } from 'svelte';
import { expect, test } from 'vitest';
import { multiplier } from './multiplier.svelte.js';
@@ -115,6 +140,10 @@ If the code being tested uses effects, you need to wrap the test inside `$effect
```js
/// file: logger.svelte.test.js
+// @filename: logger.svelte.ts
+export function logger(fn: () => void) {}
+// @filename: logger.svelte.test.js
+// ---cut---
import { flushSync } from 'svelte';
import { expect, test } from 'vitest';
import { logger } from './logger.svelte.js';
@@ -213,7 +242,7 @@ test('Component', () => {
expect(document.body.innerHTML).toBe('');
// Click the button, then flush the changes so you can synchronously write expectations
- document.body.querySelector('button').click();
+ document.body.querySelector('button')?.click();
flushSync();
expect(document.body.innerHTML).toBe('');
@@ -226,6 +255,7 @@ test('Component', () => {
While the process is very straightforward, it is also low level and somewhat brittle, as the precise structure of your component may change frequently. Tools like [@testing-library/svelte](https://testing-library.com/docs/svelte-testing-library/intro/) can help streamline your tests. The above test could be rewritten like this:
```js
+// @errors: 2339
/// file: component.test.js
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
@@ -270,9 +300,9 @@ You can create stories for component variations and test interactions with the [
}
});
-
+
-
+
{
diff --git a/packages/svelte/elements.d.ts b/packages/svelte/elements.d.ts
index f18b7dea98..daa40635b6 100644
--- a/packages/svelte/elements.d.ts
+++ b/packages/svelte/elements.d.ts
@@ -2067,9 +2067,9 @@ export interface SvelteHTMLElements {
};
'svelte:head': { [name: string]: any };
'svelte:boundary': {
- onerror?: (error: unknown, reset: () => void) => void;
- failed?: import('svelte').Snippet<[error: unknown, reset: () => void]>;
- pending?: import('svelte').Snippet;
+ onerror?: ((error: unknown, reset: () => void) => void) | null | undefined;
+ failed?: import('svelte').Snippet<[error: unknown, reset: () => void]> | null | undefined;
+ pending?: import('svelte').Snippet | null | undefined;
};
[name: string]: { [name: string]: any };
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js
index 8e7d7bcdbf..ed202edd3b 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/Component.js
@@ -9,5 +9,9 @@ import { build_inline_component } from './shared/component.js';
* @param {ComponentContext} context
*/
export function Component(node, context) {
- build_inline_component(node, /** @type {Expression} */ (context.visit(b.id(node.name))), context);
+ build_inline_component(
+ node,
+ /** @type {Expression} */ (context.visit(b.member_id(node.name))),
+ context
+ );
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js
index a87642bc4c..9b3ac3ad78 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/shared/utils.js
@@ -12,7 +12,7 @@ import {
import * as b from '#compiler/builders';
import { sanitize_template_string } from '../../../../../utils/sanitize_template_string.js';
import { regex_whitespaces_strict } from '../../../../patterns.js';
-import { has_await_expression } from '../../../../../utils/ast.js';
+import { has_await_expression, save } from '../../../../../utils/ast.js';
import { ExpressionMetadata } from '../../../../nodes.js';
/** Opens an if/each block, so that we can remove nodes in the case of a mismatch */
@@ -360,7 +360,7 @@ export class PromiseOptimiser {
return b.const(
b.array_pattern(this.expressions.map((_, i) => b.id(`$$${i}`))),
- b.await(b.call('Promise.all', promises))
+ save(b.call('Promise.all', promises))
);
}
diff --git a/packages/svelte/src/internal/client/dev/inspect.js b/packages/svelte/src/internal/client/dev/inspect.js
index 75b29ce9b1..7a8fa0e963 100644
--- a/packages/svelte/src/internal/client/dev/inspect.js
+++ b/packages/svelte/src/internal/client/dev/inspect.js
@@ -20,6 +20,8 @@ export function inspect(get_value, inspector, show_stack = false) {
// in an error (an `$inspect(object.property)` will run before the
// `{#if object}...{/if}` that contains it)
eager_effect(() => {
+ error = UNINITIALIZED;
+
try {
var value = get_value();
} catch (e) {
diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js
index 43af3d8dd3..170529a6b9 100644
--- a/packages/svelte/src/internal/client/dom/blocks/async.js
+++ b/packages/svelte/src/internal/client/dom/blocks/async.js
@@ -1,5 +1,5 @@
/** @import { Blocker, TemplateNode, Value } from '#client' */
-import { flatten, increment_pending } from '../../reactivity/async.js';
+import { flatten } from '../../reactivity/async.js';
import { get } from '../../runtime.js';
import {
hydrate_next,
@@ -42,8 +42,6 @@ export function async(node, blockers = [], expressions = [], fn) {
return;
}
- const decrement_pending = increment_pending();
-
if (was_hydrating) {
var previous_hydrate_node = hydrate_node;
set_hydrate_node(end);
@@ -64,8 +62,6 @@ export function async(node, blockers = [], expressions = [], fn) {
if (was_hydrating) {
set_hydrating(false);
}
-
- decrement_pending();
}
});
}
diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js
index 028b82ab92..beaa7d6869 100644
--- a/packages/svelte/src/internal/client/dom/blocks/boundary.js
+++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js
@@ -35,19 +35,18 @@ import { queue_micro_task } from '../task.js';
import * as e from '../../errors.js';
import * as w from '../../warnings.js';
import { DEV } from 'esm-env';
-import { Batch, current_batch, previous_batch, schedule_effect } from '../../reactivity/batch.js';
+import { Batch, current_batch } from '../../reactivity/batch.js';
import { internal_set, source } from '../../reactivity/sources.js';
import { tag } from '../../dev/tracing.js';
import { createSubscriber } from '../../../../reactivity/create-subscriber.js';
import { create_text } from '../operations.js';
import { defer_effect } from '../../reactivity/utils.js';
-import { set_signal_status } from '../../reactivity/status.js';
/**
* @typedef {{
- * onerror?: (error: unknown, reset: () => void) => void;
- * failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void;
- * pending?: (anchor: Node) => void;
+ * onerror?: ((error: unknown, reset: () => void) => void) | null;
+ * failed?: ((anchor: Node, error: () => unknown, reset: () => () => void) => void) | null;
+ * pending?: ((anchor: Node) => void) | null;
* }} BoundaryProps
*/
diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js
index 7cab6c3385..5721f7b056 100644
--- a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js
+++ b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js
@@ -1,8 +1,8 @@
/** @import { TemplateNode } from '#client' */
import { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from '../hydration.js';
import { create_text, get_first_child, get_next_sibling } from '../operations.js';
-import { block } from '../../reactivity/effects.js';
-import { COMMENT_NODE, EFFECT_PRESERVED, HEAD_EFFECT } from '#client/constants';
+import { block, branch } from '../../reactivity/effects.js';
+import { COMMENT_NODE, HEAD_EFFECT } from '#client/constants';
/**
* @param {string} hash
@@ -49,9 +49,10 @@ export function head(hash, render_fn) {
}
try {
- // normally a branch is the child of a block and would have the EFFECT_PRESERVED flag,
- // but since head blocks don't necessarily only have direct branch children we add it on the block itself
- block(() => render_fn(anchor), HEAD_EFFECT | EFFECT_PRESERVED);
+ block(() => {
+ var e = branch(() => render_fn(anchor));
+ e.f |= HEAD_EFFECT;
+ });
} finally {
if (was_hydrating) {
set_hydrating(true);
diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js
index a15fc48596..0cec01191a 100644
--- a/packages/svelte/src/internal/client/dom/elements/attributes.js
+++ b/packages/svelte/src/internal/client/dom/elements/attributes.js
@@ -584,7 +584,7 @@ function get_setters(element) {
var element_proto = Element.prototype;
// Stop at Element, from there on there's only unnecessary setters we're not interested in
- // Do not use contructor.name here as that's unreliable in some browser environments
+ // Do not use constructor.name here as that's unreliable in some browser environments
while (element_proto !== proto) {
descriptors = get_descriptors(proto);
diff --git a/packages/svelte/src/internal/client/dom/elements/events.js b/packages/svelte/src/internal/client/dom/elements/events.js
index e598a78949..5aa41e1c4d 100644
--- a/packages/svelte/src/internal/client/dom/elements/events.js
+++ b/packages/svelte/src/internal/client/dom/elements/events.js
@@ -237,9 +237,9 @@ export function handle_event_propagation(event) {
});
// This started because of Chromium issue https://chromestatus.com/feature/5128696823545856,
- // where removal or moving of of the DOM can cause sync `blur` events to fire, which can cause logic
+ // where removal or moving of the DOM can cause sync `blur` events to fire, which can cause logic
// to run inside the current `active_reaction`, which isn't what we want at all. However, on reflection,
- // it's probably best that all event handled by Svelte have this behaviour, as we don't really want
+ // it's probably best that all events handled by Svelte have this behaviour, as we don't really want
// an event handler to run in the context of another reaction or effect.
var previous_reaction = active_reaction;
var previous_effect = active_effect;
diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js
index 6aea790c36..c1d4cbcd67 100644
--- a/packages/svelte/src/internal/client/reactivity/async.js
+++ b/packages/svelte/src/internal/client/reactivity/async.js
@@ -55,33 +55,38 @@ export function flatten(blockers, sync, async, fn) {
/** @param {Value[]} values */
function finish(values) {
+ if ((parent.f & DESTROYED) !== 0) {
+ return;
+ }
+
restore();
try {
fn(values);
} catch (error) {
- if ((parent.f & DESTROYED) === 0) {
- invoke_error_boundary(error, parent);
- }
+ invoke_error_boundary(error, parent);
}
unset_context();
}
+ var decrement_pending = increment_pending();
+
// Fast path: blockers but no async expressions
if (async.length === 0) {
- /** @type {Promise} */ (blocker_promise).then(() => finish(sync.map(d)));
+ /** @type {Promise} */ (blocker_promise)
+ .then(() => finish(sync.map(d)))
+ .finally(decrement_pending);
+
return;
}
- var decrement_pending = increment_pending();
-
// Full path: has async expressions
function run() {
Promise.all(async.map((expression) => async_derived(expression)))
.then((result) => finish([...sync.map(d), ...result]))
.catch((error) => invoke_error_boundary(error, parent))
- .finally(() => decrement_pending());
+ .finally(decrement_pending);
}
if (blocker_promise) {
@@ -213,22 +218,35 @@ export async function* for_await_track_reactivity_loss(iterable) {
throw new TypeError('value is not async iterable');
}
- /** Whether the completion of the iterator was "normal", meaning it wasn't ended via `break` or a similar method */
- let normal_completion = false;
+ // eslint-disable-next-line no-useless-assignment
+ let invoke_return = true;
+
try {
while (true) {
const { done, value } = (await track_reactivity_loss(iterator.next()))();
if (done) {
- normal_completion = true;
+ invoke_return = false;
break;
}
var prev = reactivity_loss_tracker;
- yield value;
+ try {
+ yield value;
+ } catch (e) {
+ set_reactivity_loss_tracker(prev);
+ // If the yield throws, we need to call `return` but not return its value, instead rethrow
+ if (iterator.return !== undefined) {
+ (await track_reactivity_loss(iterator.return()))();
+ }
+ throw e;
+ }
set_reactivity_loss_tracker(prev);
}
+ } catch (error) {
+ invoke_return = false;
+ throw error;
} finally {
- // If the iterator had an abrupt completion and `return` is defined on the iterator, call it and return the value
- if (!normal_completion && iterator.return !== undefined) {
+ // If the iterator had an abrupt completion (break) and `return` is defined on the iterator, call it and return the value
+ if (invoke_return && iterator.return !== undefined) {
// eslint-disable-next-line no-unsafe-finally
return /** @type {TReturn} */ ((await track_reactivity_loss(iterator.return()))().value);
}
@@ -310,7 +328,7 @@ export function run(thunks) {
// wait one more tick, so that template effects are
// guaranteed to run before `$effect(...)`
.then(() => Promise.resolve())
- .finally(() => decrement_pending());
+ .finally(decrement_pending);
return blockers;
}
@@ -334,8 +352,8 @@ export function increment_pending() {
boundary.update_pending_count(1, batch);
batch.increment(blocking, effect);
- return (skip = false) => {
+ return () => {
boundary.update_pending_count(-1, batch);
- batch.decrement(blocking, effect, skip);
+ batch.decrement(blocking, effect);
};
}
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 7adf3be00c..d822834324 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -41,8 +41,11 @@ import { legacy_is_updating_store } from './store.js';
import { invariant } from '../../shared/dev.js';
import { log_effect_tree } from '../dev/debug.js';
-/** @type {Set} */
-const batches = new Set();
+/** @type {Batch | null} */
+let first_batch = null;
+
+/** @type {Batch | null} */
+let last_batch = null;
/** @type {Batch | null} */
export let current_batch = null;
@@ -85,13 +88,29 @@ export let collected_effects = null;
export let legacy_updates = null;
var flush_count = 0;
-var source_stacks = DEV ? new Set() : null;
+
+/** @type {Set} */
+var source_stacks = new Set();
let uid = 1;
export class Batch {
id = uid++;
+ /** True as soon as `#process` was called */
+ #started = false;
+
+ linked = true;
+
+ /** @type {Batch | null} */
+ #prev = null;
+
+ /** @type {Batch | null} */
+ #next = null;
+
+ /** @type {Map>>} */
+ async_deriveds = new Map();
+
/**
* The current values of any signals that are updated in this batch.
* Tuple format: [value, is_derived] (note: is_derived is false for deriveds, too, if they were overridden via assignment)
@@ -107,6 +126,13 @@ export class Batch {
*/
previous = new Map();
+ /**
+ * Async effects which this batch doesn't take into account anymore when calculating blockers,
+ * as it has a value for it already.
+ * @type {Set}
+ */
+ unblocked = new Set();
+
/**
* When the batch is committed (and the DOM is updated), we need to remove old branches
* and append new ones by calling the functions added inside (if/each/key/etc) blocks
@@ -127,10 +153,9 @@ export class Batch {
#fork_commit_callbacks = new Set();
/**
- * Async effects that are currently in flight
- * @type {Map}
+ * The number of async effects that are currently in flight
*/
- #pending = new Map();
+ #pending = 0;
/**
* Async effects that are currently in flight, _not_ inside a pending boundary
@@ -188,31 +213,24 @@ export class Batch {
#decrement_queued = false;
- /** @type {Set} */
- #blockers = new Set();
-
#is_deferred() {
- return this.is_fork || this.#blocking_pending.size > 0;
- }
+ if (this.is_fork) return true;
- #is_blocked() {
- for (const batch of this.#blockers) {
- for (const effect of batch.#blocking_pending.keys()) {
- var skipped = false;
- var e = effect;
+ for (const effect of this.#blocking_pending.keys()) {
+ var e = effect;
+ var skipped = false;
- while (e.parent !== null) {
- if (this.#skipped_branches.has(e)) {
- skipped = true;
- break;
- }
-
- e = e.parent;
+ while (e.parent !== null) {
+ if (this.#skipped_branches.has(e)) {
+ skipped = true;
+ break;
}
- if (!skipped) {
- return true;
- }
+ e = e.parent;
+ }
+
+ if (!skipped) {
+ return true;
}
}
@@ -255,11 +273,21 @@ export class Batch {
}
#process() {
+ this.#started = true;
+
if (flush_count++ > 1000) {
- batches.delete(this);
+ this.#unlink();
infinite_loop_guard();
}
+ if (DEV) {
+ // track all the values that were updated during this flush,
+ // so that they can be reset afterwards
+ for (const value of this.current.keys()) {
+ source_stacks.add(value);
+ }
+ }
+
// we only reschedule previously-deferred effects if we expect
// to be able to run them after processing the batch
if (!this.#is_deferred()) {
@@ -314,61 +342,76 @@ export class Batch {
collected_effects = null;
legacy_updates = null;
- if (this.#is_deferred() || this.#is_blocked()) {
+ // if the batch has outstanding pending work, stash effects and bail
+ if (this.#is_deferred()) {
this.#defer_effects(render_effects);
this.#defer_effects(effects);
for (const [e, t] of this.#skipped_branches) {
reset_branch(e, t);
}
- } else {
- if (this.#pending.size === 0) {
- batches.delete(this);
- }
- // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches.
- this.#dirty_effects.clear();
- this.#maybe_dirty_effects.clear();
+ if (updates.length > 0) {
+ /** @type {Batch} */ (/** @type {unknown} */ (current_batch)).#process();
+ }
- // append/remove branches
- for (const fn of this.#commit_callbacks) fn(this);
- this.#commit_callbacks.clear();
+ return;
+ }
- previous_batch = this;
- flush_queued_effects(render_effects);
- flush_queued_effects(effects);
- previous_batch = null;
+ const earlier_batch = this.#find_earlier_batch();
- this.#deferred?.resolve();
+ if (earlier_batch) {
+ earlier_batch.#merge(this);
+ return;
}
+ // clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches.
+ this.#dirty_effects.clear();
+ this.#maybe_dirty_effects.clear();
+
+ // append/remove branches
+ for (const fn of this.#commit_callbacks) fn(this);
+ this.#commit_callbacks.clear();
+
+ previous_batch = this;
+ flush_queued_effects(render_effects);
+ flush_queued_effects(effects);
+ previous_batch = null;
+
+ this.#deferred?.resolve();
+
var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch));
+ if (this.linked && this.#pending === 0) {
+ this.#unlink();
+ }
+
+ // Order matters here - we need to commit and THEN continue flushing new batches, not the other way around,
+ // else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong.
+ // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode
+ // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed
+ if (async_mode_flag && !this.linked) {
+ this.#commit();
+ // Rebases can activate other batches or null it out, therefore restore the new one here
+ current_batch = next_batch;
+ }
+
// Edge case: During traversal new branches might create effects that run immediately and set state,
// causing an effect and therefore a root to be scheduled again. We need to traverse the current batch
// once more in that case - most of the time this will just clean up dirty branches.
if (this.#roots.length > 0) {
- const batch = (next_batch ??= this);
+ if (next_batch === null) {
+ next_batch = this;
+ this.#link();
+ }
+
+ const batch = next_batch;
batch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r)));
}
if (next_batch !== null) {
- batches.add(next_batch);
-
- if (DEV) {
- for (const source of this.current.keys()) {
- /** @type {Set} */ (source_stacks).add(source);
- }
- }
-
next_batch.#process();
}
-
- // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode
- // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed
- if (async_mode_flag && !batches.has(this)) {
- this.#commit();
- }
}
/**
@@ -423,6 +466,82 @@ export class Batch {
}
}
+ #find_earlier_batch() {
+ var batch = this.#prev;
+
+ while (batch !== null) {
+ if (!batch.is_fork) {
+ // if the batches are connected, break
+ for (const [value, [, is_derived]] of this.current) {
+ if (batch.current.has(value) && !is_derived) {
+ return batch;
+ }
+ }
+ }
+
+ batch = batch.#prev;
+ }
+
+ return null;
+ }
+
+ /**
+ * @param {Batch} batch
+ */
+ #merge(batch) {
+ for (const [source, value] of batch.current) {
+ if (!this.previous.has(source) && batch.previous.has(source)) {
+ this.previous.set(source, batch.previous.get(source));
+ }
+
+ this.current.set(source, value);
+ }
+
+ for (const [effect, deferred] of batch.async_deriveds) {
+ const d = this.async_deriveds.get(effect);
+ if (d) deferred.promise.then(d.resolve);
+ }
+
+ /**
+ * mark all effects that depend on `batch.current`, except the
+ * async effects that we just resolved (TODO unless they depend
+ * on values in this batch that are NOT in the later batch?).
+ * Through this we also will populate the correct #skipped_branches,
+ * oncommit callbacks etc, so we don't need to merge them separately.
+ * @param {Value} value
+ */
+ const mark = (value) => {
+ var reactions = value.reactions;
+ if (reactions === null) return;
+
+ for (const reaction of reactions) {
+ var flags = reaction.f;
+
+ if ((flags & DERIVED) !== 0) {
+ mark(/** @type {Derived} */ (reaction));
+ } else {
+ var effect = /** @type {Effect} */ (reaction);
+
+ if (flags & (ASYNC | BLOCK_EFFECT) && !this.async_deriveds.has(effect)) {
+ this.#maybe_dirty_effects.delete(effect);
+ set_signal_status(effect, DIRTY);
+ this.schedule(effect);
+ }
+ }
+ }
+ };
+
+ for (const source of this.current.keys()) {
+ mark(source);
+ }
+
+ this.oncommit(() => batch.discard());
+ batch.#unlink();
+
+ current_batch = this;
+ this.#process();
+ }
+
/**
* @param {Effect[]} effects
*/
@@ -465,9 +584,11 @@ export class Batch {
}
flush() {
- var source_stacks = DEV ? new Set() : null;
-
try {
+ if (DEV) {
+ source_stacks.clear();
+ }
+
is_processing = true;
current_batch = this;
@@ -485,7 +606,7 @@ export class Batch {
old_values.clear();
if (DEV) {
- for (const source of /** @type {Set} */ (source_stacks)) {
+ for (const source of source_stacks) {
source.updated = null;
}
}
@@ -497,7 +618,7 @@ export class Batch {
this.#discard_callbacks.clear();
this.#fork_commit_callbacks.clear();
- batches.delete(this);
+ this.#unlink();
}
/**
@@ -508,11 +629,13 @@ export class Batch {
}
#commit() {
+ this.#unlink();
+
// 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
// recent value for a given source
- for (const batch of batches) {
+ for (let batch = first_batch; batch !== null; batch = batch.#next) {
var is_earlier = batch.id < this.id;
/** @type {Source[]} */
@@ -535,6 +658,17 @@ export class Batch {
sources.push(source);
}
+ if (is_earlier) {
+ // TODO do we need to restart these in some cases, instead of
+ // immediately resolving them? Likely not because of how this.apply() works.
+ for (const [effect, deferred] of this.async_deriveds) {
+ const d = batch.async_deriveds.get(effect);
+ if (d) deferred.promise.then(d.resolve);
+ }
+ }
+
+ if (!batch.#started) continue;
+
// Re-run async/block effects that depend on distinct values changed in both batches
var others = [...batch.current.keys()].filter((s) => !this.current.has(s));
@@ -575,19 +709,23 @@ export class Batch {
checked = new Map();
var current_unequal = [...batch.current.keys()].filter((c) =>
- this.current.has(c) ? /** @type {[any, boolean]} */ (this.current.get(c))[0] !== c : true
+ this.current.has(c)
+ ? /** @type {[any, boolean]} */ (this.current.get(c))[0] !== c.v
+ : true
);
- for (const effect of this.#new_effects) {
- if (
- (effect.f & (DESTROYED | INERT | EAGER_EFFECT)) === 0 &&
- depends_on(effect, current_unequal, checked)
- ) {
- if ((effect.f & (ASYNC | BLOCK_EFFECT)) !== 0) {
- set_signal_status(effect, DIRTY);
- batch.schedule(effect);
- } else {
- batch.#dirty_effects.add(effect);
+ if (current_unequal.length > 0) {
+ for (const effect of this.#new_effects) {
+ if (
+ (effect.f & (DESTROYED | INERT | EAGER_EFFECT)) === 0 &&
+ depends_on(effect, current_unequal, checked)
+ ) {
+ if ((effect.f & (ASYNC | BLOCK_EFFECT)) !== 0) {
+ set_signal_status(effect, DIRTY);
+ batch.schedule(effect);
+ } else {
+ batch.#dirty_effects.add(effect);
+ }
}
}
}
@@ -606,17 +744,6 @@ export class Batch {
batch.deactivate();
}
}
-
- for (const batch of batches) {
- if (batch.#blockers.has(this)) {
- batch.#blockers.delete(this);
-
- if (batch.#blockers.size === 0 && !batch.#is_deferred()) {
- batch.activate();
- batch.#process();
- }
- }
- }
}
/**
@@ -624,8 +751,7 @@ export class Batch {
* @param {Effect} effect
*/
increment(blocking, effect) {
- let pending_count = this.#pending.get(effect) ?? 0;
- this.#pending.set(effect, pending_count + 1);
+ this.#pending += 1;
if (blocking) {
let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0;
@@ -636,16 +762,9 @@ export class Batch {
/**
* @param {boolean} blocking
* @param {Effect} effect
- * @param {boolean} skip - whether to skip updates (because this is triggered by a stale reaction)
*/
- decrement(blocking, effect, skip) {
- let pending_count = this.#pending.get(effect) ?? 0;
-
- if (pending_count === 1) {
- this.#pending.delete(effect);
- } else {
- this.#pending.set(effect, pending_count - 1);
- }
+ decrement(blocking, effect) {
+ this.#pending -= 1;
if (blocking) {
let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0;
@@ -657,12 +776,15 @@ export class Batch {
}
}
- if (this.#decrement_queued || skip) return;
+ if (this.#decrement_queued) return;
this.#decrement_queued = true;
queue_micro_task(() => {
this.#decrement_queued = false;
- this.flush();
+
+ if (this.linked) {
+ this.flush();
+ }
});
}
@@ -710,20 +832,14 @@ export class Batch {
static ensure() {
if (current_batch === null) {
const batch = (current_batch = new Batch());
+ batch.#link();
- if (!is_processing) {
- batches.add(current_batch);
-
- if (!is_flushing_sync) {
- queue_micro_task(() => {
- if (!batches.has(batch) || batch.#pending.size > 0) {
- // a flushSync happened in the meantime
- return;
- }
-
+ if (!is_processing && !is_flushing_sync) {
+ queue_micro_task(() => {
+ if (!batch.#started) {
batch.flush();
- });
- }
+ }
+ });
}
}
@@ -731,7 +847,7 @@ export class Batch {
}
apply() {
- if (!async_mode_flag || (!this.is_fork && batches.size === 1)) {
+ if (!async_mode_flag || (!this.is_fork && this.#prev === null && this.#next === null)) {
batch_values = null;
return;
}
@@ -743,28 +859,33 @@ export class Batch {
batch_values.set(source, value);
}
- // ...and undo changes belonging to other batches unless they block this one
- for (const batch of batches) {
+ // ...and undo changes belonging to other batches unless they intersect
+ for (let batch = first_batch; batch !== null; batch = batch.#next) {
if (batch === this || batch.is_fork) continue;
- // A batch is blocked on an earlier batch if it overlaps with the earlier batch's changes but is not a superset
+ // If two batches intersect, the latter batch will be merged into the earlier batch,
+ // and we should treat them as a single set of changes
var intersects = false;
- var differs = false;
if (batch.id < this.id) {
for (const [source, [, is_derived]] of batch.current) {
- // Derived values don't partake in the blocking mechanism, because a derived could
+ // Derived values don't partake in the intersection mechanism, because a derived could
// be triggered in one batch already but not the other one yet, causing a false-positive
if (is_derived) continue;
- intersects ||= this.current.has(source);
- differs ||= !this.current.has(source);
+ if (this.current.has(source)) {
+ intersects = true;
+ break;
+ }
}
}
- if (intersects && differs) {
- this.#blockers.add(batch);
- } else {
+ // Since the latter batch merges into the earlier (if it resolves before the earlier one),
+ // we treat the earlier values as "already applied". This way we don't need to rerun async
+ // effects of the earlier batch in case they are merged.
+ // As a result you can think of batch_values as having the latest values of all intersecting
+ // batches up until this batch.
+ if (!intersects) {
for (const [source, previous] of batch.previous) {
if (!batch_values.has(source)) {
batch_values.set(source, previous);
@@ -830,6 +951,36 @@ export class Batch {
this.#roots.push(e);
}
+
+ #link() {
+ if (last_batch === null) {
+ first_batch = last_batch = this;
+ } else {
+ last_batch.#next = this;
+ this.#prev = last_batch;
+ }
+
+ last_batch = this;
+ }
+
+ #unlink() {
+ var prev = this.#prev;
+ var next = this.#next;
+
+ if (prev === null) {
+ first_batch = next;
+ } else {
+ prev.#next = next;
+ }
+
+ if (next === null) {
+ last_batch = prev;
+ } else {
+ next.#prev = prev;
+ }
+
+ this.linked = false;
+ }
}
// TODO Svelte@6 think about removing the callback argument.
@@ -1213,7 +1364,7 @@ export function fork(fn) {
return;
}
- if (!batches.has(batch)) {
+ if (!batch.linked) {
e.fork_discarded();
}
@@ -1259,7 +1410,7 @@ export function fork(fn) {
source.wv = increment_write_version();
}
- if (!committed && batches.has(batch)) {
+ if (!committed && batch.linked) {
batch.discard();
}
}
@@ -1270,5 +1421,5 @@ export function fork(fn) {
* Forcibly remove all current batches, to prevent cross-talk between tests
*/
export function clear() {
- batches.clear();
+ first_batch = last_batch = null;
}
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index 92490da442..8f99389134 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -42,7 +42,7 @@ import { get_error } from '../../shared/dev.js';
import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js';
import { component_context } from '../context.js';
import { UNINITIALIZED } from '../../../constants.js';
-import { batch_values, current_batch } from './batch.js';
+import { batch_values, current_batch, previous_batch } from './batch.js';
import { increment_pending, unset_context } from './async.js';
import { deferred, includes, noop } from '../../shared/utils.js';
import { set_signal_status, update_derived_status } from './status.js';
@@ -99,6 +99,8 @@ export function derived(fn) {
return signal;
}
+export const OBSOLETE = Symbol('obsolete');
+
/**
* @template V
* @param {() => V | Promise} fn
@@ -117,13 +119,13 @@ export function async_derived(fn, label, location) {
var promise = /** @type {Promise} */ (/** @type {unknown} */ (undefined));
var signal = source(/** @type {V} */ (UNINITIALIZED));
- if (DEV) signal.label = label;
+ if (DEV) signal.label = label ?? fn.toString();
// only suspend in async deriveds created on initialisation
var should_suspend = !active_reaction;
- /** @type {Map>>} */
- var deferreds = new Map();
+ /** @type {Set>>} */
+ var deferreds = new Set();
async_effect(() => {
var effect = /** @type {Effect} */ (active_effect);
@@ -140,7 +142,13 @@ export function async_derived(fn, label, 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).finally(unset_context);
+ Promise.resolve(fn())
+ .then(d.resolve, (e) => {
+ // if the promise was rejected by the user, via `getAbortSignal`, then
+ // wait for a subsequent resolution instead of flushing the batch
+ if (e !== STALE_REACTION) d.reject(e);
+ })
+ .finally(unset_context);
} catch (error) {
d.reject(error);
unset_context();
@@ -179,18 +187,17 @@ export function async_derived(fn, label, location) {
}
if (/** @type {Boundary} */ (parent.b).is_rendered()) {
- deferreds.get(batch)?.reject(STALE_REACTION);
- deferreds.delete(batch); // delete to ensure correct order in Map iteration below
+ batch.async_deriveds.get(effect)?.reject(OBSOLETE);
} else {
// While the boundary is still showing pending, a new run supersedes all older in-flight runs
// for this async expression. Cancel eagerly so resolution cannot commit stale values.
for (const d of deferreds.values()) {
- d.reject(STALE_REACTION);
+ d.reject(OBSOLETE);
}
- deferreds.clear();
}
- deferreds.set(batch, d);
+ deferreds.add(d);
+ batch.async_deriveds.set(effect, d);
}
/**
@@ -202,16 +209,10 @@ export function async_derived(fn, label, location) {
reactivity_loss_tracker = null;
}
- if (decrement_pending) {
- // don't trigger an update if we're only here because
- // the promise was superseded before it could resolve
- var skip = error === STALE_REACTION;
- decrement_pending(skip);
- }
+ decrement_pending?.();
+ deferreds.delete(d);
- if (error === STALE_REACTION || (effect.f & DESTROYED) !== 0) {
- return;
- }
+ if (error === OBSOLETE) return;
batch.activate();
@@ -227,18 +228,11 @@ export function async_derived(fn, label, location) {
internal_set(signal, value);
- // All prior async derived runs are now stale
- for (const [b, d] of deferreds) {
- deferreds.delete(b);
- if (b === batch) break;
- d.reject(STALE_REACTION);
- }
-
if (DEV && location !== undefined) {
recent_async_deriveds.add(signal);
setTimeout(() => {
- if (recent_async_deriveds.has(signal)) {
+ if (recent_async_deriveds.has(signal) && (effect.f & DESTROYED) === 0) {
w.await_waterfall(/** @type {string} */ (signal.label), location);
recent_async_deriveds.delete(signal);
}
@@ -253,8 +247,8 @@ export function async_derived(fn, label, location) {
});
teardown(() => {
- for (const d of deferreds.values()) {
- d.reject(STALE_REACTION);
+ for (const d of deferreds) {
+ d.reject(OBSOLETE);
}
});
@@ -396,7 +390,14 @@ export function update_derived(derived) {
// change, `derived.equals` may incorrectly return `true`
if (!current_batch?.is_fork || derived.deps === null) {
if (current_batch !== null) {
+ // We also write to previous_batch because if it exists, it is a sign that we're
+ // currently in the process of flushing effects. These updates to deriveds may belong
+ // to the previous batch, not the new one (which can already exist if an earlier
+ // effect wrote to a source). This can cause bugs when running batch.#commit() later,
+ // but not adding it to current_batch can, too, so we add it to both.
+ // See https://github.com/sveltejs/svelte/pull/18117 for more details.
current_batch.capture(derived, value, true);
+ previous_batch?.capture(derived, value, true);
} else {
derived.v = value;
}
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index 0fad074e6f..5bdba037b1 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -43,7 +43,7 @@ import { define_property } from '../../shared/utils.js';
import { get_next_sibling } from '../dom/operations.js';
import { component_context, dev_current_component_function, dev_stack } from '../context.js';
import { Batch, collected_effects, current_batch } from './batch.js';
-import { flatten, increment_pending } from './async.js';
+import { flatten } from './async.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js';
import { set_signal_status } from './status.js';
@@ -396,16 +396,8 @@ export function template_effect(fn, sync = [], async = [], blockers = []) {
* @param {Blocker[]} blockers
*/
export function deferred_template_effect(fn, sync = [], async = [], blockers = []) {
- if (async.length > 0 || blockers.length > 0) {
- var decrement_pending = increment_pending();
- }
-
flatten(blockers, sync, async, (values) => {
create_effect(EFFECT, () => fn(...values.map(get)));
-
- if (decrement_pending) {
- decrement_pending();
- }
});
}
diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js
index 8cd2e52ac0..1136d513fc 100644
--- a/packages/svelte/src/internal/client/reactivity/sources.js
+++ b/packages/svelte/src/internal/client/reactivity/sources.js
@@ -44,7 +44,7 @@ import { proxy } from '../proxy.js';
import { execute_derived } from './deriveds.js';
import { set_signal_status, update_derived_status } from './status.js';
-/** @type {Set} */
+/** @type {Set} */
export let eager_effects = new Set();
/** @type {Map} */
@@ -283,7 +283,18 @@ export function flush_eager_effects() {
set_signal_status(effect, MAYBE_DIRTY);
}
- if (is_dirty(effect)) {
+ let dirty;
+
+ try {
+ dirty = is_dirty(effect);
+ } catch {
+ // Dirty-checking can evaluate derived dependencies and throw in cases where
+ // parent effects are about to destroy this eager effect. Run the effect so
+ // its own error handling can deal with transient failures.
+ dirty = true;
+ }
+
+ if (dirty) {
update_effect(effect);
}
}
@@ -359,12 +370,6 @@ function mark_reactions(signal, status, updated_during_traversal) {
// In legacy mode, skip the current effect to prevent infinite loops
if (!runes && reaction === active_effect) continue;
- // Inspect effects need to run immediately, so that the stack trace makes sense
- if (DEV && (flags & EAGER_EFFECT) !== 0) {
- eager_effects.add(reaction);
- continue;
- }
-
var not_dirty = (flags & DIRTY) === 0;
// don't set a DIRTY reaction to MAYBE_DIRTY
@@ -372,7 +377,12 @@ function mark_reactions(signal, status, updated_during_traversal) {
set_signal_status(reaction, status);
}
- if ((flags & DERIVED) !== 0) {
+ if ((flags & EAGER_EFFECT) !== 0) {
+ // Eager effects need to run immediately:
+ // - for $inspect so that the stack trace makes sense
+ // - for $state.eager because they might be without an effect parent
+ eager_effects.add(/** @type {Effect} */ (reaction));
+ } else if ((flags & DERIVED) !== 0) {
var derived = /** @type {Derived} */ (reaction);
batch_values?.delete(derived);
diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js
index d2ab35a1f2..35aac64721 100644
--- a/packages/svelte/src/internal/server/renderer.js
+++ b/packages/svelte/src/internal/server/renderer.js
@@ -715,7 +715,12 @@ export class Renderer {
const { context, failed, transformError } = item.#boundary;
set_ssr_context(context);
- let transformed = await transformError(error);
+
+ let promise = transformError(error);
+ set_ssr_context(null);
+
+ let transformed = await promise;
+ set_ssr_context(context);
// Render the failed snippet instead of the partial children content
const failed_renderer = new Renderer(item.global, item);
diff --git a/packages/svelte/tests/helpers.js b/packages/svelte/tests/helpers.js
index d0ec8b6e44..52bd47dfae 100644
--- a/packages/svelte/tests/helpers.js
+++ b/packages/svelte/tests/helpers.js
@@ -201,7 +201,7 @@ export const async_mode = process.env.SVELTE_NO_ASYNC !== 'true';
* @param {any[]} logs
*/
export function normalise_inspect_logs(logs) {
- /** @type {string[]} */
+ /** @type {any[]} */
const normalised = [];
for (const log of logs) {
diff --git a/packages/svelte/tests/runtime-legacy/shared.ts b/packages/svelte/tests/runtime-legacy/shared.ts
index 454ae2f766..6f30fb5d98 100644
--- a/packages/svelte/tests/runtime-legacy/shared.ts
+++ b/packages/svelte/tests/runtime-legacy/shared.ts
@@ -60,6 +60,8 @@ export interface RuntimeTest = Record void;
after_test?: () => void;
+ /** If true, flushSync() will not be called before invoking test() */
+ skip_initial_flushSync?: boolean;
test?: (args: {
variant: 'dom' | 'hydrate';
assert: Assert;
@@ -505,7 +507,7 @@ async function run_test_variant(
try {
if (config.test) {
- flushSync();
+ if (!config.skip_initial_flushSync) flushSync();
if (variant === 'hydrate' && cwd.includes('async-')) {
// wait for pending boundaries to render
@@ -543,7 +545,7 @@ async function run_test_variant(
}
} finally {
if (runes) {
- unmount(instance);
+ await unmount(instance);
} else {
instance.$destroy();
}
diff --git a/packages/svelte/tests/runtime-production/samples/async-eager-derived/_config.js b/packages/svelte/tests/runtime-production/samples/async-eager-derived/_config.js
new file mode 100644
index 0000000000..043f1610fb
--- /dev/null
+++ b/packages/svelte/tests/runtime-production/samples/async-eager-derived/_config.js
@@ -0,0 +1,23 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ const [increment, shift] = target.querySelectorAll('button');
+
+ increment.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
number -> number -> number -> return -> body failed -> ended
'
+ );
+
+ assert.deepEqual(normalise_trace_logs(warnings), [
+ {
+ log: 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`'
+ }
+ ]);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/main.svelte
new file mode 100644
index 0000000000..da7c48642c
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/main.svelte
@@ -0,0 +1,45 @@
+
+
+
+
{await get_result()}
+
+ {#snippet pending()}
+
pending
+ {/snippet}
+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/_config.js
new file mode 100644
index 0000000000..9e8a2d8def
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/_config.js
@@ -0,0 +1,21 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+import { normalise_trace_logs } from '../../../helpers.js';
+
+export default test({
+ compileOptions: {
+ dev: true
+ },
+ html: '
');
+
+ assert.deepEqual(normalise_trace_logs(warnings), [
+ {
+ log: 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`'
+ }
+ ]);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/main.svelte
new file mode 100644
index 0000000000..ffe2ef93c0
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/main.svelte
@@ -0,0 +1,43 @@
+
+
+
+