Merge branch 'main' into entangle-batches

entangle-batches
Simon Holthausen 1 day ago
commit a8896c2f7f

@ -1,5 +1,6 @@
---
title: Best practices
skill: true
name: svelte-core-bestpractices
description: Guidance on writing fast, robust, modern Svelte code. Load this skill whenever in a Svelte project and asked to write/edit or analyze a Svelte component or module. Covers reactivity, event handling, styling, integration with libraries and more.
---

@ -324,7 +324,7 @@ When spreading props, local event handlers must go _after_ the spread, or they r
>
> It was always possible to use component callback props, but because you had to listen to DOM events using `on:`, it made sense to use `createEventDispatcher` for component events due to syntactical consistency. Now that we have event attributes (`onclick`), it's the other way around: Callback props are now the more sensible thing to do.
>
> The removal of event modifiers is arguably one of the changes that seems like a step back for those who've liked the shorthand syntax of event modifiers. Given that they are not used that frequently, we traded a smaller surface area for more explicitness. Modifiers also were inconsistent, because most of them were only useable on DOM elements.
> The removal of event modifiers is arguably one of the changes that seems like a step back for those who've liked the shorthand syntax of event modifiers. Given that they are not used that frequently, we traded a smaller surface area for more explicitness. Modifiers also were inconsistent, because most of them were only usable on DOM elements.
>
> Multiple listeners for the same event are also no longer possible, but it was something of an anti-pattern anyway, since it impedes readability: if there are many attributes, it becomes harder to spot that there are two handlers unless they are right next to each other. It also implies that the two handlers are independent, when in fact something like `event.stopImmediatePropagation()` inside `one` would prevent `two` from being called.
>

@ -1,5 +1,25 @@
# svelte
## 5.53.9
### Patch Changes
- fix: better `bind:this` cleanup timing ([#17885](https://github.com/sveltejs/svelte/pull/17885))
## 5.53.8
### Patch Changes
- fix: `{@html}` no longer duplicates content inside `contenteditable` elements ([#17853](https://github.com/sveltejs/svelte/pull/17853))
- fix: don't access inert block effects ([#17882](https://github.com/sveltejs/svelte/pull/17882))
- fix: handle asnyc updates within pending boundary ([#17873](https://github.com/sveltejs/svelte/pull/17873))
- perf: avoid re-traversing the effect tree after `$:` assignments ([#17848](https://github.com/sveltejs/svelte/pull/17848))
- chore: simplify scheduling logic ([#17805](https://github.com/sveltejs/svelte/pull/17805))
## 5.53.7
### Patch Changes

@ -2,7 +2,7 @@
"name": "svelte",
"description": "Cybernetically enhanced web apps",
"license": "MIT",
"version": "5.53.7",
"version": "5.53.9",
"type": "module",
"types": "./types/index.d.ts",
"engines": {

@ -9,7 +9,11 @@ import { build_expression } from './shared/utils.js';
* @param {ComponentContext} context
*/
export function HtmlTag(node, context) {
context.state.template.push_comment();
const is_controlled = node.metadata.is_controlled;
if (!is_controlled) {
context.state.template.push_comment();
}
const has_await = node.metadata.expression.has_await;
const has_blockers = node.metadata.expression.has_blockers();
@ -17,14 +21,17 @@ export function HtmlTag(node, context) {
const expression = build_expression(context, node.expression, node.metadata.expression);
const html = has_await ? b.call('$.get', b.id('$$html')) : expression;
const is_svg = context.state.metadata.namespace === 'svg';
const is_mathml = context.state.metadata.namespace === 'mathml';
// When is_controlled, the parent node already provides the correct namespace,
// so is_svg/is_mathml are only needed for the non-controlled path's wrapper element
const is_svg = !is_controlled && context.state.metadata.namespace === 'svg';
const is_mathml = !is_controlled && context.state.metadata.namespace === 'mathml';
const statement = b.stmt(
b.call(
'$.html',
context.state.node,
b.thunk(html),
is_controlled && b.true,
is_svg && b.true,
is_mathml && b.true,
is_ignored(node, 'hydration_html_changed') && b.true

@ -109,6 +109,8 @@ export function process_children(nodes, initial, is_element, context) {
!node.metadata.expression.is_async()
) {
node.metadata.is_controlled = true;
} else if (node.type === 'HtmlTag' && nodes.length === 1 && is_element) {
node.metadata.is_controlled = true;
} else {
const id = flush_node(
false,

@ -133,6 +133,8 @@ export namespace AST {
/** @internal */
metadata: {
expression: ExpressionMetadata;
/** If `true`, the `{@html}` block is the only child of its parent element and can use `parent.innerHTML` directly */
is_controlled?: boolean;
};
}

@ -29,6 +29,8 @@ export const INERT = 1 << 13;
export const DESTROYED = 1 << 14;
/** Set once a reaction has run for the first time */
export const REACTION_RAN = 1 << 15;
/** Effect is in the process of getting destroyed. Can be observed in child teardown functions */
export const DESTROYING = 1 << 25;
// Flags exclusive to effects
/**

@ -5,7 +5,7 @@ import { active_effect, active_reaction } from './runtime.js';
import { create_user_effect } from './reactivity/effects.js';
import { async_mode_flag, legacy_mode_flag } from '../flags/index.js';
import { FILENAME } from '../../constants.js';
import { BRANCH_EFFECT, REACTION_RAN } from './constants.js';
import { BRANCH_EFFECT } from './constants.js';
/** @type {ComponentContext | null} */
export let component_context = null;
@ -182,6 +182,7 @@ export function push(props, runes = false, fn) {
e: null,
s: props,
x: null,
r: /** @type {Effect} */ (active_effect),
l: legacy_mode_flag && !runes ? { s: null, u: null, $: [] } : null
};

@ -45,7 +45,14 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) {
var branches = new BranchManager(node);
block(() => {
var batch = /** @type {Batch} */ (current_batch);
// we null out `current_batch` because otherwise `save(...)` will incorrectly restore it —
// the batch will already have been committed by the time it resolves
batch.deactivate();
var input = get_input();
batch.activate();
var destroyed = false;
/** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */

@ -35,7 +35,7 @@ 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, schedule_effect } from '../../reactivity/batch.js';
import { Batch, current_batch, schedule_effect } 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';
@ -218,6 +218,8 @@ export class Boundary {
this.is_pending = true;
this.#pending_effect = branch(() => pending(this.#anchor));
var batch = /** @type {Batch} */ (current_batch);
queue_micro_task(() => {
var fragment = (this.#offscreen_fragment = document.createDocumentFragment());
var anchor = create_text();
@ -225,7 +227,6 @@ export class Boundary {
fragment.append(anchor);
this.#main_effect = this.#run(() => {
Batch.ensure();
return branch(() => this.#children(anchor));
});
@ -237,12 +238,14 @@ export class Boundary {
this.#pending_effect = null;
});
this.#resolve();
this.#resolve(batch);
}
});
}
#render() {
var batch = /** @type {Batch} */ (current_batch);
try {
this.is_pending = this.has_pending_snippet();
this.#pending_count = 0;
@ -259,14 +262,17 @@ export class Boundary {
const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending);
this.#pending_effect = branch(() => pending(this.#anchor));
} else {
this.#resolve();
this.#resolve(batch);
}
} catch (error) {
this.error(error);
}
}
#resolve() {
/**
* @param {Batch} batch
*/
#resolve(batch) {
this.is_pending = false;
// any effects that were previously deferred should be rescheduled —
@ -274,12 +280,12 @@ export class Boundary {
// same update that brought us here) the effects will be flushed
for (const e of this.#dirty_effects) {
set_signal_status(e, DIRTY);
schedule_effect(e);
batch.schedule(e);
}
for (const e of this.#maybe_dirty_effects) {
set_signal_status(e, MAYBE_DIRTY);
schedule_effect(e);
batch.schedule(e);
}
this.#dirty_effects.clear();
@ -320,6 +326,7 @@ export class Boundary {
set_component_context(this.#effect.ctx);
try {
Batch.ensure();
return fn();
} catch (e) {
handle_error(e);
@ -335,11 +342,12 @@ export class Boundary {
* Updates the pending count associated with the currently visible pending snippet,
* if any, such that we can replace the snippet with content once work is done
* @param {1 | -1} d
* @param {Batch} batch
*/
#update_pending_count(d) {
#update_pending_count(d, batch) {
if (!this.has_pending_snippet()) {
if (this.parent) {
this.parent.#update_pending_count(d);
this.parent.#update_pending_count(d, batch);
}
// if there's no parent, we're in a scope with no pending snippet
@ -349,7 +357,7 @@ export class Boundary {
this.#pending_count += d;
if (this.#pending_count === 0) {
this.#resolve();
this.#resolve(batch);
if (this.#pending_effect) {
pause_effect(this.#pending_effect, () => {
@ -369,9 +377,10 @@ export class Boundary {
* and controls when the current `pending` snippet (if any) is removed.
* Do not call from inside the class
* @param {1 | -1} d
* @param {Batch} batch
*/
update_pending_count(d) {
this.#update_pending_count(d);
update_pending_count(d, batch) {
this.#update_pending_count(d, batch);
this.#local_pending_count += d;
@ -445,9 +454,6 @@ export class Boundary {
}
this.#run(() => {
// If the failure happened while flushing effects, current_batch can be null
Batch.ensure();
this.#render();
});
};
@ -464,8 +470,6 @@ export class Boundary {
if (failed) {
this.#failed_effect = this.#run(() => {
Batch.ensure();
try {
return branch(() => {
// errors in `failed` snippets cause the boundary to error again

@ -1,5 +1,4 @@
/** @import { Effect, TemplateNode } from '#client' */
import { INERT } from '#client/constants';
import { Batch, current_batch } from '../../reactivity/batch.js';
import {
branch,
@ -88,7 +87,7 @@ export class BranchManager {
// effect is currently offscreen. put it in the DOM
var offscreen = this.#offscreen.get(key);
if (offscreen && (offscreen.effect.f & INERT) === 0) {
if (offscreen) {
this.#onscreen.set(key, offscreen.effect);
this.#offscreen.delete(key);
@ -125,9 +124,6 @@ export class BranchManager {
// or those that are already outroing (else the transition is aborted and the effect destroyed right away)
if (k === key || this.#outroing.has(k)) continue;
// don't destroy branches that are inside outroing blocks
if ((effect.f & INERT) !== 0) continue;
const on_destroy = () => {
const keys = Array.from(this.#batches.values());

@ -42,17 +42,33 @@ function check_hash(element, server_hash, value) {
/**
* @param {Element | Text | Comment} node
* @param {() => string | TrustedHTML} get_value
* @param {boolean} [is_controlled]
* @param {boolean} [svg]
* @param {boolean} [mathml]
* @param {boolean} [skip_warning]
* @returns {void}
*/
export function html(node, get_value, svg = false, mathml = false, skip_warning = false) {
export function html(
node,
get_value,
is_controlled = false,
svg = false,
mathml = false,
skip_warning = false
) {
var anchor = node;
/** @type {string | TrustedHTML} */
var value = '';
if (is_controlled) {
var parent_node = /** @type {Element} */ (node);
if (hydrating) {
anchor = set_hydrate_node(get_first_child(parent_node));
}
}
template_effect(() => {
var effect = /** @type {Effect} */ (active_effect);
@ -61,6 +77,22 @@ export function html(node, get_value, svg = false, mathml = false, skip_warning
return;
}
if (is_controlled && !hydrating) {
// When @html is the only child, use innerHTML directly.
// This also handles contenteditable, where the user may delete the anchor comment.
effect.nodes = null;
parent_node.innerHTML = /** @type {string} */ (value);
if (value !== '') {
assign_nodes(
/** @type {TemplateNode} */ (get_first_child(parent_node)),
/** @type {TemplateNode} */ (parent_node.lastChild)
);
}
return;
}
if (effect.nodes !== null) {
remove_effect_dom(effect.nodes.start, /** @type {TemplateNode} */ (effect.nodes.end));
effect.nodes = null;

@ -9,6 +9,7 @@ import { hydrating } from '../../hydration.js';
import { tick, untrack } from '../../../runtime.js';
import { is_runes } from '../../../context.js';
import { current_batch, previous_batch } from '../../../reactivity/batch.js';
import { async_mode_flag } from '../../../../flags/index.js';
/**
* @param {HTMLInputElement} input
@ -87,8 +88,9 @@ export function bind_value(input, get, set = get) {
var value = get();
if (input === document.activeElement) {
// we need both, because in non-async mode, render effects run before previous_batch is set
var batch = /** @type {Batch} */ (previous_batch ?? current_batch);
// In sync mode render effects are executed during tree traversal -> needs current_batch
// In async mode render effects are flushed once batch resolved, at which point current_batch is null -> needs previous_batch
var batch = /** @type {Batch} */ (async_mode_flag ? previous_batch : current_batch);
// Never rewrite the contents of a focused input. We can get here if, for example,
// an update is deferred because of async work depending on the input:

@ -4,6 +4,7 @@ import { is } from '../../../proxy.js';
import { is_array } from '../../../../shared/utils.js';
import * as w from '../../../warnings.js';
import { Batch, current_batch, previous_batch } from '../../../reactivity/batch.js';
import { async_mode_flag } from '../../../../flags/index.js';
/**
* Selects the correct option(s) (depending on whether this is a multiple select)
@ -115,8 +116,9 @@ export function bind_select_value(select, get, set = get) {
var value = get();
if (select === document.activeElement) {
// we need both, because in non-async mode, render effects run before previous_batch is set
var batch = /** @type {Batch} */ (previous_batch ?? current_batch);
// In sync mode render effects are executed during tree traversal -> needs current_batch
// In async mode render effects are flushed once batch resolved, at which point current_batch is null -> needs previous_batch
var batch = /** @type {Batch} */ (async_mode_flag ? previous_batch : current_batch);
// Don't update the <select> if it is focused. We can get here if, for example,
// an update is deferred because of async work depending on the select:

@ -1,7 +1,8 @@
import { STATE_SYMBOL } from '#client/constants';
/** @import { ComponentContext, Effect } from '#client' */
import { DESTROYING, STATE_SYMBOL } from '#client/constants';
import { component_context } from '../../../context.js';
import { effect, render_effect } from '../../../reactivity/effects.js';
import { untrack } from '../../../runtime.js';
import { queue_micro_task } from '../../task.js';
import { active_effect, untrack } from '../../../runtime.js';
/**
* @param {any} bound_value
@ -23,6 +24,9 @@ function is_bound_this(bound_value, element_or_component) {
* @returns {void}
*/
export function bind_this(element_or_component = {}, update, get_value, get_parts) {
var component_effect = /** @type {ComponentContext} */ (component_context).r;
var parent = /** @type {Effect} */ (active_effect);
effect(() => {
/** @type {unknown[]} */
var old_parts;
@ -48,12 +52,25 @@ export function bind_this(element_or_component = {}, update, get_value, get_part
});
return () => {
// We cannot use effects in the teardown phase, we we use a microtask instead.
queue_micro_task(() => {
// When the bind:this effect is destroyed, we go up the effect parent chain until we find the last parent effect that is destroyed,
// or the effect containing the component bind:this is in (whichever comes first). That way we can time the nulling of the binding
// as close to user/developer expectation as possible.
// TODO Svelte 6: Decide if we want to keep this logic or just always null the binding in the component effect's teardown
// (which would be simpler, but less intuitive in some cases, and breaks the `ondestroy-before-cleanup` test)
let p = parent;
while (p !== component_effect && p.parent !== null && p.parent.f & DESTROYING) {
p = p.parent;
}
const teardown = () => {
if (parts && is_bound_this(get_value(...parts), element_or_component)) {
update(null, ...parts);
}
});
};
const original_teardown = p.teardown;
p.teardown = () => {
teardown();
original_teardown?.();
};
};
});

@ -43,7 +43,6 @@ export function flatten(blockers, sync, async, fn) {
return;
}
var batch = current_batch;
var parent = /** @type {Effect} */ (active_effect);
var restore = capture();
@ -75,16 +74,22 @@ export function flatten(blockers, sync, async, fn) {
return;
}
var decrement_pending = increment_pending();
// Full path: has async expressions
function run() {
restore();
Promise.all(async.map((expression) => async_derived(expression)))
.then((result) => finish([...sync.map(d), ...result]))
.catch((error) => invoke_error_boundary(error, parent));
.catch((error) => invoke_error_boundary(error, parent))
.finally(() => decrement_pending());
}
if (blocker_promise) {
blocker_promise.then(run);
blocker_promise.then(() => {
restore();
run();
unset_context();
});
} else {
run();
}
@ -104,10 +109,10 @@ export function run_after_blockers(blockers, fn) {
* causes `b` to be registered as a dependency).
*/
export function capture() {
var previous_effect = active_effect;
var previous_effect = /** @type {Effect} */ (active_effect);
var previous_reaction = active_reaction;
var previous_component_context = component_context;
var previous_batch = current_batch;
var previous_batch = /** @type {Batch} */ (current_batch);
if (DEV) {
var previous_dev_stack = dev_stack;
@ -117,7 +122,13 @@ export function capture() {
set_active_effect(previous_effect);
set_active_reaction(previous_reaction);
set_component_context(previous_component_context);
if (activate_batch) previous_batch?.activate();
if (activate_batch && (previous_effect.f & DESTROYED) === 0) {
// TODO we only need optional chaining here because `{#await ...}` blocks
// are anomalous. Once we retire them we can get rid of it
previous_batch?.activate();
previous_batch?.apply();
}
if (DEV) {
set_from_async_derived(null);
@ -280,7 +291,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;
}
@ -292,16 +303,19 @@ export function wait(blockers) {
return Promise.all(blockers.map((b) => b.promise));
}
/**
* @returns {(skip?: boolean) => void}
*/
export function increment_pending() {
var boundary = /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b);
var batch = /** @type {Batch} */ (current_batch);
var blocking = boundary.is_rendered();
boundary.update_pending_count(1);
boundary.update_pending_count(1, batch);
batch.increment(blocking);
return () => {
boundary.update_pending_count(-1);
batch.decrement(blocking);
return (skip = false) => {
boundary.update_pending_count(-1, batch);
batch.decrement(blocking, skip);
};
}

@ -23,6 +23,7 @@ import { async_mode_flag } from '../../flags/index.js';
import { deferred, define_property, includes } from '../../shared/utils.js';
import {
active_effect,
active_reaction,
get,
increment_write_version,
is_dirty,
@ -37,6 +38,7 @@ import { eager_effect, unlink_effect } from './effects.js';
import { defer_effect } from './utils.js';
import { UNINITIALIZED } from '../../../constants.js';
import { set_signal_status } from './status.js';
import { legacy_is_updating_store } from './store.js';
/** @type {Set<Batch>} */
const batches = new Set();
@ -66,14 +68,11 @@ export let previous_batch = null;
*/
export let batch_values = null;
// TODO this should really be a property of `batch`
/** @type {Effect[]} */
let queued_root_effects = [];
/** @type {Effect | null} */
let last_scheduled_effect = null;
export let is_flushing_sync = false;
let is_processing = false;
/**
* During traversal, this is an array. Newly created effects are (if not immediately
@ -83,6 +82,18 @@ export let is_flushing_sync = false;
*/
export let collected_effects = null;
/**
* An array of effects that are marked during traversal as a result of a `set`
* (not `internal_set`) call. These will be added to the next batch and
* trigger another `batch.process()`
* @type {Effect[] | null}
* @deprecated when we get rid of legacy mode and stores, we can get rid of this
*/
export let legacy_updates = null;
var flush_count = 0;
var source_stacks = DEV ? new Set() : null;
let uid = 1;
export class Batch {
@ -133,6 +144,12 @@ export class Batch {
*/
#deferred = null;
/**
* The root effects that need to be flushed
* @type {Effect[]}
*/
#roots = [];
/**
* Deferred effects (which run after async work has completed) that are DIRTY
* @type {Set<Effect>}
@ -189,22 +206,23 @@ export class Batch {
for (var e of tracked.d) {
set_signal_status(e, DIRTY);
schedule_effect(e);
this.schedule(e);
}
for (e of tracked.m) {
set_signal_status(e, MAYBE_DIRTY);
schedule_effect(e);
this.schedule(e);
}
}
}
/**
*
* @param {Effect[]} root_effects
*/
process(root_effects) {
queued_root_effects = [];
#process() {
if (flush_count++ > 1000) {
infinite_loop_guard();
}
const roots = this.#roots;
this.#roots = [];
this.apply();
@ -214,16 +232,28 @@ export class Batch {
/** @type {Effect[]} */
var render_effects = [];
for (const root of root_effects) {
this.#traverse_effect_tree(root, effects, render_effects);
// Note: #traverse_effect_tree runs block effects eagerly, which can schedule effects,
// which means queued_root_effects now may be filled again.
/**
* @type {Effect[]}
* @deprecated when we get rid of legacy mode and stores, we can get rid of this
*/
var updates = (legacy_updates = []);
// Helpful for debugging reactivity loss that has to do with branches being skipped:
// log_inconsistent_branches(root);
for (const root of roots) {
this.#traverse(root, effects, render_effects);
}
// any writes should take effect in a subsequent batch
current_batch = null;
if (updates.length > 0) {
var batch = Batch.ensure();
for (const e of updates) {
batch.schedule(e);
}
}
collected_effects = null;
legacy_updates = null;
if (this.#is_deferred()) {
this.#defer_effects(render_effects);
@ -233,32 +263,39 @@ export class Batch {
reset_branch(e, t);
}
} else {
// If sources are written to, then work needs to happen in a separate batch, else prior sources would be mixed with
// newly updated sources, which could lead to infinite loops when effects run over and over again.
previous_batch = this;
current_batch = null;
// 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;
if (this.#pending === 0) {
this.#commit();
}
flush_queued_effects(render_effects);
flush_queued_effects(effects);
this.#deferred?.resolve();
}
// Clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches.
this.#dirty_effects.clear();
this.#maybe_dirty_effects.clear();
var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch));
previous_batch = null;
if (next_batch !== null) {
batches.add(next_batch);
this.#deferred?.resolve();
}
if (DEV) {
for (const source of this.current.keys()) {
/** @type {Set<Source>} */ (source_stacks).add(source);
}
}
batch_values = null;
next_batch.#process();
}
}
/**
@ -268,7 +305,7 @@ export class Batch {
* @param {Effect[]} effects
* @param {Effect[]} render_effects
*/
#traverse_effect_tree(root, effects, render_effects) {
#traverse(root, effects, render_effects) {
root.f ^= CLEAN;
var effect = root.first;
@ -278,26 +315,18 @@ export class Batch {
var is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0;
var is_skippable_branch = is_branch && (flags & CLEAN) !== 0;
var inert = (flags & INERT) !== 0;
var skip = is_skippable_branch || this.#skipped_branches.has(effect);
var skip = is_skippable_branch || (flags & INERT) !== 0 || this.#skipped_branches.has(effect);
if (!skip && effect.fn !== null) {
if (is_branch) {
if (!inert) effect.f ^= CLEAN;
effect.f ^= CLEAN;
} else if ((flags & EFFECT) !== 0) {
effects.push(effect);
} else if ((flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0 && (async_mode_flag || inert)) {
} else if (async_mode_flag && (flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0) {
render_effects.push(effect);
} else if (is_dirty(effect)) {
if ((flags & BLOCK_EFFECT) !== 0) this.#maybe_dirty_effects.add(effect);
update_effect(effect);
if ((flags & BLOCK_EFFECT) !== 0) {
this.#maybe_dirty_effects.add(effect);
// if this is inside an outroing block, ensure that the block
// re-runs if the outro is later aborted
if (inert) set_signal_status(effect, DIRTY);
}
}
var child = effect.first;
@ -352,32 +381,54 @@ export class Batch {
activate() {
current_batch = this;
this.apply();
}
deactivate() {
// If we're not the current batch, don't deactivate,
// else we could create zombie batches that are never flushed
if (current_batch !== this) return;
current_batch = null;
batch_values = null;
}
flush() {
if (queued_root_effects.length > 0) {
var source_stacks = DEV ? new Set() : null;
try {
is_processing = true;
current_batch = this;
flush_effects();
} else if (this.#pending === 0 && !this.is_fork) {
// append/remove branches
for (const fn of this.#commit_callbacks) fn(this);
this.#commit_callbacks.clear();
this.#commit();
this.#deferred?.resolve();
}
// we only reschedule previously-deferred effects if we expect
// to be able to run them after processing the batch
if (!this.#is_deferred()) {
for (const e of this.#dirty_effects) {
this.#maybe_dirty_effects.delete(e);
set_signal_status(e, DIRTY);
this.schedule(e);
}
this.deactivate();
for (const e of this.#maybe_dirty_effects) {
set_signal_status(e, MAYBE_DIRTY);
this.schedule(e);
}
}
this.#process();
} finally {
flush_count = 0;
last_scheduled_effect = null;
collected_effects = null;
legacy_updates = null;
is_processing = false;
current_batch = null;
batch_values = null;
old_values.clear();
if (DEV) {
for (const source of /** @type {Set<Source>} */ (source_stacks)) {
source.updated = null;
}
}
}
}
discard() {
@ -428,9 +479,7 @@ export class Batch {
// Re-run async/block effects that depend on distinct values changed in both batches
const others = [...batch.current.keys()].filter((s) => !this.current.has(s));
if (others.length > 0) {
// Avoid running queued root effects on the wrong branch
var prev_queued_root_effects = queued_root_effects;
queued_root_effects = [];
batch.activate();
/** @type {Set<Value>} */
const marked = new Set();
@ -440,20 +489,17 @@ export class Batch {
mark_effects(source, others, marked, checked);
}
if (queued_root_effects.length > 0) {
current_batch = batch;
if (batch.#roots.length > 0) {
batch.apply();
for (const root of queued_root_effects) {
batch.#traverse_effect_tree(root, [], []);
for (const root of batch.#roots) {
batch.#traverse(root, [], []);
}
// TODO do we need to do anything with the dummy effect arrays?
batch.deactivate();
}
queued_root_effects = prev_queued_root_effects;
batch.deactivate();
}
}
@ -478,46 +524,22 @@ export class Batch {
}
/**
*
* @param {boolean} blocking
* @param {boolean} skip - whether to skip updates (because this is triggered by a stale reaction)
*/
decrement(blocking) {
decrement(blocking, skip) {
this.#pending -= 1;
if (blocking) this.#blocking_pending -= 1;
if (this.#decrement_queued) return;
if (this.#decrement_queued || skip) return;
this.#decrement_queued = true;
queue_micro_task(() => {
this.#decrement_queued = false;
if (!this.#is_deferred()) {
// we only reschedule previously-deferred effects if we expect
// to be able to run them after processing the batch
this.revive();
} else if (queued_root_effects.length > 0) {
// if other effects are scheduled, process the batch _without_
// rescheduling the previously-deferred effects
this.flush();
}
this.flush();
});
}
revive() {
for (const e of this.#dirty_effects) {
this.#maybe_dirty_effects.delete(e);
set_signal_status(e, DIRTY);
schedule_effect(e);
}
for (const e of this.#maybe_dirty_effects) {
set_signal_status(e, MAYBE_DIRTY);
schedule_effect(e);
}
this.flush();
}
/** @param {(batch: Batch) => void} fn */
oncommit(fn) {
this.#commit_callbacks.add(fn);
@ -535,17 +557,20 @@ export class Batch {
static ensure() {
if (current_batch === null) {
const batch = (current_batch = new Batch());
batches.add(current_batch);
if (!is_flushing_sync) {
queue_micro_task(() => {
if (current_batch !== batch) {
// a flushSync happened in the meantime
return;
}
if (!is_processing) {
batches.add(current_batch);
batch.flush();
});
if (!is_flushing_sync) {
queue_micro_task(() => {
if (current_batch !== batch) {
// a flushSync happened in the meantime
return;
}
batch.flush();
});
}
}
}
@ -559,7 +584,7 @@ export class Batch {
this.#scheduling = true;
queue_micro_task(() => {
this.#scheduling = false;
this.revive();
this.flush();
});
}
}
@ -582,6 +607,63 @@ export class Batch {
}
}
}
/**
*
* @param {Effect} effect
*/
schedule(effect) {
last_scheduled_effect = effect;
// defer render effects inside a pending boundary
// TODO the `REACTION_RAN` check is only necessary because of legacy `$:` effects AFAICT — we can remove later
if (
effect.b?.is_pending &&
(effect.f & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0 &&
(effect.f & REACTION_RAN) === 0
) {
effect.b.defer_effect(effect);
return;
}
var e = effect;
while (e.parent !== null) {
e = e.parent;
var flags = e.f;
// if the effect is being scheduled because a parent (each/await/etc) block
// updated an internal source, or because a branch is being unskipped,
// bail out or we'll cause a second flush
if (collected_effects !== null && e === active_effect) {
if (async_mode_flag) return;
// in sync mode, render effects run during traversal. in an extreme edge case
// — namely that we're setting a value inside a derived read during traversal —
// they can be made dirty after they have already been visited, in which
// case we shouldn't bail out. we also shouldn't bail out if we're
// updating a store inside a `$:`, since this might invalidate
// effects that were already visited
if (
(active_reaction === null || (active_reaction.f & DERIVED) === 0) &&
!legacy_is_updating_store
) {
return;
}
}
if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) {
if ((flags & CLEAN) === 0) {
// branch is already dirty, bail
return;
}
e.f ^= CLEAN;
}
}
this.#roots.push(e);
}
}
/**
@ -599,8 +681,8 @@ export function flushSync(fn) {
var result;
if (fn) {
if (current_batch !== null) {
flush_effects();
if (current_batch !== null && !current_batch.is_fork) {
current_batch.flush();
}
result = fn();
@ -609,87 +691,42 @@ export function flushSync(fn) {
while (true) {
flush_tasks();
if (queued_root_effects.length === 0) {
current_batch?.flush();
// we need to check again, in case we just updated an `$effect.pending()`
if (queued_root_effects.length === 0) {
// this would be reset in `flush_effects()` but since we are early returning here,
// we need to reset it here as well in case the first time there's 0 queued root effects
last_scheduled_effect = null;
return /** @type {T} */ (result);
}
if (current_batch === null) {
return /** @type {T} */ (result);
}
flush_effects();
current_batch.flush();
}
} finally {
is_flushing_sync = was_flushing_sync;
}
}
function flush_effects() {
var source_stacks = DEV ? new Set() : null;
try {
var flush_count = 0;
while (queued_root_effects.length > 0) {
var batch = Batch.ensure();
if (flush_count++ > 1000) {
if (DEV) {
var updates = new Map();
for (const source of batch.current.keys()) {
for (const [stack, update] of source.updated ?? []) {
var entry = updates.get(stack);
function infinite_loop_guard() {
if (DEV) {
var updates = new Map();
if (!entry) {
entry = { error: update.error, count: 0 };
updates.set(stack, entry);
}
for (const source of /** @type {Batch} */ (current_batch).current.keys()) {
for (const [stack, update] of source.updated ?? []) {
var entry = updates.get(stack);
entry.count += update.count;
}
}
for (const update of updates.values()) {
if (update.error) {
// eslint-disable-next-line no-console
console.error(update.error);
}
}
if (!entry) {
entry = { error: update.error, count: 0 };
updates.set(stack, entry);
}
infinite_loop_guard();
}
batch.process(queued_root_effects);
old_values.clear();
if (DEV) {
for (const source of batch.current.keys()) {
/** @type {Set<Source>} */ (source_stacks).add(source);
}
entry.count += update.count;
}
}
} finally {
queued_root_effects = [];
last_scheduled_effect = null;
collected_effects = null;
if (DEV) {
for (const source of /** @type {Set<Source>} */ (source_stacks)) {
source.updated = null;
for (const update of updates.values()) {
if (update.error) {
// eslint-disable-next-line no-console
console.error(update.error);
}
}
}
}
function infinite_loop_guard() {
try {
e.effect_update_depth_exceeded();
} catch (error) {
@ -859,52 +896,11 @@ function depends_on(reaction, sources, checked) {
}
/**
* @param {Effect} signal
* @param {Effect} effect
* @returns {void}
*/
export function schedule_effect(signal) {
var effect = (last_scheduled_effect = signal);
var boundary = effect.b;
// defer render effects inside a pending boundary
// TODO the `REACTION_RAN` check is only necessary because of legacy `$:` effects AFAICT — we can remove later
if (
boundary?.is_pending &&
(signal.f & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0 &&
(signal.f & REACTION_RAN) === 0
) {
boundary.defer_effect(signal);
return;
}
while (effect.parent !== null) {
effect = effect.parent;
var flags = effect.f;
// if the effect is being scheduled because a parent (each/await/etc) block
// updated an internal source, or because a branch is being unskipped,
// bail out or we'll cause a second flush
if (collected_effects !== null && effect === active_effect) {
// in sync mode, render effects run during traversal. in an extreme edge case
// they can be made dirty after they have already been visited, in which
// case we shouldn't bail out
if (async_mode_flag || (signal.f & RENDER_EFFECT) === 0) {
return;
}
}
if ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) {
if ((flags & CLEAN) === 0) {
// branch is already dirty, bail
return;
}
effect.f ^= CLEAN;
}
}
queued_root_effects.push(effect);
export function schedule_effect(effect) {
/** @type {Batch} */ (current_batch).schedule(effect);
}
/** @type {Source<number>[]} */
@ -1094,7 +1090,7 @@ export function fork(fn) {
flush_eager_effects();
});
batch.revive();
batch.flush();
await settled;
},
discard: () => {

@ -1,5 +1,6 @@
/** @import { Derived, Effect, Source } from '#client' */
/** @import { Batch } from './batch.js'; */
/** @import { Boundary } from '../dom/blocks/boundary.js'; */
import { DEV } from 'esm-env';
import {
ERROR_VALUE,
@ -10,7 +11,8 @@ import {
ASYNC,
WAS_MARKED,
DESTROYED,
CLEAN
CLEAN,
REACTION_RAN
} from '#client/constants';
import {
active_reaction,
@ -36,7 +38,6 @@ import {
import { eager_effects, internal_set, set_eager_effects, source } from './sources.js';
import { get_error } from '../../shared/dev.js';
import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js';
import { Boundary } from '../dom/blocks/boundary.js';
import { component_context } from '../context.js';
import { UNINITIALIZED } from '../../../constants.js';
import { batch_values, current_batch } from './batch.js';
@ -142,6 +143,8 @@ export function async_derived(fn, label, location) {
{ once: true }
);
var effect = /** @type {Effect} */ (active_effect);
/** @type {ReturnType<typeof deferred<V>>} */
var d = deferred();
promise = d.promise;
@ -159,10 +162,25 @@ export function async_derived(fn, label, location) {
if (DEV) current_async_effect = null;
if (should_suspend) {
var decrement_pending = increment_pending();
// we only increment the batch's pending state for updates, not creation, otherwise
// we will decrement to zero before the work that depends on this promise (e.g. a
// template effect) has initialized, causing the batch to resolve prematurely
if ((effect.f & REACTION_RAN) !== 0) {
var decrement_pending = increment_pending();
}
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
} 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);
}
deferreds.clear();
}
deferreds.get(batch)?.reject(STALE_REACTION);
deferreds.delete(batch); // delete to ensure correct order in Map iteration below
deferreds.set(batch, d);
}
@ -171,17 +189,26 @@ export function async_derived(fn, label, location) {
* @param {unknown} error
*/
const handler = (value, error = undefined) => {
current_async_effect = null;
if (DEV) current_async_effect = 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);
}
if (error === STALE_REACTION || (effect.f & DESTROYED) !== 0) {
return;
}
batch.activate();
if (error) {
if (error !== STALE_REACTION) {
signal.f |= ERROR_VALUE;
signal.f |= ERROR_VALUE;
// @ts-expect-error the error is the wrong type, but we don't care
internal_set(signal, error);
}
// @ts-expect-error the error is the wrong type, but we don't care
internal_set(signal, error);
} else {
if ((signal.f & ERROR_VALUE) !== 0) {
signal.f ^= ERROR_VALUE;
@ -208,9 +235,7 @@ export function async_derived(fn, label, location) {
}
}
if (decrement_pending) {
decrement_pending();
}
batch.deactivate();
};
d.promise.then(handler, (e) => handler(null, e || 'unknown'));

@ -10,7 +10,8 @@ import {
set_active_reaction,
set_is_destroying_effect,
untrack,
untracking
untracking,
set_active_effect
} from '../runtime.js';
import {
DIRTY,
@ -33,18 +34,18 @@ import {
USER_EFFECT,
ASYNC,
CONNECTED,
MANAGED_EFFECT
MANAGED_EFFECT,
DESTROYING
} from '#client/constants';
import * as e from '../errors.js';
import { DEV } from 'esm-env';
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, schedule_effect } from './batch.js';
import { Batch, collected_effects, current_batch } from './batch.js';
import { flatten, increment_pending } from './async.js';
import { without_reactive_context } from '../dom/elements/bindings/shared.js';
import { set_signal_status } from './status.js';
import { async_mode_flag } from '../../flags/index.js';
/**
* @param {'$effect' | '$effect.pre' | '$inspect'} rune
@ -129,7 +130,7 @@ function create_effect(type, fn) {
collected_effects.push(effect);
} else {
// schedule for later
schedule_effect(effect);
Batch.ensure().schedule(effect);
}
} else if (fn !== null) {
try {
@ -317,7 +318,19 @@ export function legacy_pre_effect(deps, fn) {
if (token.ran) return;
token.ran = true;
untrack(fn);
var effect = /** @type {Effect} */ (active_effect);
// here, we lie: by setting `active_effect` to be the parent branch, any writes
// that happen inside `fn` will _not_ cause an unnecessary reschedule, because
// the affected effects will be children of `active_effect`. this is safe
// because these effects are known to run in the correct order
try {
set_active_effect(effect.parent);
untrack(fn);
} finally {
set_active_effect(effect);
}
});
}
@ -509,9 +522,9 @@ export function destroy_effect(effect, remove_dom = true) {
removed = true;
}
set_signal_status(effect, DESTROYING);
destroy_effect_children(effect, remove_dom && !removed);
remove_reactions(effect, 0);
set_signal_status(effect, DESTROYED);
var transitions = effect.nodes && effect.nodes.t;
@ -523,6 +536,9 @@ export function destroy_effect(effect, remove_dom = true) {
execute_effect_teardown(effect);
effect.f ^= DESTROYING;
effect.f |= DESTROYED;
var parent = effect.parent;
// If the parent doesn't have any children, then skip this work altogether
@ -667,10 +683,13 @@ function resume_children(effect, local) {
if ((effect.f & INERT) === 0) return;
effect.f ^= INERT;
// Mark branches as clean so that effects can be scheduled, but only in async mode
// (in legacy mode, effect resumption happens during traversal)
if (async_mode_flag && (effect.f & BRANCH_EFFECT) !== 0 && (effect.f & CLEAN) === 0) {
effect.f ^= CLEAN;
// If a dependency of this effect changed while it was paused,
// schedule the effect to update. we don't use `is_dirty`
// here because we don't want to eagerly recompute a derived like
// `{#if foo}{foo.bar()}{/if}` if `foo` is now `undefined
if ((effect.f & CLEAN) === 0) {
set_signal_status(effect, DIRTY);
Batch.ensure().schedule(effect); // Assumption: This happens during the commit phase of the batch, causing another flush, but it's safe
}
var child = effect.first;

@ -22,6 +22,7 @@ import { DESTROYED, LEGACY_PROPS, STATE_SYMBOL } from '#client/constants';
import { proxy } from '../proxy.js';
import { capture_store_binding } from './store.js';
import { legacy_mode_flag } from '../../flags/index.js';
import { effect, render_effect } from './effects.js';
/**
* @param {((value?: number) => number)} fn
@ -296,7 +297,7 @@ export function prop(props, key, flags, fallback) {
};
/** @type {((v: V) => void) | undefined} */
var setter;
let setter;
if (bindable) {
// Can be the case when someone does `mount(Component, props)` with `let props = $state({...})`
@ -308,6 +309,7 @@ export function prop(props, key, flags, fallback) {
(is_entry_props && key in props ? (v) => (props[key] = v) : undefined);
}
/** @type {V} */
var initial_value;
var is_store_sub = false;
@ -417,9 +419,7 @@ export function prop(props, key, flags, fallback) {
// special case — avoid recalculating the derived if we're in a
// teardown function and the prop was overridden locally, or the
// component was already destroyed (this latter part is necessary
// because `bind:this` can read props after the component has
// been destroyed. TODO simplify `bind:this`
// component was already destroyed (people could access props in a timeout)
if ((is_destroying_effect && overridden) || (parent_effect.f & DESTROYED) !== 0) {
return d.v;
}

@ -41,7 +41,8 @@ import {
current_batch,
eager_block_effects,
schedule_effect,
set_current_batch
set_current_batch,
legacy_updates
} from './batch.js';
import { proxy } from '../proxy.js';
import { execute_derived } from './deriveds.js';
@ -170,16 +171,17 @@ export function set(source, value, should_proxy = false) {
tag_proxy(new_value, /** @type {string} */ (source.label));
}
return internal_set(source, new_value);
return internal_set(source, new_value, legacy_updates);
}
/**
* @template V
* @param {Source<V>} source
* @param {V} value
* @param {Effect[] | null} [updated_during_traversal]
* @returns {V}
*/
export function internal_set(source, value) {
export function internal_set(source, value, updated_during_traversal = null) {
if (!source.equals(value)) {
var old_value = source.v;
@ -236,7 +238,7 @@ export function internal_set(source, value) {
// For debugging, in case you want to know which reactions are being scheduled:
// log_reactions(source);
mark_reactions(source, DIRTY);
mark_reactions(source, DIRTY, updated_during_traversal);
var batch = Batch.ensure();
batch.capture(source, old_value);
@ -327,9 +329,10 @@ export function increment(source) {
/**
* @param {Value} signal
* @param {number} status should be DIRTY or MAYBE_DIRTY
* @param {Effect[] | null} updated_during_traversal
* @returns {void}
*/
function mark_reactions(signal, status) {
function mark_reactions(signal, status, updated_during_traversal) {
var reactions = signal.reactions;
if (reactions === null) return;
@ -372,14 +375,20 @@ function mark_reactions(signal, status) {
reaction.f |= WAS_MARKED;
}
mark_reactions(derived, MAYBE_DIRTY);
mark_reactions(derived, MAYBE_DIRTY, updated_during_traversal);
}
} else if (not_dirty) {
var effect = /** @type {Effect} */ (reaction);
if ((flags & BLOCK_EFFECT) !== 0 && eager_block_effects !== null) {
eager_block_effects.add(/** @type {Effect} */ (reaction));
eager_block_effects.add(effect);
}
schedule_effect(/** @type {Effect} */ (reaction));
if (updated_during_traversal !== null) {
updated_during_traversal.push(effect);
} else {
schedule_effect(effect);
}
}
}
}

@ -8,6 +8,12 @@ import { teardown } from './effects.js';
import { mutable_source, set } from './sources.js';
import { DEV } from 'esm-env';
/**
* We set this to `true` when updating a store so that we correctly
* schedule effects if the update takes place inside a `$:` effect
*/
export let legacy_is_updating_store = false;
/**
* Whether or not the prop currently being read is a store binding, as in
* `<Child bind:x={$y} />`. If it is, we treat the prop as mutable even in
@ -102,7 +108,7 @@ export function store_unsub(store, store_name, stores) {
* @returns {V}
*/
export function store_set(store, value) {
store.set(value);
update_with_flag(store, value);
return value;
}
@ -141,6 +147,21 @@ export function setup_stores() {
return [stores, cleanup];
}
/**
* @param {Store<V>} store
* @param {V} value
* @template V
*/
function update_with_flag(store, value) {
legacy_is_updating_store = true;
try {
store.set(value);
} finally {
legacy_is_updating_store = false;
}
}
/**
* Updates a store with a new value.
* @param {Store<V>} store the store to update
@ -149,7 +170,7 @@ export function setup_stores() {
* @template V
*/
export function store_mutate(store, expression, new_value) {
store.set(new_value);
update_with_flag(store, new_value);
return expression;
}
@ -160,7 +181,7 @@ export function store_mutate(store, expression, new_value) {
* @returns {number}
*/
export function update_store(store, store_value, d = 1) {
store.set(store_value + d);
update_with_flag(store, store_value + d);
return store_value;
}
@ -172,7 +193,7 @@ export function update_store(store, store_value, d = 1) {
*/
export function update_pre_store(store, store_value, d = 1) {
const value = store_value + d;
store.set(value);
update_with_flag(store, value);
return value;
}

@ -38,6 +38,12 @@ export type ComponentContext = {
* @deprecated remove in 6.0
*/
x: Record<string, any> | null;
/**
* The parent effect of this component
* TODO 6.0 this is used to control `bind:this` timing that might change,
* in which case we can remove this property
*/
r: Effect;
/**
* legacy stuff
* @deprecated remove in 6.0

@ -4,5 +4,5 @@
* The current version, as set in package.json.
* @type {string}
*/
export const VERSION = '5.53.7';
export const VERSION = '5.53.9';
export const PUBLIC_VERSION = '5';

@ -0,0 +1,9 @@
<script lang="ts">
import { count } from './stores';
let n = 0;
$: $count = n;
</script>
<button onclick={() => n += 1}>{$count}</button>

@ -0,0 +1,22 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: `<button>0</button><button>0</button>`,
test({ assert, target }) {
const [button1, button2] = target.querySelectorAll('button');
flushSync(() => button1.click());
assert.htmlEqual(target.innerHTML, `<button>1</button><button>1</button>`);
flushSync(() => button1.click());
assert.htmlEqual(target.innerHTML, `<button>2</button><button>2</button>`);
flushSync(() => button2.click());
assert.htmlEqual(target.innerHTML, `<button>1</button><button>1</button>`);
flushSync(() => button2.click());
assert.htmlEqual(target.innerHTML, `<button>2</button><button>2</button>`);
}
});

@ -0,0 +1,6 @@
<script>
import Child from './Child.svelte';
</script>
<Child />
<Child />

@ -0,0 +1,3 @@
import { writable } from 'svelte/store';
export const count = writable(0);

@ -1,4 +1,4 @@
import { flushSync, tick } from 'svelte';
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
@ -7,12 +7,7 @@ export default test({
`,
async test({ assert, target }) {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
flushSync();
await tick();
assert.htmlEqual(target.innerHTML, '<p data-foo="bar">hello</p>');
}
});

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

@ -0,0 +1,19 @@
<script>
let queue = [];
function push(value) {
const deferred = Promise.withResolvers();
queue.push(() => deferred.resolve(value));
return deferred.promise;
}
let count = $state(0);
</script>
<button onclick={() => queue.shift()()}>shift</button>
<button onclick={() => count++}>increment</button>
<svelte:boundary>
{await push(count)}
{#snippet pending()}loading{/snippet}
</svelte:boundary>

@ -0,0 +1,9 @@
<script>
let { data } = $props();
const processed = $derived(data.toUpperCase());
export function getProcessed() {
return processed;
}
</script>

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

@ -0,0 +1,19 @@
<script>
import Inner from './Inner.svelte';
let value = $state('hello');
let innerComp = $state();
// Reads Inner's derived value from outside the {#if} block, keeping it
// connected in the reactive graph even after the branch is destroyed.
const externalView = $derived(innerComp?.getProcessed() ?? '');
</script>
{#if value}
{@const result = value}
<Inner data={result} bind:this={innerComp} />
{/if}
<button onclick={() => (value = undefined)}>clear</button>
<p>{externalView}</p>

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

@ -0,0 +1,10 @@
<script>
let value = $state('hello');
let elements = {};
</script>
{#if value}
<span bind:this={elements[value.toUpperCase()]}>{value}</span>
{/if}
<button onclick={() => (value = undefined)}>clear</button>

@ -0,0 +1,25 @@
import { flushSync } from '../../../../src/index-client';
import { test } from '../../test';
export default test({
html: `<div id="editable" contenteditable="true"></div><p id="output"></p>`,
test({ assert, target }) {
const div = /** @type {HTMLDivElement} */ (target.querySelector('#editable'));
const output = /** @type {HTMLParagraphElement} */ (target.querySelector('#output'));
// Simulate user typing by directly modifying the DOM
div.textContent = 'hello';
// Simulate blur which triggers `content = e.currentTarget.innerText`
const event = new Event('blur');
div.dispatchEvent(event);
flushSync();
// The output should show "hello" (innerText was set correctly)
assert.equal(output.textContent, 'hello');
// The contenteditable div should contain "hello" once, not duplicated
assert.htmlEqual(div.innerHTML, 'hello');
}
});

@ -0,0 +1,9 @@
<script>
let content = $state("");
</script>
<div id="editable" onblur={(e) => { content = e.currentTarget.textContent; }} contenteditable="true">
{@html content}
</div>
<p id="output">{content}</p>

@ -0,0 +1,14 @@
import { tick } from 'svelte';
import { test } from '../../test';
import { raf } from '../../../animation-helpers';
export default test({
async test({ assert, target }) {
const [btn] = target.querySelectorAll('button');
btn.click();
await tick();
raf.tick(100);
assert.htmlEqual(target.innerHTML, `<button>clear</button>`);
}
});

@ -0,0 +1,17 @@
<script>
import { fade } from 'svelte/transition';
let data = $state({ id: 1 });
</script>
<button onclick={() => (data = null)}>clear</button>
{#if data}
{#key data?.id}
<p transition:fade|global={{ duration: 100 }}>keyed</p>
{/key}
{#if data.id}
<p>sibling</p>
{/if}
{/if}

@ -1293,8 +1293,8 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
enhanced-resolve@5.19.0:
resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==}
enhanced-resolve@5.20.0:
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
engines: {node: '>=10.13.0'}
enquirer@2.4.1:
@ -1604,8 +1604,8 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immutable@4.3.7:
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
immutable@4.3.8:
resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
@ -3589,7 +3589,7 @@ snapshots:
emoji-regex@9.2.2: {}
enhanced-resolve@5.19.0:
enhanced-resolve@5.20.0:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
@ -3685,7 +3685,7 @@ snapshots:
eslint-plugin-n@17.24.0(eslint@10.0.0)(typescript@5.5.4):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.0)
enhanced-resolve: 5.19.0
enhanced-resolve: 5.20.0
eslint: 10.0.0
eslint-plugin-es-x: 7.8.0(eslint@10.0.0)
get-tsconfig: 4.13.6
@ -3974,7 +3974,7 @@ snapshots:
ignore@7.0.5: {}
immutable@4.3.7:
immutable@4.3.8:
optional: true
imurmurhash@0.1.4: {}
@ -4450,7 +4450,7 @@ snapshots:
sass@1.70.0:
dependencies:
chokidar: 3.6.0
immutable: 4.3.7
immutable: 4.3.8
source-map-js: 1.2.1
optional: true

Loading…
Cancel
Save