diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js index 141b914537..7bb410da39 100644 --- a/packages/svelte/src/internal/client/dom/blocks/branches.js +++ b/packages/svelte/src/internal/client/dom/blocks/branches.js @@ -1,5 +1,11 @@ /** @import { Effect, TemplateNode } from '#client' */ -import { Batch, current_batch } from '../../reactivity/batch.js'; +import { + Batch, + current_batch, + depends_on_fork_values, + speculative_branches, + speculative_selectors +} from '../../reactivity/batch.js'; import { branch, destroy_effect, @@ -7,7 +13,8 @@ import { pause_effect, resume_effect } from '../../reactivity/effects.js'; -import { HMR_ANCHOR } from '../../constants.js'; +import { EFFECT_PRESERVED, HMR_ANCHOR } from '../../constants.js'; +import { active_effect } from '../../runtime.js'; import { hydrate_node, hydrating } from '../hydration.js'; import { create_text, should_defer_append } from '../operations.js'; import { DEV } from 'esm-env'; @@ -26,6 +33,9 @@ export class BranchManager { /** @type {Map} */ #batches = new Map(); + /** @type {Effect | null} */ + #effect = null; + /** * Map of keys to effects that are currently rendered in the DOM. * These effects are visible and actively part of the document tree. @@ -90,6 +100,8 @@ export class BranchManager { var offscreen = this.#offscreen.get(key); if (offscreen) { + speculative_branches.delete(offscreen.effect); + // effect could have been outro'ed before through a prior batch — resume if necessary resume_effect(offscreen.effect); this.#onscreen.set(key, offscreen.effect); @@ -111,6 +123,14 @@ export class BranchManager { } for (const [b, k] of this.#batches) { + var fork = b.resolved(); + if ( + fork.is_fork && + depends_on_fork_values(/** @type {Effect} */ (this.#effect), fork, batch.resolved()) + ) { + continue; + } + this.#batches.delete(b); if (b === batch) { @@ -171,6 +191,8 @@ export class BranchManager { const keys = Array.from(this.#batches.values()); for (const [k, branch] of this.#offscreen) { + speculative_branches.get(branch.effect)?.batches.delete(batch); + if (!keys.includes(k)) { destroy_effect(branch.effect); this.#offscreen.delete(k); @@ -185,7 +207,14 @@ export class BranchManager { */ ensure(key, fn) { var batch = /** @type {Batch} */ (current_batch); - var defer = should_defer_append(); + var defer = batch.is_fork || should_defer_append(); + this.#effect = /** @type {Effect} */ (active_effect); + + if (batch.is_fork) { + // Even constant selectors must survive so another batch can select their branches. + this.#effect.f |= EFFECT_PRESERVED; + speculative_selectors.add(this.#effect); + } if (fn && !this.#onscreen.has(key) && !this.#offscreen.has(key)) { if (defer) { @@ -194,10 +223,16 @@ export class BranchManager { fragment.append(target); - this.#offscreen.set(key, { - effect: branch(() => fn(target)), - fragment - }); + var effect = branch(() => fn(target)); + this.#offscreen.set(key, { effect, fragment }); + + if (batch.is_fork) { + speculative_branches.set(effect, { + batches: new Set([batch]), + d: new Set(), + m: new Set() + }); + } } else { this.#onscreen.set( key, @@ -221,6 +256,7 @@ export class BranchManager { if (k === key) { batch.unskip_effect(branch.effect); } else { + speculative_branches.get(branch.effect)?.batches.delete(batch); batch.skip_effect(branch.effect); } } @@ -228,6 +264,9 @@ export class BranchManager { batch.oncommit(this.#commit); batch.ondiscard(this.#discard); } else { + var offscreen = this.#offscreen.get(key); + if (offscreen) batch.unskip_effect(offscreen.effect); + if (hydrating) { this.anchor = hydrate_node; } diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 2c7cc95ce3..7f098015cf 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -42,7 +42,7 @@ import { update } from './sources.js'; import { eager_effect, teardown, unlink_effect } from './effects.js'; -import { defer_effect } from './utils.js'; +import { clear_marked, defer_effect } from './utils.js'; import { UNINITIALIZED } from '../../../constants.js'; import { set_signal_status } from './status.js'; import { OBSOLETE } from './deriveds.js'; @@ -73,6 +73,16 @@ export let active_batch = null; */ export let previous_batch = null; +/** + * Fork-created offscreen branches can only be traversed by batches that selected them. + * Dirty descendants are retained here so later selectors can also pick up their updates. + * @type {WeakMap, d: Set, m: Set }>} + */ +export const speculative_branches = new WeakMap(); + +/** @type {WeakSet} */ +export const speculative_selectors = new WeakSet(); + /** @type {Effect | null} */ let last_scheduled_effect = null; @@ -302,6 +312,15 @@ export class Batch { */ is_eager = false; + /** + * `true` once this batch has committed (some of) its UI — from that point on + * it can no longer entangle with other pending batches on new reads, because + * what is on screen was rendered with this batch's own world (a batch can + * stay live after committing, e.g. while a boundary shows its pending + * snippet until the async work inside it settles) + */ + committed = false; + /** * If this batch was merged into another one (because their reactivity graphs * turned out to overlap), this points to the batch it was merged into. Stale @@ -321,16 +340,6 @@ export class Batch { */ fork_effects = null; - /** - * Reactions that observed the pre-write world of this batch via its active - * overlay while it was pending, mapped to the values they saw. When this - * batch commits, readers whose observed values differ from the committed - * ones re-run with the real values. - * Lazily initialised for perf reasons - * @type {Map> | null} - */ - stale_readers = null; - /** * `true` while this batch is flushing its effects and is provably terminal — * solitary, with no pending async work and nothing scheduled. Such a batch @@ -426,10 +435,22 @@ export class Batch { */ unskip_effect(effect) { var tracked = this.#skipped_branches?.get(effect); + var speculative = speculative_branches.get(effect); + + if ( + speculative !== undefined && + !Array.from(speculative.batches, (batch) => batch.resolved()).includes(this) + ) { + speculative.batches.add(this); + revalidate_branch(effect, this); + tracked = { + d: [...(tracked?.d ?? []), ...speculative.d], + m: [...(tracked?.m ?? []), ...speculative.m] + }; + } + if (tracked) { - /** @type {Map} */ (this.#skipped_branches).delete( - effect - ); + this.#skipped_branches?.delete(effect); for (var e of tracked.d) { set_signal_status(e, DIRTY); @@ -711,14 +732,6 @@ export class Batch { this.#scheduled.push(...other.#scheduled); other.#scheduled = []; - // TODO could a newer value have been observed by this and other is older? - this.stale_readers = transfer_map( - this.stale_readers, - other.stale_readers, - (observed, seen) => /** @type {Map} */ (transfer_map(observed, seen)) - ); - other.stale_readers = null; - if (other.waiting !== null) { var waiting = (this.waiting ??= { batches: new Set(), reactions: new Map() }); @@ -732,6 +745,7 @@ export class Batch { } this.restarts = Math.max(this.restarts, other.restarts); + this.committed ||= other.committed; // `other`'s settled() promise resolves when this batch settles if (other.#deferred !== null) { @@ -922,6 +936,10 @@ export class Batch { this.#dirty_effects = null; this.#maybe_dirty_effects = null; + // this batch's UI is about to hit the DOM — new reads can no longer + // entangle it with other pending batches + this.committed = true; + // append/remove branches if (this.#commit_callbacks !== null) { for (const fn of this.#commit_callbacks) fn(this); @@ -987,6 +1005,31 @@ export class Batch { (flags & INERT) !== 0 || this.#skipped_branches?.has(effect) === true; + var speculative = !skip && is_branch ? speculative_branches.get(effect) : undefined; + + if (speculative !== undefined) { + var batches = Array.from(speculative.batches, (batch) => batch.resolved()); + + if (!batches.includes(this)) { + // Do not even dirty-check descendants in a world where they don't exist. + // Keep their updates for the batches that can eventually commit this branch. + var tracked = { d: [], m: [] }; + reset_branch(effect, tracked); + + // Another fork's writes only matter if that fork is committed. + if (!this.is_fork) { + for (const e of tracked.d) speculative.d.add(e); + for (const e of tracked.m) speculative.m.add(e); + + for (const batch of batches) { + batch.transfer_effects(new Set(tracked.d), new Set(tracked.m)); + } + } + + skip = true; + } + } + if (!skip && effect.fn !== null) { if (is_branch) { effect.f ^= CLEAN; @@ -997,11 +1040,17 @@ export class Batch { } else { var dirty = is_dirty(effect); + // Async invalidations are consumed once checked, not replayed when promises settle. + if ((flags & ASYNC) !== 0) { + this.#maybe_dirty_effects?.delete(effect); + } + if (dirty) { if ((flags & BLOCK_EFFECT) !== 0) { (this.#maybe_dirty_effects ??= new Set()).add(effect); } update_effect(effect); + this.#dirty_effects?.delete(effect); } else if ((flags & MAYBE_DIRTY) !== 0) { this.record_effect(effect); } @@ -1193,51 +1242,6 @@ export class Batch { }); } } - - if (this.stale_readers === null) return; - - var readers = this.stale_readers; - this.stale_readers = null; - - var batch = Batch.ensure(); - - for (const [reader, seen] of readers) { - var flags = reader.f; - - if ((flags & (DESTROYED | INERT | DIRTY)) !== 0) continue; - - // Only re-run readers that are actually affected by the commit: a - // reader observed specific values through this batch's overlay. If - // each of those matches the committed value (the write was reverted, - // or a derived recomputed to an equal value), or the reader no - // longer depends on it, the reader's world didn't change - var status = CLEAN; - - for (const [signal, value] of seen) { - if (reader.deps === null || !includes.call(reader.deps, signal)) continue; - - if ((signal.f & (DIRTY | MAYBE_DIRTY)) !== 0) { - // a derived that hasn't been revalidated with the committed - // values yet — the reader's own validation will recompute it - // (with equality applying) via `is_dirty` - status = MAYBE_DIRTY; - } else if (signal.v !== value) { - status = DIRTY; - break; - } - } - - if (status === CLEAN) continue; - - set_signal_status(reader, status); - - if ((flags & DERIVED) !== 0) { - // invalidate anything that depends on the derived - mark_reactions(/** @type {Derived} */ (reader), MAYBE_DIRTY, null); - } else { - batch.schedule(/** @type {Effect} */ (reader)); - } - } } /** @@ -1674,21 +1678,30 @@ export function eager(fn) { /** * Whether `reaction` depends — directly or through deriveds — on a signal * whose value in `fork`'s world differs from the real one (i.e. one of the - * fork's own speculative writes) + * fork's own speculative writes), excluding writes superseded by `committing` * @param {Reaction} reaction * @param {Batch} fork + * @param {Batch | null} [committing] * @returns {boolean} */ -function depends_on_fork_values(reaction, fork) { +export function depends_on_fork_values(reaction, fork, committing = null) { var deps = reaction.deps; if (deps === null) return false; for (var i = 0; i < deps.length; i++) { var dep = deps[i]; - if (fork.current.has(dep)) return true; + if ( + fork.current.has(dep) && + !(committing !== null && fork.id < committing.id && committing.current.has(dep)) + ) { + return true; + } - if ((dep.f & DERIVED) !== 0 && depends_on_fork_values(/** @type {Derived} */ (dep), fork)) { + if ( + (dep.f & DERIVED) !== 0 && + depends_on_fork_values(/** @type {Derived} */ (dep), fork, committing) + ) { return true; } } @@ -1735,10 +1748,8 @@ function mark_committed_reactions(value, batch, marked, status) { var owner = effect.batch && effect.batch.resolved(); var superseded = batch.fork_effects?.get(effect); - var stale = - batch.stale_readers?.has(effect) === true || owner?.stale_readers?.has(effect) === true; - if (superseded === undefined || stale) { + if (superseded === undefined) { if ((reaction.f & DIRTY) === 0) { set_signal_status(reaction, status); } @@ -1793,6 +1804,7 @@ function reset_branch(effect, tracked) { } set_signal_status(effect, CLEAN); + clear_marked(effect.deps); var e = effect.first; while (e !== null) { @@ -1801,6 +1813,25 @@ function reset_branch(effect, tracked) { } } +/** + * A branch adopted from a fork may contain clean selectors that only ran in + * the fork's world. Recheck them before publishing any nested branches. + * @param {Effect} effect + * @param {Batch} batch + */ +function revalidate_branch(effect, batch) { + for (var e = effect.first; e !== null; e = e.next) { + if (speculative_branches.has(e)) continue; + + if (speculative_selectors.has(e)) { + set_signal_status(e, DIRTY); + batch.schedule(e); + } + + revalidate_branch(e, batch); + } +} + /** * Mark an entire effect tree clean following an error * @param {Effect} effect diff --git a/packages/svelte/src/internal/client/reactivity/utils.js b/packages/svelte/src/internal/client/reactivity/utils.js index 0d27cb8b84..39d6d9be05 100644 --- a/packages/svelte/src/internal/client/reactivity/utils.js +++ b/packages/svelte/src/internal/client/reactivity/utils.js @@ -5,7 +5,7 @@ import { set_signal_status } from './status.js'; /** * @param {Value[] | null} deps */ -function clear_marked(deps) { +export function clear_marked(deps) { if (deps === null) return; for (const dep of deps) { diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index dcc3222a9d..d2af2688a7 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -50,6 +50,7 @@ import { active_batch, Batch, claimed_by_other, + collected_effects, current_batch, flushSync, previous_batch, @@ -571,6 +572,14 @@ export function get(signal) { var flags = signal.f; var is_derived = (flags & DERIVED) !== 0; + /** + * Whether a read outside the init/update cycle (i.e. after an `await`) added + * `signal` to the reaction's deps for the first time. During the init/update + * cycle this stays `false` — first-time reads are detected by checking + * `deps` instead (new deps accumulate in `new_deps` in that case) + */ + var first_read = false; + captured_signals?.add(signal); // Register the dependency on the current reaction signal. @@ -608,6 +617,7 @@ export function get(signal) { active_reaction.deps ??= []; if (!includes.call(active_reaction.deps, signal)) { active_reaction.deps.push(signal); + first_read = true; } var reactions = signal.reactions; @@ -708,7 +718,6 @@ export function get(signal) { // have their status reset (the owning batch relies on both), and their // value in this world follows from the active overlay /** @type {Batch | null} */ - // eslint-disable-next-line no-useless-assignment var owner = null; if ( @@ -716,13 +725,20 @@ export function get(signal) { active_batch.values !== null && (owner = claimed_by_other(derived)) !== null ) { - // the world-local value is memoized in the active overlay (and invalidated - // there when dependencies change). Reads are registered with the owner - // batch — when it commits, the reader re-runs with the real values - if (!active_batch.values.has(derived)) { + if (is_unseen_read(derived, first_read) && entangle(derived)) { + // a read with no history can entangle the two batches instead, so + // that they commit together — the derived is now part of this + // batch's world and behaves normally (below) + owner = null; + } else if (!active_batch.values.has(derived)) { + // the world-local value is memoized in the active overlay (and invalidated + // there when dependencies change). Reads are registered with the owner + // batch — when it commits, the reader re-runs with the real values active_batch.values.set(derived, [execute_derived(derived), owner]); } - } else { + } + + if (owner === null) { // connect disconnected deriveds if we are reading them inside an effect, // or inside another derived that is already connected var should_connect = @@ -759,17 +775,53 @@ export function get(signal) { // we saw turns out to differ from the committed one) var override_owner = override[1]; - if (override_owner !== null && active_reaction !== null && !untracking) { - override_owner = override_owner.resolved(); - - var readers = (override_owner.stale_readers ??= new Map()); - var seen = readers.get(active_reaction); + if (override_owner !== null) { + if (active_reaction === null) { + // reads outside a reaction during a flush happen in one-shot init + // code (e.g. a component initialising inside a newly-created + // branch). They have no dependency history and no re-run + // mechanism — entangle the batches if possible, so that both + // worlds commit together, and read the latest value + if ((signal.f & DERIVED) === 0) { + entangle(signal); + + if ((signal.f & ERROR_VALUE) !== 0) { + throw signal.v; + } + + return signal.v; + } + } else if (!untracking) { + var override_value = override[0]; + + if ( + (signal.f & DERIVED) === 0 && + ((active_reaction.f & REACTION_IS_UPDATING) !== 0 + ? active_reaction.deps === null || !includes.call(active_reaction.deps, signal) + : first_read) + ) { + // the reaction never depended on this signal before the owner's write — + // the pre-write world never contained this combination of values. + // Entangle the batches if possible, so that both worlds commit + // together and this value is simply the batch's own write... + var entangled = entangle(signal); + + if ((signal.f & ERROR_VALUE) !== 0) { + throw signal.v; + } + + if (entangled) { + return signal.v; + } + + // ...otherwise, read the latest value — the owner batch will + // re-run us when it commits, if the value we saw turns out + // to differ from the committed one + override_value = signal.v; + } - if (seen === undefined) { - readers.set(active_reaction, (seen = new Map())); + return override_value; } - - seen.set(signal, override[0]); } return override[0]; @@ -783,6 +835,52 @@ export function get(signal) { return signal.v; } +/** + * Whether the current read of `signal` — which is owned by another live batch — + * has no history: the reader neither depended on the signal in a previous run, + * nor observed a value for it while the owner batch was pending. (Reads outside + * a reaction never have history.) + * @param {Value} signal + * @param {boolean} first_read whether a post-`await` read just added `signal` to the reaction's deps + * @returns {boolean} + */ +function is_unseen_read(signal, first_read) { + if (active_reaction === null) return true; + if (untracking) return false; + + if ((active_reaction.f & REACTION_IS_UPDATING) !== 0) { + if (active_reaction.deps !== null && includes.call(active_reaction.deps, signal)) { + return false; + } + } else if (!first_read) { + return false; + } + + return true; +} + +/** + * Attempt to entangle the active batch with the batch that owns `signal`, + * merging their worlds so that both commit together. This is only possible + * while none of the batch's UI has been committed — during effect tree + * traversal, or in an async continuation of a still-pending batch. Returns + * true if the signal now belongs to the active batch's own world. + * @param {Value} signal + * @returns {boolean} Returns false if the signal was not merged because it has to wait on another batch to commit first + */ +function entangle(signal) { + var batch = /** @type {Batch} */ (active_batch).resolved(); + + if (collected_effects === null && batch.committed) { + // too late — the batch's UI is (at least partially) committed already. + // Readers fall back to observing the latest value, and are re-run when + // the owner commits (if the value they saw turns out to be stale) + return false; + } + + return !batch.claim(signal); +} + /** * (Re)connect a disconnected derived, so that it is notified * of changes in `mark_reactions` diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/_config.js b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/_config.js index 0e741d3bbf..af066836b2 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-changed/_config.js @@ -17,17 +17,17 @@ export default test({ await tick(); assert.deepEqual(logs, []); - // an independent batch runs the effect, which reads `x` through the - // pending batch's overlay (seeing the held-back value 0) + // an independent batch runs the effect, which newly depends on `x` — + // it reads the latest value (1) rather than the held-back one (0) y.click(); await tick(); - assert.deepEqual(logs, ['effect 0 1']); + assert.deepEqual(logs, ['effect 1 1']); - // the pending batch settles and commits x === 1 — the effect saw a - // stale value and must re-run with the real one + // the pending batch settles and commits x === 1 — exactly the value + // the effect already saw, so it should not re-run shift.click(); await tick(); - assert.deepEqual(logs, ['effect 0 1', 'effect 1 1']); + assert.deepEqual(logs, ['effect 1 1']); assert.htmlEqual( target.innerHTML, '

1

' diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-dropped-dep/_config.js b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-dropped-dep/_config.js index 8aa913d73d..4a7e6282fb 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-dropped-dep/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-stale-reader-dropped-dep/_config.js @@ -13,22 +13,22 @@ export default test({ await tick(); assert.deepEqual(logs, ['effect _ 0']); - // the effect reads `x` through the pending batch's overlay - // (seeing the held-back value 0) + // the effect newly depends on `x` — it reads the latest value (1) + // rather than the held-back one (0) y.click(); await tick(); - assert.deepEqual(logs, ['effect _ 0', 'effect 0 1']); + assert.deepEqual(logs, ['effect _ 0', 'effect 1 1']); // the effect re-runs and no longer depends on `x` at all y.click(); await tick(); - assert.deepEqual(logs, ['effect _ 0', 'effect 0 1', 'effect _ 2']); + assert.deepEqual(logs, ['effect _ 0', 'effect 1 1', 'effect _ 2']); // the pending batch settles and commits x = 1 — the effect no longer // depends on `x`, so it should not re-run shift.click(); await tick(); - assert.deepEqual(logs, ['effect _ 0', 'effect 0 1', 'effect _ 2']); + assert.deepEqual(logs, ['effect _ 0', 'effect 1 1', 'effect _ 2']); assert.htmlEqual( target.innerHTML, '

1

' diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/_config.js new file mode 100644 index 0000000000..40ee9af704 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/_config.js @@ -0,0 +1,41 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +export default test({ + async test({ assert, target, instance }) { + const [fork_button, update, resolve, discard] = target.querySelectorAll('button'); + + fork_button.click(); + await tick(); + assert.equal(instance.get_calls(), 1); + assert.htmlEqual(target.innerHTML, buttons); + + // Transfer an invalidation into the fork while its async work is pending. + update.click(); + await tick(); + assert.equal(instance.get_calls(), 1); // can also be 2 at this point already, would also be ok + + try { + resolve.click(); + await tick(); + assert.equal(instance.get_calls(), 2); + + // Completing the replacement must not replay the same invalidation. + resolve.click(); + await tick(); + assert.equal(instance.get_calls(), 2); + } finally { + discard.click(); + await tick(); + } + + assert.htmlEqual(target.innerHTML, buttons); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/main.svelte new file mode 100644 index 0000000000..814980d99b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-async-effect-replay/main.svelte @@ -0,0 +1,42 @@ + + + + + + + +{#if show} + + {#snippet pending()}loading{/snippet} +

{await load(searchParams.value)}

+
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/_config.js new file mode 100644 index 0000000000..e9f204d71d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/_config.js @@ -0,0 +1,35 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + +`; + +export default test({ + async test({ assert, target, logs }) { + const [preload, increment, commit, merge, resolve] = target.querySelectorAll('button'); + + preload.click(); + flushSync(() => increment.click()); + assert.deepEqual(logs, [0]); + + commit.click(); + assert.deepEqual(logs, [0, 1]); + + flushSync(() => merge.click()); + await tick(); + assert.deepEqual(logs, [0, 1]); + assert.htmlEqual(target.innerHTML, buttons); + + // Resolve the obsolete request for 0, then the existing request for 1. + resolve.click(); + resolve.click(); + await tick(); + assert.deepEqual(logs, [0, 1]); + assert.htmlEqual(target.innerHTML, `${buttons}

1

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/main.svelte new file mode 100644 index 0000000000..1166cc0a03 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-merge/main.svelte @@ -0,0 +1,25 @@ + + + + + + + + +{#if show && gate > 0} +

{await load(count)}

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/_config.js new file mode 100644 index 0000000000..8b50ff2e19 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/_config.js @@ -0,0 +1,45 @@ +import { flushSync, tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + + + + +`; + +export default test({ + async test({ assert, target }) { + const [preload, increment, commit, reveal, discard, preload_second, commit_second, reset] = + target.querySelectorAll('button'); + + for (const mode of ['commit', 'reveal', 'second-fork']) { + preload.click(); + flushSync(() => increment.click()); + assert.htmlEqual(target.innerHTML, buttons); + + if (mode === 'commit') { + commit.click(); + await tick(); + } else if (mode === 'reveal') { + flushSync(() => reveal.click()); + discard.click(); + } else { + preload_second.click(); + discard.click(); + commit_second.click(); + await tick(); + } + + assert.htmlEqual(target.innerHTML, `${buttons}

1 2

`); + flushSync(() => increment.click()); + assert.htmlEqual(target.innerHTML, `${buttons}

2 4

`); + flushSync(() => reset.click()); + assert.htmlEqual(target.innerHTML, buttons); + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/main.svelte new file mode 100644 index 0000000000..a0e98c1c4b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-branch-update/main.svelte @@ -0,0 +1,22 @@ + + + + + + + + + + + +{#if show} +

{count} {doubled}

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/_config.js new file mode 100644 index 0000000000..1c16112872 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/_config.js @@ -0,0 +1,32 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +export default test({ + async test({ assert, target }) { + const [preload, reveal, hide, commit] = target.querySelectorAll('button'); + + preload.click(); + reveal.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `${buttons}0

constant

keyed

boundary

` + ); + + hide.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}0`); + + // The remaining fork write must not resurrect its obsolete branch selection. + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/main.svelte new file mode 100644 index 0000000000..13247dda7a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch-static/main.svelte @@ -0,0 +1,22 @@ + + + + + + + +{other} + +{#if show} + {#if true}

constant

{/if} + {#key 1}

keyed

{/key} + +

boundary

+
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/Child.svelte new file mode 100644 index 0000000000..b131b926f8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/Child.svelte @@ -0,0 +1,8 @@ + + +{#if pending}pending{/if} +

{pages}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/_config.js new file mode 100644 index 0000000000..acf299a06a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/_config.js @@ -0,0 +1,73 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + + + + +`; + +export default test({ + async test({ assert, target, logs }) { + const [preload, reveal, reveal_and_navigate, navigate, resolve, commit, discard, reset] = + target.querySelectorAll('button'); + + for (const mode of ['separate', 'together', 'pending']) { + for (const finish of [commit, discard]) { + preload.click(); + + if (mode !== 'pending') { + resolve.click(); + await tick(); + } + + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, ['load']); + + if (mode === 'together') { + reveal_and_navigate.click(); + } else { + reveal.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}
`); + navigate.click(); + } + + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}
`); + assert.deepEqual(logs, ['load']); + + if (mode === 'pending') { + resolve.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}
`); + } + + finish.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `${buttons}
${finish === commit ? 'pending

2

' : ''}
` + ); + assert.deepEqual(logs, ['load']); + + if (finish === commit) { + navigate.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

2

`); + assert.deepEqual(logs, ['load']); + } + + reset.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + logs.length = 0; + } + } + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/main.svelte new file mode 100644 index 0000000000..6d24027f05 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-nested-branch/main.svelte @@ -0,0 +1,36 @@ + + + + + + + + + + + +{#if outer} +
+ {#if inner} + console.log(error.message)}> + {#snippet pending()}loading{/snippet} + + + {/if} +
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/Child.svelte new file mode 100644 index 0000000000..b131b926f8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/Child.svelte @@ -0,0 +1,8 @@ + + +{#if pending}pending{/if} +

{pages}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/_config.js new file mode 100644 index 0000000000..40a9c5e63d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/_config.js @@ -0,0 +1,50 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + +`; + +export default test({ + async test({ assert, target, logs }) { + const [preload, navigate, commit, discard] = target.querySelectorAll('button'); + + preload.click(); + // Let the async child resolve, without committing its fork. + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, []); + + // A real-world update must not evaluate the speculative child in the real world. + navigate.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, []); + discard.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, []); + + navigate.click(); + await tick(); + preload.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + navigate.click(); + await tick(); + assert.htmlEqual(target.innerHTML, buttons); + assert.deepEqual(logs, []); + + commit.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}pending

2

`); + assert.deepEqual(logs, []); + + navigate.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

2

`); + assert.deepEqual(logs, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/main.svelte new file mode 100644 index 0000000000..43e9600df9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-fork-uninitialized-debug/main.svelte @@ -0,0 +1,20 @@ + + + + + + + +{#if show} + console.log(error.message)}> + {#snippet pending()}loading{/snippet} + + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-read-pending-value-in-committed-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-read-pending-value-in-committed-batch/_config.js new file mode 100644 index 0000000000..6a65bb3c1a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-read-pending-value-in-committed-batch/_config.js @@ -0,0 +1,61 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +const buttons = ` + + + + + + +`; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [up, down, show1, show2, shift_a, shift_t] = target.querySelectorAll('button'); + + // batch B: reveals boundary 1 -> commits with pending snippet, stays live + show1.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

loading 1...

`); + + // batch C: reveals boundary 2 -> commits with pending snippet, stays live + show2.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

loading 1...

loading 2...

`); + + // batch A: writes a=1; the async-a effect is owned by C, so A merges with + // C and stays pending. B remains separate + up.click(); + await tick(); + + // B's continuation first-reads `a`, which is overlaid by the pending + // merged batch. B has already committed its UI, so it cannot entangle — + // it reads the latest value (1) instead + shift_t.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

late read: 1

loading 2...

`); + + // revert `a` to 0 inside the pending batch — its eventual commit leaves + // `a` unchanged. The write re-runs the late reader (it acquired `a` as a + // dependency), so it re-awaits a fresh deferred + down.click(); + await tick(); + + // resolve the pending batch's async-a runs -> it commits + shift_a.click(); + await tick(); + shift_a.click(); + await tick(); + shift_a.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

late read: 1

async a: 0

`); + + // resolve the late reader's re-run -> it converges on the committed value + shift_t.click(); + await tick(); + assert.htmlEqual(target.innerHTML, `${buttons}

late read: 0

async a: 0

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-read-pending-value-in-committed-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-read-pending-value-in-committed-batch/main.svelte new file mode 100644 index 0000000000..78ca377225 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-read-pending-value-in-committed-batch/main.svelte @@ -0,0 +1,49 @@ + + + + + + + + + +{#if show1} + + +

late read: {(await push('t', 0)) > 0 ? a : -1}

+ + {#snippet pending()} +

loading 1...

+ {/snippet} +
+{/if} + +{#if show2} + +

async a: {await push('a', a)}

+ + {#snippet pending()} +

loading 2...

+ {/snippet} +
+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js index dee8af2446..951744862a 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-1/_config.js @@ -10,26 +10,24 @@ export default test({ y.click(); await tick(); + // the new branch reads `x`, which the pending batch has written, as a new + // dependency — the two batches entangle, so the new branch is held back + // until the async work completes assert.htmlEqual( target.innerHTML, ` - world - ` // if this does not show world - that would also be ok + ` ); resolve.click(); await tick(); - assert.deepEqual(logs, [ - 'universe', - 'world', - '$effect: world', - '$effect: universe', - '$effect: universe' - ]); - // assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']); // this would also be ok + // both branches commit together, fully consistent. The init-time + // console.logs ran eagerly (with the latest value), the $effects + // were deferred until the commit + assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']); assert.htmlEqual( target.innerHTML, ` diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js index d99f0df731..7c9f577abd 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-2/_config.js @@ -10,6 +10,9 @@ export default test({ y.click(); await tick(); + // the new branch reads `x`, which the pending batch has written, as a new + // dependency — the two batches entangle, so the new branch is held back + // until the async work completes assert.htmlEqual( target.innerHTML, ` @@ -17,17 +20,12 @@ export default test({
- world - "world" - world - world - world - "world" - ` // if this does not show world "world" world world world "world" - then this would also be ok + ` ); resolve.click(); await tick(); + // both branches commit together, fully consistent assert.htmlEqual( target.innerHTML, ` diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js index eb4485e8a6..f0ef3785a5 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-3/_config.js @@ -22,6 +22,10 @@ export default test({ resolve.click(); await tick(); + // the new branch's async expression read `x`, which the pending batch had + // written, as a new dependency — the two batches entangled, so even though + // the new branch's own async work has completed, it is held back until the + // first branch's async work completes too assert.htmlEqual( target.innerHTML, ` @@ -29,19 +33,12 @@ export default test({
- world - "world" - world - world - world - "world" - ` // if this does not show world "world" world world world "world" - then this would also be ok + ` ); resolve.click(); await tick(); - resolve.click(); - await tick(); + // both branches commit together, fully consistent assert.htmlEqual( target.innerHTML, ` diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-4/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-4/Child.svelte new file mode 100644 index 0000000000..05409ef225 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-4/Child.svelte @@ -0,0 +1,7 @@ + + +{x.x} diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-4/_config.js new file mode 100644 index 0000000000..9e3e4369a4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-4/_config.js @@ -0,0 +1,44 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const [x, y, resolve] = target.querySelectorAll('button'); + + x.click(); + await tick(); + assert.deepEqual(logs, ['universe']); + + y.click(); + await tick(); + // the new branch reads `x`, which the pending batch has written, as a new + // dependency — the two batches entangle, so the new branch is held back + // until the async work completes. Its $effect is deferred, but the + // init-time console.log necessarily runs eagerly (with the latest value) + assert.deepEqual(logs, ['universe', 'universe']); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + + resolve.click(); + await tick(); + // both branches commit together, fully consistent + assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']); + assert.htmlEqual( + target.innerHTML, + ` + + + + universe + universe + universe + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-4/main.svelte new file mode 100644 index 0000000000..3b858909c2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-4/main.svelte @@ -0,0 +1,28 @@ + + + + + + + + +{#if x?.x === 'universe'} + {await delay(x.x)} + +{/if} + +{#if y > 0} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-5/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-5/_config.js new file mode 100644 index 0000000000..28b8229839 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-5/_config.js @@ -0,0 +1,51 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [x, y, resolve] = target.querySelectorAll('button'); + + x.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + +

WORLD

+ ` + ); + + y.click(); + await tick(); + // the new branch reads `upper` — a derived owned by the pending batch — as + // a new dependency. The two batches entangle, so the new branch is held + // back until the async work completes (rather than rendering with the + // derived's pre-write value, 'WORLD') + assert.htmlEqual( + target.innerHTML, + ` + + + +

WORLD

+ ` + ); + + resolve.click(); + await tick(); + // both branches commit together, fully consistent + assert.htmlEqual( + target.innerHTML, + ` + + + +

UNIVERSE

+ universe +

UNIVERSE

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-5/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-5/main.svelte new file mode 100644 index 0000000000..aa95730425 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch-5/main.svelte @@ -0,0 +1,29 @@ + + + + + + + + +

{upper}

+ +{#if x.x === 'universe'} + {await delay(x.x)} +{/if} + +{#if y > 0} +

{upper}

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js index f4b6cc777b..9e3e4369a4 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-new-branch/_config.js @@ -11,26 +11,24 @@ export default test({ y.click(); await tick(); - assert.deepEqual(logs, ['universe', 'world', '$effect: world']); + // the new branch reads `x`, which the pending batch has written, as a new + // dependency — the two batches entangle, so the new branch is held back + // until the async work completes. Its $effect is deferred, but the + // init-time console.log necessarily runs eagerly (with the latest value) + assert.deepEqual(logs, ['universe', 'universe']); assert.htmlEqual( target.innerHTML, ` - world ` ); resolve.click(); await tick(); - assert.deepEqual(logs, [ - 'universe', - 'world', - '$effect: world', - '$effect: universe', - '$effect: universe' - ]); + // both branches commit together, fully consistent + assert.deepEqual(logs, ['universe', 'universe', '$effect: universe', '$effect: universe']); assert.htmlEqual( target.innerHTML, ` diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency-continuation/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency-continuation/_config.js new file mode 100644 index 0000000000..f0d530cf10 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency-continuation/_config.js @@ -0,0 +1,63 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [a, t, shift_a, shift_t] = target.querySelectorAll('button'); + + shift_a.click(); + shift_t.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + + + +

async a: 0

+

late read: -1

+ ` + ); + + // batch A: writes `a`, stays pending (its promise is unresolved) + a.click(); + await tick(); + + // batch B: writes `t`; resolve its promise so the continuation + // reads `a` for the first time while A is still pending. This + // entangles B with A — both worlds are held back and commit together + t.click(); + await tick(); + shift_t.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + + +

async a: 0

+

late read: -1

+ ` + ); + + // commit the merged batch + shift_a.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + + +

async a: 1

+

late read: 1

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency-continuation/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency-continuation/main.svelte new file mode 100644 index 0000000000..428dd4c774 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency-continuation/main.svelte @@ -0,0 +1,35 @@ + + + + + + + + +

async a: {await push('a', a)}

+ + +

late read: {(await push('t', t), t > 0 ? a : -1)}

+ + {#snippet pending()} +

loading...

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency/_config.js new file mode 100644 index 0000000000..f1bf5a7349 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency/_config.js @@ -0,0 +1,41 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + mode: ['client'], + async test({ assert, target }) { + await tick(); + const [update, show, resolve] = target.querySelectorAll('button'); + + update.click(); + await tick(); + + show.click(); + await tick(); + + // the template effect newly depends on `value`, which the pending batch + // has written — it reads the latest value rather than the pre-write one + assert.htmlEqual( + target.innerHTML, + ` + + + +

1

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

1

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency/main.svelte new file mode 100644 index 0000000000..80e54f9d53 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-read-new-dependency/main.svelte @@ -0,0 +1,17 @@ + + + + + + +{await wait(value)} + +

{show ? value.x : ''}

\ No newline at end of file