Merge branch 'main' into remove-was-marked

remove-was-marked
Rich Harris 2 weeks ago committed by GitHub
commit 7573848a9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: leave stale promises to wait for a later resolution, instead of rejecting

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: reapply context after transforming error during SSR

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: don't rebase just-created batches

@ -0,0 +1,5 @@
---
'svelte': patch
---
chore: allow `null` for `pending` in typings

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: flush eager effects in production

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: rethrow error of failed iterable after calling `return()`

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: resolve stale deriveds with latest value

@ -0,0 +1,5 @@
---
'svelte': patch
---
chore: remove unnecessary `increment_pending` calls

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: correctly compile component member expressions for SSR

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: reset `source.updated` stack traces after `flush`

@ -0,0 +1,5 @@
---
"svelte": patch
---
fix: replacing async 'blocking' strategy with 'merging'

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: abort running obsolete async branches

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: wrap `Promise.all` in `save` during SSR

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: ignore false-positive errors of `$inspect` dependencies

@ -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('<button>0</button>');
// 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('<button>1</button>');
@ -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 [
}
});
</script>
<Story name="Empty Form" />
<Story
name="Filled Form"
play={async ({ args, canvas, userEvent }) => {

@ -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 };

@ -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
);
}

@ -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))
);
}

@ -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) {

@ -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();
}
});
}

@ -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
*/

@ -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);

@ -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);

@ -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;

@ -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<any>} */ (blocker_promise).then(() => finish(sync.map(d)));
/** @type {Promise<any>} */ (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);
};
}

@ -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<Batch>} */
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<Value>} */
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<Effect, ReturnType<typeof deferred<any>>>} */
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<Effect>}
*/
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<Effect, number>}
* 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<Batch>} */
#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>} */ (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>} */ (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;
}

@ -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<V>} fn
@ -117,13 +119,13 @@ export function async_derived(fn, label, location) {
var promise = /** @type {Promise<V>} */ (/** @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<Batch, ReturnType<typeof deferred<V>>>} */
var deferreds = new Map();
/** @type {Set<ReturnType<typeof deferred<V>>>} */
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;
}

@ -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();
}
});
}

@ -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<any>} */
/** @type {Set<Effect>} */
export let eager_effects = new Set();
/** @type {Map<Source, any>} */
@ -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);

@ -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);

@ -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) {

@ -60,6 +60,8 @@ export interface RuntimeTest<Props extends Record<string, any> = Record<string,
id_prefix?: string;
before_test?: () => 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();
}

@ -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,
`<button>clicks: 0 - 0 - 0</button> <button>shift</button> <p>true - true</p>`
);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>clicks: 1 - 1 - 1</button> <button>shift</button> <p>false - false</p>`
);
}
});

@ -0,0 +1,22 @@
<script>
let count = $state(0);
const delayedCount = $derived(await push(count));
const derivedCount = $derived(count);
let resolvers = [];
function push(value) {
if (!value) return value;
const { promise, resolve } = Promise.withResolvers();
resolvers.push(() => resolve(value));
return promise;
}
</script>
<button onclick={() => count += 1}>
clicks: {count} - {delayedCount} - {derivedCount}
</button>
<button onclick={() => resolvers.shift()?.()}>shift</button>
<p>{$state.eager(count) !== count} - {$state.eager(derivedCount) !== derivedCount}</p>

@ -0,0 +1,30 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const [increment, shift, middle] = target.querySelectorAll('button');
const [div] = target.querySelectorAll('div');
increment.click();
await tick();
increment.click();
await tick();
increment.click();
await tick();
middle.click(); // resolve the second increment which will make the if block go away and the first batch discarded
await tick();
assert.htmlEqual(div.innerHTML, '2 2');
shift.click();
await tick();
shift.click();
await tick();
shift.click();
await tick();
shift.click();
await tick();
assert.htmlEqual(div.innerHTML, '3 3');
}
});

@ -0,0 +1,21 @@
<script>
let a = $state(0);
const deferred = [];
function delay(value) {
if (!value) return value;
return new Promise((resolve) => deferred.push(() => resolve(value)));
}
</script>
<div>
{a} {await delay(a)}
{#if a < 2}
{await delay(a)}
{/if}
</div>
<button onclick={() => {a++;}}>a++</button>
<button onclick={() => deferred.shift()?.()}>shift</button>
<button onclick={() => deferred[2]()}>middle</button>

@ -0,0 +1,19 @@
import { tick } from 'svelte';
import { test } from '../../test';
// Tests that batch.#commit() does not null out a potentially new current_batch
export default test({
skip_initial_flushSync: true, // test that the initial batch is flushed without an explicit flushSync() call
async test({ assert, target }) {
await tick();
const [button] = target.querySelectorAll('button');
const [updates] = target.querySelectorAll('p');
assert.htmlEqual(updates.innerHTML, 'false');
button.click();
await tick();
assert.htmlEqual(updates.innerHTML, 'true');
}
});

@ -0,0 +1,30 @@
<script>
let count = $state(-1);
let payload = $state(false);
let updated = $state(false);
$effect(() => {
if (payload) {
updated = true;
}
});
function update() {
count = 0;
queueMicrotask(() => {
payload = true;
});
}
</script>
<button onclick={update}>update</button>
<p>{updated}</p>
<svelte:boundary>
{await new Promise(() => {})}
{#snippet pending()}
<p>pending</p>
{/snippet}
</svelte:boundary>

@ -0,0 +1,27 @@
import { tick } from 'svelte';
import { test } from '../../test';
// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(),
// rescheduling an effect on the new batch that shouldn't run.
export default test({
async test({ assert, target, logs }) {
await tick();
const [increment, resolve] = target.querySelectorAll('button');
increment.click();
await tick();
assert.deepEqual(logs, []);
// This resolve
// - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing
// - shouldn't result in #commit() rebasing the new batch
resolve.click();
await tick();
assert.deepEqual(logs, [2]);
// As a result, this resolve shouldn't result in another execution of the effect depending on the derived
resolve.click();
await tick();
assert.deepEqual(logs, [2]);
}
});

@ -0,0 +1,32 @@
<script>
let count = $state(0);
let double = $derived(count * 2);
let count_mirror = $state(0);
const queued = [];
function delay(v) {
if (!v) return v;
return new Promise(resolve => {
queued.push(() => resolve(v));
});
}
</script>
<button onclick={() => count++}>count {await delay(count)} | count_mirror {await delay(count_mirror)}</button>
<button onclick={() => queued.shift()?.()}>resolve</button>
{#if count}
<!-- inside if block so effects are newly created and therefore added to batch.#new_effects -->
<!-- first $effect creates new batch ... -->
{(() => {
$effect(() => {
count_mirror = count;
})
})()}
<!-- ... which second $effect shouldn't write to because the derived execution belongs to the previous batch -->
{(() => {
$effect(() => {
console.log(double);
})
})()}
{/if}

@ -0,0 +1,25 @@
import { tick } from 'svelte';
import { test } from '../../test';
// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(),
// rescheduling an effect on the new batch that shouldn't run.
export default test({
async test({ assert, target, logs }) {
await tick();
const [increment, resolve] = target.querySelectorAll('button');
assert.deepEqual(logs, ['delay 0']);
increment.click();
await tick();
assert.deepEqual(logs, ['delay 0', 'delay 2']);
// This resolve should trigger the async effect only once
resolve.click();
await tick();
assert.deepEqual(logs, ['delay 0', 'delay 2', 'effect run', 'delay 4']);
resolve.click();
await tick();
assert.deepEqual(logs, ['delay 0', 'delay 2', 'effect run', 'delay 4']);
}
});

@ -0,0 +1,29 @@
<script>
import { untrack } from "svelte";
let a = $state(0);
let b = $state(0);
let c = $state(0);
const queued = [];
function delay(v) {
console.log('delay ' + v);
if (!v) return v;
return new Promise(resolve => {
queued.push(() => resolve(v));
});
}
$effect(() => {
if (b + c === 0 || b + c > 2) return;
console.log('effect run')
untrack(() => {
b++;
c++;
})
})
</script>
<button onclick={() => { a++; b++; }}>increment</button>
<button onclick={() => queued.shift()?.()}>resolve</button>
{await delay(a + b + c)}

@ -0,0 +1,31 @@
import { tick } from 'svelte';
import { test } from '../../test';
// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(),
// rescheduling an effect on the new batch that shouldn't run.
export default test({
async test({ assert, target, logs }) {
await tick();
const [increment, shift, pop] = target.querySelectorAll('button');
increment.click();
await tick();
assert.deepEqual(logs, []);
// Resolve the blocking await which shouldn't result in the derived execution capturing
// the new derived value on the new batch, but on the previous batch which is currently flushing
pop.click();
await tick();
assert.deepEqual(logs, [2]);
// Resolve the non-blocking await which shouldn't result in #commit() rebasing the new batch
shift.click();
await tick();
assert.deepEqual(logs, [2]);
// Resolve the new batch's await
shift.click();
await tick();
assert.deepEqual(logs, [2]);
}
});

@ -0,0 +1,37 @@
<script>
let count = $state(0);
let double = $derived(count * 2);
let count_mirror = $state(0);
const queued = [];
function delay(v) {
if (!v) return v;
return new Promise(resolve => {
queued.push(() => resolve(v));
});
}
</script>
<button onclick={() => count++}>count {await delay(count)} | count_mirror {await delay(count_mirror)}</button>
<button onclick={() => queued.shift()?.()}>shift</button>
<button onclick={() => queued.pop()?.()}>pop</button>
{#if count}
<svelte:boundary>
{await delay(count)}
{#snippet pending()}loading{/snippet}
</svelte:boundary>
<!-- inside if block so effects are newly created and therefore added to batch.#new_effects -->
<!-- first $effect creates new batch ... -->
{(() => {
$effect(() => {
count_mirror = count;
})
})()}
<!-- ... which second $effect shouldn't write to because the derived execution belongs to the previous batch -->
{(() => {
$effect(() => {
console.log(double);
})
})()}
{/if}

@ -0,0 +1,58 @@
import { tick } from 'svelte';
import { test } from '../../test';
// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(),
// rescheduling an effect on the new batch that shouldn't run.
export default test({
async test({ assert, target, logs }) {
await tick();
const [increment, unrelated, resolve] = target.querySelectorAll('button');
increment.click();
await tick();
assert.deepEqual(logs, []);
// This resolve
// - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing
// - shouldn't result in #commit() rebasing the new batch
resolve.click();
await tick();
assert.deepEqual(logs, [2]);
assert.htmlEqual(
target.innerHTML,
`
<button>count 1 | count_mirror 0 | count_mirror_d 0 | unrelated 0</button>
<button>unrelated++</button>
<button>resolve</button>
`
);
// This resolve
// - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing
// - shouldn't result in #commit() rebasing the new batch
unrelated.click();
await tick();
assert.deepEqual(logs, [2]);
assert.htmlEqual(
target.innerHTML,
`
<button>count 1 | count_mirror 0 | count_mirror_d 0 | unrelated 1</button>
<button>unrelated++</button>
<button>resolve</button>
`
);
// As a result, this resolve shouldn't result in another execution of the effect depending on the derived
resolve.click();
await tick();
assert.deepEqual(logs, [2]);
assert.htmlEqual(
target.innerHTML,
`
<button>count 1 | count_mirror 1 | count_mirror_d 2 | unrelated 1</button>
<button>unrelated++</button>
<button>resolve</button>
`
);
}
});

@ -0,0 +1,38 @@
<script>
import { untrack } from "svelte";
let count = $state(0);
let double = $derived(count * 2);
let count_mirror = $state(0);
let unrelated = $state(0);
let count_mirror_d = $derived(count_mirror * 2);
const queued = [];
function delay(v) {
if (!v) return v;
return new Promise(resolve => {
queued.push(() => resolve(v));
});
}
</script>
<button onclick={() => count++}>count {await delay(count)} | count_mirror {await delay(count_mirror)} | count_mirror_d {count_mirror_d} | unrelated {unrelated}</button>
<button onclick={() => unrelated++}>unrelated++</button>
<button onclick={() => queued.shift()?.()}>resolve</button>
{#if count}
<!-- inside if block so effects are newly created and therefore added to batch.#new_effects -->
<!-- first $effect creates new batch ... -->
{(() => {
$effect(() => {
count_mirror = count;
untrack(() => count_mirror_d); // execute derived; should associate value with the right batch
})
})()}
<!-- ... which second $effect shouldn't write to because the derived execution belongs to the previous batch -->
{(() => {
$effect(() => {
console.log(double);
})
})()}
{/if}

@ -0,0 +1,25 @@
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, '<button>clicks: 0</button><button>shift</button> 0');
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, '<button>clicks: 1</button><button>shift</button> 1');
shift.click();
await tick();
assert.htmlEqual(target.innerHTML, '<button>clicks: 2</button><button>shift</button> 2');
}
});

@ -0,0 +1,25 @@
<script lang="ts">
import { flushSync } from 'svelte';
let count = $state(0);
const queue: Array<() => void> = [];
$effect(() => {
if (count === 1) {
count = 2;
flushSync();
}
})
function push(v: number) {
if (v === 0) return v;
return new Promise(r => queue.push(() => r(v)));
}
</script>
<button onclick={() => count += 1}>
clicks: {count}
</button>
<button onclick={() => queue.shift()?.()}>shift</button>
{await push(count)}

@ -34,18 +34,6 @@ export default test({
shift?.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<p>true</p>
<button>toggle</button>
<button>shift</button>
`
);
shift?.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`

@ -0,0 +1,20 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const [increment, pop] = target.querySelectorAll('button');
increment.click();
await tick();
increment.click();
await tick();
pop.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>increment</button> <button>pop</button> 2 2 1`);
pop.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>increment</button> <button>pop</button> 2 2 1`);
}
});

@ -0,0 +1,26 @@
<script>
let count = $state(0);
let other = $state(0);
const queue = [];
function push(v) {
return new Promise((r,e) => queue.push(() => v === 1 ? e(v) : r(v)));
}
</script>
<button onclick={() => {
if (count === 0) {
other++;
count++;
} else {
count++
}
}}>increment</button>
<button onclick={() => queue.pop()?.()}>pop</button>
{#if count > 0}
<svelte:boundary>
{await push(count)} {count} {other}
{#snippet failed()}boom{/snippet}
</svelte:boundary>
{/if}

@ -0,0 +1,6 @@
<script>
let { count } = $props();
let double = $derived(count * 2);
$effect.pre(() => console.log(count, double));
</script>

@ -0,0 +1,27 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, logs, warnings }) {
const [increment, resolve] = target.querySelectorAll('button');
increment.click();
await tick();
assert.deepEqual(logs, []);
resolve.click();
await tick();
assert.deepEqual(logs, []);
resolve.click();
await tick();
assert.deepEqual(logs, []);
resolve.click();
await tick();
assert.deepEqual(logs, [1, 2]);
// no await waterfall / inert derived warnings
assert.deepEqual(warnings, []);
}
});

@ -0,0 +1,31 @@
<script>
import Child from "./Child.svelte";
let count = $state(0);
let deferreds = [];
function push(v) {
return new Promise((resolve, reject) => {
deferreds.push({ resolve: () => resolve(v), reject });
});
}
</script>
<button onclick={() => count += 1}>increment</button>
<button onclick={() => deferreds.shift()?.resolve()}>resolve</button>
<svelte:boundary>
{#if count % 2 === 0}
{@const double = count * 2}
<p>true</p>
{await push(count)} {double}
<Child count={await push(count)} />
{:else}
<p>false</p>
<Child count={await push(count)} />
{/if}
{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>

@ -0,0 +1,24 @@
import { tick } from 'svelte';
import { test } from '../../test';
import { normalise_trace_logs } from '../../../helpers.js';
export default test({
compileOptions: {
dev: true
},
html: '<p>pending</p>',
async test({ assert, target, warnings }) {
await tick();
assert.htmlEqual(
target.innerHTML,
'<h1>number -> number -> number -> return -> body failed -> ended</h1>'
);
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`'
}
]);
}
});

@ -0,0 +1,45 @@
<script>
let values = $state([0, 1, 2]);
async function get_result() {
const logs = [];
const iterator = {
index: 0,
async next() {
if (this.index > 2) { done: true }
return { done: false, value: values[this.index++] };
},
async return() {
logs.push('return');
},
[Symbol.asyncIterator]() {
return this;
}
};
try {
for await (const value of iterator) {
logs.push('number');
// Read reactive state after async iterator await.
if (values.length === 3 && value === 2) {
throw new Error('body failed');
}
}
logs.push('done');
} catch (error) {
logs.push(error.message);
}
logs.push('ended');
return logs.join(' -> ');
}
</script>
<svelte:boundary>
<h1>{await get_result()}</h1>
{#snippet pending()}
<p>pending</p>
{/snippet}
</svelte:boundary>

@ -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: '<p>pending</p>',
async test({ assert, target, warnings }) {
await tick();
assert.htmlEqual(target.innerHTML, '<h1>number -> number -> next failed -> ended</h1>');
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`'
}
]);
}
});

@ -0,0 +1,43 @@
<script>
let values = $state([0, 1, 2]);
async function get_result() {
const logs = [];
const iterator = {
index: 0,
async next() {
if (this.index > 1) throw new Error('next failed');
return { done: false, value: values[this.index++] };
},
async return() {
logs.push('return');
},
[Symbol.asyncIterator]() {
return this;
}
};
try {
for await (const value of iterator) {
logs.push('number');
// Read reactive state after async iterator await.
values.length === value;
}
logs.push('done');
} catch (error) {
logs.push(error.message);
}
logs.push('ended');
return logs.join(' -> ');
}
</script>
<svelte:boundary>
<h1>{await get_result()}</h1>
{#snippet pending()}
<p>pending</p>
{/snippet}
</svelte:boundary>

@ -0,0 +1,29 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const [increment, pop] = target.querySelectorAll('button');
increment.click();
await tick();
increment.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>increment</button><button>pop</button><p>0 0 0</p>`
);
pop.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>increment</button><button>pop</button><p>2 2 1</p>`
);
}
});

@ -0,0 +1,22 @@
<script>
let count = $state(0);
let other = $state(0);
const queue = [];
function push(v) {
if (v === 0) return v;
return new Promise((fulfil) => {
queue.push(() => fulfil(v));
});
}
</script>
<button onclick={() => {
if (count === 0) other++;
count++;
}}>increment</button>
<button onclick={() => queue.pop()?.()}>pop</button>
<p>{await push(count)} {count} {other}</p>

@ -0,0 +1,28 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const [increment, hide, pop] = target.querySelectorAll('button');
increment.click();
await tick();
pop.click();
await tick();
hide.click(); // hides the if block, which cancels the pending async inside, which means the batch can complete
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>increment</button> <button>hide</button> <button>pop</button> 1`
);
pop.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>increment</button> <button>hide</button> <button>pop</button> 1`
);
}
});

@ -0,0 +1,21 @@
<script>
let show = $state(true);
let count = $state(0);
const queue = [];
function push(value) {
if (!value) return value;
return new Promise(r => queue.push(() => r(value)));
}
</script>
<button onclick={() => count += 1}>increment</button>
<button onclick={() => show = false}>hide</button>
<!-- pop() so that the outer one resolves first, not the one inside the if block -->
<button onclick={() => queue.pop()?.()}>pop</button>
{await push(count)}
{#if show}
{await push(count)}
{/if}

@ -0,0 +1,33 @@
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();
increment.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>3</button><button>shift</button><p>1 = 1</p><p>fizz: true</p><p>buzz: true</p>`
);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>3</button><button>shift</button><p>1 = 1</p><p>fizz: true</p><p>buzz: true</p>`
);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>3</button><button>shift</button><p>3 = 3</p><p>fizz: true</p><p>buzz: false</p>`
);
}
});

@ -0,0 +1,44 @@
<script>
import { getAbortSignal } from 'svelte';
const queue = [];
let n = $state(1);
let fizz = $state(true);
let buzz = $state(true);
function increment() {
n++;
fizz = n % 3 === 0;
buzz = n % 5 === 0;
}
function push(value) {
if (value === 1) return 1;
const d = Promise.withResolvers();
queue.push(() => d.resolve(value));
const signal = getAbortSignal();
signal.onabort = () => d.reject(signal.reason);
return d.promise;
}
</script>
<button onclick={increment}>
{$state.eager(n)}
</button>
<button onclick={() => queue.shift()?.()}>shift</button>
<p>{n} = {await push(n)}</p>
{#if true}
<p>fizz: {fizz}</p>
{/if}
{#if true}
<p>buzz: {buzz}</p>
{/if}

@ -0,0 +1,34 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const [button1, button2, pop, shift] = target.querySelectorAll('button');
const [p] = target.querySelectorAll('p');
button1.click();
await tick();
button2.click();
await tick();
assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`);
shift.click();
await tick();
assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`);
pop.click();
await tick();
assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`);
pop.click();
await tick();
assert.htmlEqual(p.innerHTML, `1 + 0 = 1 | 1 0`);
shift.click();
await tick();
pop.click();
await tick();
assert.htmlEqual(p.innerHTML, `1 + 2 = 3 | 1 1`);
}
});

@ -0,0 +1,21 @@
<script>
const queue1 = [];
const queue2 = [];
let a = $state(0);
let b = $state(0);
let c = $state(0);
let d = $state(0)
function push(value, where = 1) {
if (!value) return value;
return new Promise(r => (where === 1 ? queue1 : queue2).push(() => r(value)));
}
</script>
<button onclick={() => {a++;c++}}>a / c</button>
<button onclick={() => {b+=2;d++}}>b / d</button>
<button onclick={() => queue1.pop()?.()}>pop 1</button>
<button onclick={() => queue2.shift()?.()}>shift 2</button>
<p>{a} + {b} = {await push(a + b)} | {await push(c, 2)} {await push(d, 2)}</p>

@ -0,0 +1,34 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const [button1, button2, shift_1, pop_1, shift_2] = target.querySelectorAll('button');
const [p] = target.querySelectorAll('p');
button1.click();
await tick();
button2.click();
await tick();
assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`);
pop_1.click();
await tick();
shift_2.click();
await tick();
assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`);
// Check that the first batch can still resolve before the second even if one of its async values
// is already superseeded (but the subsequent batch as a whole is still pending).
shift_1.click();
await tick();
assert.htmlEqual(p.innerHTML, `1 + 0 = 1 | 1 0`);
shift_1.click();
await tick();
shift_2.click();
await tick();
assert.htmlEqual(p.innerHTML, `1 + 2 = 3 | 1 1`);
}
});

@ -0,0 +1,22 @@
<script>
const queue1 = [];
const queue2 = [];
let a = $state(0);
let b = $state(0);
let c = $state(0);
let d = $state(0)
function push(value, where = 1) {
if (!value) return value;
return new Promise(r => (where === 1 ? queue1 : queue2).push(() => r(value)));
}
</script>
<button onclick={() => {a++;c++}}>a / c</button>
<button onclick={() => {b+=2;d++}}>b / d</button>
<button onclick={() => queue1.shift()?.()}>shift 1</button>
<button onclick={() => queue1.pop()?.()}>pop 1</button>
<button onclick={() => queue2.shift()?.()}>shift 2</button>
<p>{a} + {b} = {await push(a + b)} | {await push(c, 2)} {await push(d, 2)}</p>

@ -0,0 +1,20 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const [increment, pop] = target.querySelectorAll('button');
increment.click();
await tick();
increment.click();
await tick();
pop.click();
await tick();
assert.htmlEqual(target.innerHTML, '<button>increment</button><button>pop</button> 2 2 1'); // showing nothing here yet would also be ok
pop.click();
await tick();
assert.htmlEqual(target.innerHTML, '<button>increment</button><button>pop</button> 2 2 1');
}
});

@ -0,0 +1,23 @@
<script>
let count = $state(0);
let other = $state(0);
const queue = [];
function push(v) {
if (v === 0) return v;
return new Promise((resolve) => queue.push(() => resolve(v)));
}
</script>
<button onclick={() => {
if (count === 0) other++;
count++;
}}>increment</button>
<button onclick={() => queue.pop()?.()}>pop</button>
{#if count > 0}
{await push(count)} {count} {other}
{/if}

@ -0,0 +1,8 @@
import { test } from '../../test';
export default test({
mode: ['client', 'server'],
html: `<span>x</span>`,
ssrHtml: `<span>x</span>`
});

@ -0,0 +1,8 @@
<script>
import Icon from './Icon.svelte';
let icons = $state({ currency: { Icon } });
const platformIcons = $derived(icons);
</script>
<platformIcons.currency.Icon />

@ -0,0 +1,11 @@
<script>
let {things} = $props();
$inspect(things);
</script>
<ul>
{#each things as thing}
<li>thing {thing.id}</li>
{/each}
</ul>

@ -0,0 +1,21 @@
import { normalise_inspect_logs } from '../../../helpers';
import { test } from '../../test';
import { flushSync } from 'svelte';
export default test({
compileOptions: {
dev: true
},
async test({ assert, target, errors, logs }) {
const button = target.querySelector('button');
flushSync(() => {
button?.click();
});
assert.htmlEqual(target.innerHTML, '<button>clear</button>');
assert.equal(errors.length, 0);
assert.deepEqual(normalise_inspect_logs(logs), [[{ id: 1 }, { id: 2 }]]);
}
});

@ -0,0 +1,15 @@
<script>
import List from "./List.svelte"
let data = $state({things: [{id:1}, {id:2}]})
function reloadData() {
data = null
}
</script>
{#if data}
<List things={data.things.map((t) => t)} />
{/if}
<button onclick={() => reloadData()}>clear</button>

@ -0,0 +1,54 @@
import { tick } from 'svelte';
import { ok, test } from '../../test';
// Test that the store is unsubscribed from, even if it's not referenced once the store itself is set to null
export default test({
skip_no_async: true,
async test({ target, assert }) {
assert.htmlEqual(
target.innerHTML,
`<input type="number"> <p>0</p> <button>add watcher</button>`
);
target.querySelector('button')?.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<input type="number"> <p>1</p> hello 1 <button>remove watcher</button>`
);
const input = target.querySelector('input');
ok(input);
input.stepUp();
input.dispatchEvent(new Event('input', { bubbles: true }));
await tick();
assert.htmlEqual(
target.innerHTML,
`<input type="number"> <p>2</p> hello 2 <button>remove watcher</button>`
);
target.querySelector('button')?.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<input type="number"> <p>2</p> <button>add watcher</button>`
);
input.stepUp();
input.dispatchEvent(new Event('input', { bubbles: true }));
await tick();
assert.htmlEqual(
target.innerHTML,
`<input type="number"> <p>2</p> <button>add watcher</button>`
);
target.querySelector('button')?.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<input type="number"> <p>3</p> hello 3 <button>remove watcher</button>`
);
}
});

@ -0,0 +1,29 @@
<script>
import { writable, derived } from "svelte/store";
const obj = writable({ a: 1 });
let count = $state(0);
let watcherA = $state();
function watch (prop) {
return derived(obj, (o) => {
count++;
return o[prop];
});
}
</script>
<input type="number" bind:value={$obj.a}>
<p>{count}</p>
{#if watcherA}
<!-- make sure the presence of async work doesn't break the `legacy_updates` mechanism -->
{#if true}
{await 'hello'}
{/if}
{$watcherA}
<button on:click={() => watcherA = null}>remove watcher</button>
{:else}
<button on:click={() => watcherA = watch("a")}>add watcher</button>
{/if}

@ -0,0 +1,8 @@
import { test } from '../../test';
export default test({
mode: ['async'],
compileOptions: {
dev: true
}
});

@ -0,0 +1 @@
<!--1410iyz--><!----><title>Async multiple attributes</title>

@ -0,0 +1,15 @@
<script>
const user = $derived(Promise.resolve({
name: 'test',
image: '',
}))
</script>
<svelte:head>
<title>Async multiple attributes</title>
</svelte:head>
<img
alt={(await user).name}
src={(await user).image}
/>
Loading…
Cancel
Save