From 304c452fa16be010b96de5cb59fc4c51ef932ba2 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 7 Jul 2026 21:31:28 +0200 Subject: [PATCH] wip --- .../src/internal/client/reactivity/batch.js | 230 ++++++++++++++---- .../internal/client/reactivity/deriveds.js | 27 +- .../internal/client/reactivity/equality.js | 4 +- .../src/internal/client/reactivity/sources.js | 27 +- .../src/internal/client/reactivity/types.d.ts | 9 + .../svelte/src/internal/client/runtime.js | 59 +++-- .../async-batch-derived-clean-flag/_config.js | 74 ++++++ .../main.svelte | 21 ++ 8 files changed, 383 insertions(+), 68 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-derived-clean-flag/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-batch-derived-clean-flag/main.svelte diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 16d1ba273a..7d0e8e500f 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -1,5 +1,5 @@ /** @import { Fork } from 'svelte' */ -/** @import { Derived, Effect, Reaction, Source, Value, ValueSnapshot } from '#client' */ +/** @import { Derived, Effect, Reaction, Source, Value, ValueRecord, ValueSnapshot } from '#client' */ import { BLOCK_EFFECT, BRANCH_EFFECT, @@ -65,6 +65,25 @@ export let current_batch = null; **/ export let active_batch = null; +/** + * The `values` overlay of the active batch, mirrored into a module-level + * variable (kept in sync via `set_active_batch`) so that hot paths can check + * for the presence of an overlay with a single comparison. + * `null` means we are reading the 'global view' + * @type {Map> | null} + */ +export let overlay_values = null; + +/** + * Update `active_batch` — and `overlay_values`, which must always equal + * `active_batch?.values ?? null` + * @param {Batch | null} batch + */ +function set_active_batch(batch) { + active_batch = batch; + overlay_values = batch === null ? null : batch.values; +} + /** * This is needed to avoid overwriting inputs * @type {Batch | null} @@ -127,18 +146,20 @@ export class Batch { /** * 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) - * They keys of this map are identical to `this.#previous` - * @type {Map>} + * Each record also contains the value/write version from _before_ the update + * took place (`pv`/`pwv`), for time travelling — `pv === UNINITIALIZED` means + * there is no previous value + * @type {Map>} */ current = new Map(); /** - * The values of any signals (sources and deriveds) that are updated in this batch _before_ those updates took place. - * They keys of this map are identical to `this.#current` - * @type {Map>} + * Previous values of signals that are deliberately _not_ (or no longer) + * represented in `current` — error values, and deriveds that were + * invalidated during a rebase. Lazily created, as this is rare + * @type {Map> | null} */ - previous = new Map(); + #previous = null; /** * The combination of this batch's `current` and other batches' `previous` values, @@ -201,6 +222,13 @@ export class Batch { /** @type {Map} */ cvs = new Map(); + /** + * `true` if `cvs` contains any deriveds (only the case in rare multi-batch + * scenarios — e.g. rebase sentinels, or recomputations under an overlay). + * This lets `get_cv` skip the map lookup for deriveds in the common case + */ + has_derived_cvs = false; + /** * A map of branches that still exist, but will be destroyed when this batch * is committed — we skip over these during `process`. @@ -433,7 +461,7 @@ export class Batch { } } - active_batch = null; + set_active_batch(null); if (next_batch !== null) { next_batch.#process(); @@ -514,14 +542,33 @@ export class Batch { * @param {Batch} batch */ #merge(batch) { - for (const [source, snapshot] of batch.current) { - var previous = batch.previous.get(source); + for (const [source, record] of batch.current) { + var existing = this.current.get(source); + + if (existing === undefined) { + // adopt the record (including its previous value) — unless this batch + // has its own, older previous value for the source + var previous = this.#previous?.get(source); + + if (previous !== undefined) { + this.#previous?.delete(source); + record.pv = previous.v; + record.pwv = previous.wv; + record.p = previous; + } - if (previous && !this.previous.has(source)) { - this.previous.set(source, previous); + this.current.set(source, record); + } else { + existing.v = record.v; + existing.wv = record.wv; + + // this batch's own previous value takes precedence, if it has one + if (existing.pv === UNINITIALIZED && record.pv !== UNINITIALIZED) { + existing.pv = record.pv; + existing.pwv = record.pwv; + existing.p = record.p; + } } - - this.current.set(source, snapshot); } for (const [effect, deferred] of batch.async_deriveds) { @@ -612,18 +659,37 @@ export class Batch { * @param {number} wv */ capture(value, v, wv) { - if (value.v !== UNINITIALIZED && !this.previous.has(value)) { - this.previous.set(value, { v: value.v, wv: value.wv }); - } + var record = this.current.get(value); - // Don't save errors or they won't be thrown in `runtime.js#get` - if ((value.f & ERROR_VALUE) === 0) { - var snapshot = { v, wv }; + if ((value.f & ERROR_VALUE) !== 0) { + // Don't save errors, or they won't be thrown in `runtime.js#get` — + // but do preserve the previous value, for time travelling + if (record === undefined && value.v !== UNINITIALIZED) { + var stash = (this.#previous ??= new Map()); - this.current.delete(value); // order must be preserved + if (!stash.has(value)) { + stash.set(value, { v: value.v, wv: value.wv }); + } + } + } else if (record === undefined) { + // note: we don't need to preserve write order in the map — every + // consumer of `current` uses the write versions in the records, + // which encode the ordering + var previous = this.#previous?.get(value); + + if (previous !== undefined) { + this.#previous?.delete(value); + record = { v, wv, pv: previous.v, pwv: previous.wv, p: previous }; + } else { + record = { v, wv, pv: value.v, pwv: value.wv, p: null }; + } - this.current.set(value, snapshot); - active_batch?.values?.set(value, snapshot); + this.current.set(value, record); + overlay_values?.set(value, record); + } else { + record.v = v; + record.wv = wv; + overlay_values?.set(value, record); } if (!this.is_fork) { @@ -632,13 +698,30 @@ export class Batch { } } + /** + * Remove a value from `current`, so that it is no longer treated as having + * been changed by this batch (e.g. a derived that needs to be recomputed + * following a rebase). Its previous value is preserved for time travelling + * @param {Value} value + */ + uncapture(value) { + var record = this.current.get(value); + if (record === undefined) return; + + this.current.delete(value); + + if (record.pv !== UNINITIALIZED) { + (this.#previous ??= new Map()).set(value, (record.p ??= { v: record.pv, wv: record.pwv })); + } + } + activate() { current_batch = this; } deactivate() { current_batch = null; - active_batch = null; + set_active_batch(null); } flush() { @@ -657,7 +740,7 @@ export class Batch { is_processing = false; current_batch = null; - active_batch = null; + set_active_batch(null); old_values.clear(); @@ -710,8 +793,12 @@ export class Batch { if (batch_snapshot) { if (is_earlier && snapshot.v !== batch_snapshot.v) { - // bring the value up to date - batch.current.set(source, snapshot); + // bring the value up to date. Note that we mutate the existing + // record rather than sharing ours — records are mutable, and + // sharing one between multiple live batches would cause + // writes in one batch to leak into another + batch_snapshot.v = snapshot.v; + batch_snapshot.wv = snapshot.wv; } else { // same value or later batch has more recent value, // no need to re-run these effects @@ -896,16 +983,24 @@ export class Batch { apply() { if (!async_mode_flag) { - // TODO previously we bailed here if there was only one (non-fork) batch... maybe we can reinstate that return; } - if (active_batch === this) return; - active_batch = this; + // If this is the only batch, and not a fork, the 'global view' is already + // correct (values are written through to the signals as they change) — + // we don't need an overlay, and `is_dirty` can rely on the `CLEAN` flag + if (!this.is_fork && this.#prev === null && this.#next === null) { + this.values = null; + set_active_batch(this); + return; + } + + if (active_batch === this && this.values !== null) return; // if there are multiple batches, we are 'time travelling' — // we need to override values with the ones in this batch... this.values = new Map(this.current); + set_active_batch(this); // ...and undo changes belonging to other batches unless they intersect for (let batch = first_batch; batch !== null; batch = batch.#next) { @@ -925,9 +1020,17 @@ export class Batch { } if (!intersects) { - for (const [value, snapshot] of batch.previous) { - if (!this.values.has(value)) { - this.values.set(value, snapshot); + for (const [value, record] of batch.current) { + if (record.pv !== UNINITIALIZED && !this.values.has(value)) { + this.values.set(value, (record.p ??= { v: record.pv, wv: record.pwv })); + } + } + + if (batch.#previous !== null) { + for (const [value, snapshot] of batch.#previous) { + if (!this.values.has(value)) { + this.values.set(value, snapshot); + } } } } @@ -941,6 +1044,10 @@ export class Batch { schedule(effect) { last_scheduled_effect = effect; + // the effect can no longer be considered definitely-clean, + // its dependencies need to be checked when it is reached + effect.f &= ~CLEAN; + if (!this.cvs.has(effect)) { this.cvs.set(effect, effect.cv); } @@ -1188,8 +1295,13 @@ function mark_effects(batch, value, sources, marked, checked) { const flags = reaction.f; if ((flags & DERIVED) !== 0) { - batch.current.delete(/** @type {Derived} */ (reaction)); + batch.uncapture(/** @type {Derived} */ (reaction)); batch.cvs.set(/** @type {Derived} */ (reaction), -1); + batch.has_derived_cvs = true; + + // the sentinel above forces a recomputation within `batch` — + // it must not be bypassed by a cached 'definitely clean' state + reaction.f &= ~CLEAN; mark_effects(batch, /** @type {Derived} */ (reaction), sources, marked, checked); } else if ((flags & (ASYNC | BLOCK_EFFECT)) !== 0 && depends_on(reaction, sources, checked)) { @@ -1310,10 +1422,10 @@ export function eager(fn) { try { running_eager_effect = true; - active_batch = null; + set_active_batch(null); value = fn(); } finally { - active_batch = previous_batch; + set_active_batch(previous_batch); running_eager_effect = previous_running_eager_effect; } @@ -1480,7 +1592,7 @@ export function fork(fn) { * @param {Value} value */ export function get_wv(value) { - var snapshot = active_batch?.values?.get(value); + var snapshot = overlay_values?.get(value); return snapshot ? snapshot.wv : value.wv; } @@ -1488,16 +1600,52 @@ export function get_wv(value) { * @param {Reaction} reaction */ export function get_cv(reaction) { - return active_batch?.cvs.get(reaction) ?? reaction.cv; + if (active_batch !== null && ((reaction.f & DERIVED) === 0 || active_batch.has_derived_cvs)) { + var cv = active_batch.cvs.get(reaction); + if (cv !== undefined) return cv; + } + + return reaction.cv; } /** * @param {Reaction} reaction */ export function set_cv(reaction, cv = write_version) { - // TODO seems weird to have both of these + if ((reaction.f & DERIVED) !== 0) { + // For deriveds, only update the global check version if it was computed + // against the global view. A check version computed under a divergent + // overlay could claim more freshness than the (written-through, global) + // values warrant, causing reads in the global view to skip a necessary + // recomputation + if (!current_batch?.is_fork && !running_eager_effect && overlay_values === null) { + // in the global view, the batch-scoped entries are redundant — + // `get_cv` falls back to the global check version we write here + reaction.cv = cv; + return; + } + + if (current_batch !== null) { + current_batch.cvs.set(reaction, cv); + current_batch.has_derived_cvs = true; + } + + if (active_batch !== null && active_batch !== current_batch) { + active_batch.cvs.set(reaction, cv); + active_batch.has_derived_cvs = true; + } + + return; + } + + // For effects the batch-scoped entries are always needed (they update the + // snapshots taken in `schedule`, without which an effect that ran would + // still appear dirty). The global check version is also always updated: + // re-runs with other batches' values are handled explicitly during + // rebasing, and a stale-low check version would cause spurious re-runs + // (cancelling in-flight async work) current_batch?.cvs.set(reaction, cv); - active_batch?.cvs.set(reaction, cv); + if (active_batch !== current_batch) active_batch?.cvs.set(reaction, cv); if (!current_batch?.is_fork && !running_eager_effect) { reaction.cv = cv; diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 99a809dbaa..174c144364 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -36,7 +36,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 { current_batch, get_wv, active_batch, set_cv, previous_batch } from './batch.js'; +import { current_batch, active_batch, overlay_values, set_cv, previous_batch } from './batch.js'; import { increment_pending, unset_context } from './async.js'; import { deferred, noop } from '../../shared/utils.js'; @@ -345,9 +345,12 @@ export function execute_derived(derived) { set_active_effect(parent); - derived_stack ??= []; - if (DEV) { + // the stack allows us to detect self-referencing deriveds (in `get`). + // it is maintained in DEV only, because scanning it on every read + // is too expensive for production + derived_stack ??= []; + // TODO don't we need eager effects in prod too? let prev_eager_effects = eager_effects; set_eager_effects(new Set()); @@ -364,13 +367,11 @@ export function execute_derived(derived) { } } else { try { - derived_stack.push(derived); derived.f &= ~WAS_MARKED; destroy_derived_effects(derived); value = update_reaction(derived); } finally { set_active_effect(prev_active_effect); - derived_stack.pop(); } } @@ -386,12 +387,20 @@ export function update_derived(derived) { var deps = derived.deps; var cv = Infinity; + var overlay = overlay_values; if (deps !== null) { cv = -Infinity; for (var i = 0; i < deps.length; i++) { - var dep_wv = get_wv(deps[i]); + var dep = deps[i]; + var dep_wv = dep.wv; + + if (overlay !== null) { + var snapshot = overlay.get(dep); + if (snapshot !== undefined) dep_wv = snapshot.wv; + } + if (dep_wv > cv) cv = dep_wv; } } @@ -415,7 +424,11 @@ export function update_derived(derived) { } } - if (active_batch === null && (derived.f & CONNECTED) !== 0) { + // In the global view (no overlay active) we can cache the fact that the derived + // is now consistent with its dependencies, so that reads can skip the dependency + // check in `is_dirty`. Don't do this inside a cleanup function, or we would + // cache a stale value + if (!is_destroying_effect && (derived.f & CONNECTED) !== 0 && overlay_values === null) { derived.f |= CLEAN; } } diff --git a/packages/svelte/src/internal/client/reactivity/equality.js b/packages/svelte/src/internal/client/reactivity/equality.js index 9f13bdc8c6..fc4304fe93 100644 --- a/packages/svelte/src/internal/client/reactivity/equality.js +++ b/packages/svelte/src/internal/client/reactivity/equality.js @@ -1,9 +1,9 @@ /** @import { Equals } from '#client' */ -import { active_batch } from './batch.js'; +import { overlay_values } from './batch.js'; /** @type {Equals} */ export function equals(value) { - var snapshot = active_batch?.values?.get(this); + var snapshot = overlay_values?.get(this); return value === (snapshot ? snapshot.v : this.v); } diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index aa0206a917..37579486f9 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -30,7 +30,7 @@ import { REACTION_RAN } from '#client/constants'; import * as e from '../errors.js'; -import { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js'; +import { async_mode_flag, legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { tag_proxy } from '../dev/tracing.js'; import { get_error } from '../../shared/dev.js'; import { component_context, is_runes } from '../context.js'; @@ -40,7 +40,7 @@ import { legacy_updates, set_cv, get_cv, - active_batch, + overlay_values, current_batch } from './batch.js'; import { proxy } from '../proxy.js'; @@ -193,6 +193,14 @@ export function internal_set(source, value, updated_during_traversal = null) { } set_cv(derived); + + // an assignment behaves like a source write: the value is written + // through to the signal (below, in `capture`), so the global check + // version must be written too — the pair is consistent regardless + // of any active overlay + if (!batch.is_fork) { + derived.cv = write_version; + } } batch.capture(source, value, increment_write_version()); @@ -341,6 +349,8 @@ export function mark_reactions(batch, signal, wv, updated_during_traversal) { // if (wv <= get_cv(reaction)) continue; if ((flags & EAGER_EFFECT) !== 0) { + reaction.f &= ~CLEAN; + // 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 @@ -352,9 +362,16 @@ export function mark_reactions(batch, signal, wv, updated_during_traversal) { // If setting state inside an effect, `batch !== active_batch` — // we need to invalidate the current overlay so that subsequent // effects read the correct value - active_batch?.values?.delete(derived); + overlay_values?.delete(derived); + + // In sync mode `update_derived` never captures deriveds into + // `batch.current` (only assignments do), and none of the machinery + // that consumes stale derived entries (overlays, commit rebasing) + // runs — so we can skip this in that case + if (async_mode_flag) { + batch.uncapture(derived); + } - batch.current.delete(derived); derived.f &= ~CLEAN; if ((flags & WAS_MARKED) === 0) { @@ -373,6 +390,8 @@ export function mark_reactions(batch, signal, wv, updated_during_traversal) { } else { var effect = /** @type {Effect} */ (reaction); + effect.f &= ~CLEAN; + if ((flags & BLOCK_EFFECT) !== 0 && eager_block_effects !== null) { eager_block_effects.add(effect); } diff --git a/packages/svelte/src/internal/client/reactivity/types.d.ts b/packages/svelte/src/internal/client/reactivity/types.d.ts index 72065a5ce1..47691291af 100644 --- a/packages/svelte/src/internal/client/reactivity/types.d.ts +++ b/packages/svelte/src/internal/client/reactivity/types.d.ts @@ -109,3 +109,12 @@ export interface ValueSnapshot { v: T; wv: number; } + +export interface ValueRecord extends ValueSnapshot { + /** the value before the batch's first write (`UNINITIALIZED` if there was none) */ + pv: unknown; + /** the write version before the batch's first write */ + pwv: number; + /** lazily created `{ v: pv, wv: pwv }` snapshot, for time travelling */ + p: ValueSnapshot | null; +} diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index b4f6636b2e..30bb9bdf3e 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -51,9 +51,8 @@ import { current_batch, flushSync, get_cv, - active_batch, + overlay_values, set_cv, - get_wv, previous_batch } from './reactivity/batch.js'; import { handle_error } from './error-handling.js'; @@ -162,20 +161,28 @@ export function is_dirty(reaction) { return true; } + // If no overlay is active (`overlay_values === null`), we are reading the + // 'global view'. In the global view the `CLEAN` flag is a cache of the + // dependency check below: it guarantees that no dependency has a write + // version newer than the reaction's check version. It must be ignored while + // an overlay is active, because the overlay may present different + // values/versions than the global ones the flag was computed against. + var overlay = overlay_values; + if (flags & DERIVED) { reaction.f &= ~WAS_MARKED; if ((flags & CONNECTED) !== 0) { - if (active_batch !== null) { - if (active_batch.values?.has(/** @type {Derived} */ (reaction))) { - return false; - } - } else { - if ((reaction.f & CLEAN) !== 0) { + if (overlay !== null) { + if (overlay.has(/** @type {Derived} */ (reaction))) { return false; } + } else if ((flags & CLEAN) !== 0) { + return false; } } + } else if (overlay === null && (flags & CLEAN) !== 0) { + return false; } var dependencies = /** @type {Value[]} */ (reaction.deps); @@ -197,11 +204,24 @@ export function is_dirty(reaction) { } } - if (get_wv(dependency) > cv) { + var wv = dependency.wv; + + if (overlay !== null) { + var snapshot = overlay.get(dependency); + if (snapshot !== undefined) wv = snapshot.wv; + } + + if (wv > cv) { return true; } } + // cache the result of the dependency check, so that subsequent + // reads in the global view can skip it + if (overlay === null && (flags & CONNECTED) !== 0) { + reaction.f |= CLEAN; + } + return false; } @@ -402,10 +422,12 @@ function remove_reaction(signal, dependency) { var derived = /** @type {Derived} */ (dependency); // If we are working with a derived that is owned by an effect, then mark it as being - // disconnected and remove the mark flag, as it cannot be reliably removed otherwise + // disconnected and remove the mark flag, as it cannot be reliably removed otherwise. + // A disconnected derived no longer receives invalidations, so it must not + // carry a `CLEAN` flag that would exempt it from checking its dependencies if ((derived.f & CONNECTED) !== 0) { derived.f ^= CONNECTED; - derived.f &= ~WAS_MARKED; + derived.f &= ~(WAS_MARKED | CLEAN); } // freeze any effects inside this derived @@ -441,6 +463,11 @@ export function update_effect(effect) { return; } + // mark the effect clean before running it — writes to its dependencies + // during execution will unset the flag again (via `mark_reactions` + // or `schedule`), causing a re-run + effect.f |= CLEAN; + var previous_effect = active_effect; var was_updating_effect = is_updating_effect; @@ -645,7 +672,9 @@ export function get(signal) { if (is_derived) { var derived = /** @type {Derived} */ (signal); - if (derived_stack !== null && includes.call(derived_stack, derived)) { + // `derived_stack` is only maintained in DEV - would be too costly in production, + // so a self-referencing derived returns its stale value there + if (DEV && derived_stack !== null && includes.call(derived_stack, derived)) { e.derived_references_self(); } @@ -689,8 +718,10 @@ export function get(signal) { } } - var snapshot = active_batch?.values?.get(signal); - if (snapshot) return /** @type {V} */ (snapshot.v); + if (overlay_values !== null) { + var snapshot = overlay_values.get(signal); + if (snapshot !== undefined) return /** @type {V} */ (snapshot.v); + } if ((signal.f & ERROR_VALUE) !== 0) { throw signal.v; diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-derived-clean-flag/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-derived-clean-flag/_config.js new file mode 100644 index 0000000000..6ce33f8ee6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-derived-clean-flag/_config.js @@ -0,0 +1,74 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Guards against 'definitely clean' state (the `CLEAN` flag / a too-new check +// version) leaking between batches: `sum` is affected by both the deferred +// batch (which changes `a`) and the quicker batch (which changes `b`). +// Consuming the invalidation in one batch/view must not prevent other views +// from recomputing `sum` with their own values +export default test({ + async test({ assert, target, logs }) { + await tick(); + + const [a, b, shift, read] = target.querySelectorAll('button'); + + assert.htmlEqual( + target.innerHTML, + ` + + + + +

10

+

0

+ ` + ); + + // batch A: changes `a`, is deferred while `delay(a)` is pending + a.click(); + await tick(); + + // batch B: changes `b`. It doesn't intersect with A, so it commits + // independently with its own consistent view (a = 0, b = 20) — + // `a`'s change is withheld until the async work is done + b.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + + +

20

+

0

+ ` + ); + + // reading `sum` outside any batch sees the written-through values + // of both batches — this read must not poison the state that batch A + // relies on to recompute `sum` when it resumes + read.click(); + assert.deepEqual(logs, [21]); + + // resolve batch A — `sum` must reflect both changes + shift.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + + +

21

+

1

+ ` + ); + + read.click(); + assert.deepEqual(logs, [21, 21]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-derived-clean-flag/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-derived-clean-flag/main.svelte new file mode 100644 index 0000000000..a32c1d35b9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-derived-clean-flag/main.svelte @@ -0,0 +1,21 @@ + + + + + + + +

{sum}

+

{await delay(a)}