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

true - true

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

false - false

` + ); + } +}); diff --git a/packages/svelte/tests/runtime-production/samples/async-eager-derived/main.svelte b/packages/svelte/tests/runtime-production/samples/async-eager-derived/main.svelte new file mode 100644 index 0000000000..d1d979126d --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/async-eager-derived/main.svelte @@ -0,0 +1,22 @@ + + + + + +

{$state.eager(count) !== count} - {$state.eager(derivedCount) !== derivedCount}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js b/packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js new file mode 100644 index 0000000000..53cceb9d54 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-order/_config.js @@ -0,0 +1,30 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, shift, middle] = target.querySelectorAll('button'); + const [div] = target.querySelectorAll('div'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + increment.click(); + await tick(); + middle.click(); // resolve the second increment which will make the if block go away and the first batch discarded + await tick(); + assert.htmlEqual(div.innerHTML, '2 2'); + + shift.click(); + await tick(); + shift.click(); + await tick(); + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual(div.innerHTML, '3 3'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-batch-order/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-batch-order/main.svelte new file mode 100644 index 0000000000..0289380d78 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-batch-order/main.svelte @@ -0,0 +1,21 @@ + + +
+ {a} {await delay(a)} + {#if a < 2} + {await delay(a)} + {/if} +
+ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/_config.js new file mode 100644 index 0000000000..922feed515 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/_config.js @@ -0,0 +1,19 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that batch.#commit() does not null out a potentially new current_batch +export default test({ + skip_initial_flushSync: true, // test that the initial batch is flushed without an explicit flushSync() call + async test({ assert, target }) { + await tick(); + + const [button] = target.querySelectorAll('button'); + const [updates] = target.querySelectorAll('p'); + + assert.htmlEqual(updates.innerHTML, 'false'); + + button.click(); + await tick(); + assert.htmlEqual(updates.innerHTML, 'true'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/main.svelte new file mode 100644 index 0000000000..f7dae33b7e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-commit-preserve-new-batch/main.svelte @@ -0,0 +1,30 @@ + + + + +

{updated}

+ + + {await new Promise(() => {})} + + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/_config.js new file mode 100644 index 0000000000..fb6f3388c9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/_config.js @@ -0,0 +1,27 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(), +// rescheduling an effect on the new batch that shouldn't run. +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, resolve] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.deepEqual(logs, []); + + // This resolve + // - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing + // - shouldn't result in #commit() rebasing the new batch + resolve.click(); + await tick(); + assert.deepEqual(logs, [2]); + + // As a result, this resolve shouldn't result in another execution of the effect depending on the derived + resolve.click(); + await tick(); + assert.deepEqual(logs, [2]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/main.svelte new file mode 100644 index 0000000000..af470363bf --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/main.svelte @@ -0,0 +1,32 @@ + + + + + +{#if count} + + + {(() => { + $effect(() => { + count_mirror = count; + }) + })()} + + {(() => { + $effect(() => { + console.log(double); + }) + })()} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/_config.js new file mode 100644 index 0000000000..d8a86f77da --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(), +// rescheduling an effect on the new batch that shouldn't run. +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, resolve] = target.querySelectorAll('button'); + assert.deepEqual(logs, ['delay 0']); + + increment.click(); + await tick(); + assert.deepEqual(logs, ['delay 0', 'delay 2']); + + // This resolve should trigger the async effect only once + resolve.click(); + await tick(); + assert.deepEqual(logs, ['delay 0', 'delay 2', 'effect run', 'delay 4']); + + resolve.click(); + await tick(); + assert.deepEqual(logs, ['delay 0', 'delay 2', 'effect run', 'delay 4']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/main.svelte new file mode 100644 index 0000000000..fc90ae2ba4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/main.svelte @@ -0,0 +1,29 @@ + + + + +{await delay(a + b + c)} diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/_config.js new file mode 100644 index 0000000000..b430e408c7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/_config.js @@ -0,0 +1,31 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(), +// rescheduling an effect on the new batch that shouldn't run. +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, shift, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.deepEqual(logs, []); + + // Resolve the blocking await which shouldn't result in the derived execution capturing + // the new derived value on the new batch, but on the previous batch which is currently flushing + pop.click(); + await tick(); + assert.deepEqual(logs, [2]); + + // Resolve the non-blocking await which shouldn't result in #commit() rebasing the new batch + shift.click(); + await tick(); + assert.deepEqual(logs, [2]); + + // Resolve the new batch's await + shift.click(); + await tick(); + assert.deepEqual(logs, [2]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/main.svelte new file mode 100644 index 0000000000..9dec14cd13 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/main.svelte @@ -0,0 +1,37 @@ + + + + + + +{#if count} + + {await delay(count)} + {#snippet pending()}loading{/snippet} + + + + {(() => { + $effect(() => { + count_mirror = count; + }) + })()} + + {(() => { + $effect(() => { + console.log(double); + }) + })()} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/_config.js new file mode 100644 index 0000000000..804c1f53bb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/_config.js @@ -0,0 +1,58 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(), +// rescheduling an effect on the new batch that shouldn't run. +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, unrelated, resolve] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.deepEqual(logs, []); + + // This resolve + // - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing + // - shouldn't result in #commit() rebasing the new batch + resolve.click(); + await tick(); + assert.deepEqual(logs, [2]); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + + // This resolve + // - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing + // - shouldn't result in #commit() rebasing the new batch + unrelated.click(); + await tick(); + assert.deepEqual(logs, [2]); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + + // As a result, this resolve shouldn't result in another execution of the effect depending on the derived + resolve.click(); + await tick(); + assert.deepEqual(logs, [2]); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/main.svelte new file mode 100644 index 0000000000..fdc2447e3e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/main.svelte @@ -0,0 +1,38 @@ + + + + + + +{#if count} + + + {(() => { + $effect(() => { + count_mirror = count; + untrack(() => count_mirror_d); // execute derived; should associate value with the right batch + }) + })()} + + {(() => { + $effect(() => { + console.log(double); + }) + })()} +{/if} \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/_config.js new file mode 100644 index 0000000000..59a81afd35 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [increment, shift] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ' 0'); + + shift.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ' 1'); + + shift.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ' 2'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte new file mode 100644 index 0000000000..12c4dd578e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-flushsync-in-effect/main.svelte @@ -0,0 +1,25 @@ + + + + +{await push(count)} diff --git a/packages/svelte/tests/runtime-runes/samples/async-inner-after-outer/_config.js b/packages/svelte/tests/runtime-runes/samples/async-inner-after-outer/_config.js index 8905ee4bf5..c12eba7d17 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-inner-after-outer/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-inner-after-outer/_config.js @@ -34,18 +34,6 @@ export default test({ shift?.click(); await tick(); - assert.htmlEqual( - target.innerHTML, - ` -

true

- - - ` - ); - - shift?.click(); - await tick(); - assert.htmlEqual( target.innerHTML, ` diff --git a/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/_config.js b/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/_config.js new file mode 100644 index 0000000000..c8bc4c986f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/_config.js @@ -0,0 +1,20 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` 2 2 1`); + + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ` 2 2 1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/main.svelte new file mode 100644 index 0000000000..7689af049c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-new-batch-during-initial-load/main.svelte @@ -0,0 +1,26 @@ + + + + + +{#if count > 0} + + {await push(count)} {count} {other} + {#snippet failed()}boom{/snippet} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/Child.svelte b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/Child.svelte new file mode 100644 index 0000000000..1d9bdfada2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/Child.svelte @@ -0,0 +1,6 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/_config.js b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/_config.js new file mode 100644 index 0000000000..83364706e5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/_config.js @@ -0,0 +1,27 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs, warnings }) { + const [increment, resolve] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.deepEqual(logs, []); + + resolve.click(); + await tick(); + assert.deepEqual(logs, []); + + resolve.click(); + await tick(); + assert.deepEqual(logs, []); + + resolve.click(); + await tick(); + assert.deepEqual(logs, [1, 2]); + + // no await waterfall / inert derived warnings + assert.deepEqual(warnings, []); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/main.svelte new file mode 100644 index 0000000000..fe01ae457e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-obsolete-branch-no-effect-runs/main.svelte @@ -0,0 +1,31 @@ + + + + + + + {#if count % 2 === 0} + {@const double = count * 2} +

true

+ {await push(count)} {double} + + {:else} +

false

+ + {/if} + + {#snippet pending()} +

loading...

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/_config.js new file mode 100644 index 0000000000..9785f639cb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/_config.js @@ -0,0 +1,24 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; +import { normalise_trace_logs } from '../../../helpers.js'; + +export default test({ + compileOptions: { + dev: true + }, + html: '

pending

', + async test({ assert, target, warnings }) { + await tick(); + + assert.htmlEqual( + target.innerHTML, + '

number -> number -> number -> return -> body failed -> ended

' + ); + + assert.deepEqual(normalise_trace_logs(warnings), [ + { + log: 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`' + } + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/main.svelte new file mode 100644 index 0000000000..da7c48642c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-1/main.svelte @@ -0,0 +1,45 @@ + + + +

{await get_result()}

+ + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/_config.js new file mode 100644 index 0000000000..9e8a2d8def --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/_config.js @@ -0,0 +1,21 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; +import { normalise_trace_logs } from '../../../helpers.js'; + +export default test({ + compileOptions: { + dev: true + }, + html: '

pending

', + async test({ assert, target, warnings }) { + await tick(); + + assert.htmlEqual(target.innerHTML, '

number -> number -> next failed -> ended

'); + + assert.deepEqual(normalise_trace_logs(warnings), [ + { + log: 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`' + } + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/main.svelte new file mode 100644 index 0000000000..ffe2ef93c0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await-throws-2/main.svelte @@ -0,0 +1,43 @@ + + + +

{await get_result()}

+ + {#snippet pending()} +

pending

+ {/snippet} +
diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-3/_config.js new file mode 100644 index 0000000000..ff03e0e28e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-3/_config.js @@ -0,0 +1,29 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + + increment.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + `

0 0 0

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

2 2 1

` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-3/main.svelte new file mode 100644 index 0000000000..589ac9ea0e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-3/main.svelte @@ -0,0 +1,22 @@ + + + + + +

{await push(count)} {count} {other}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/_config.js new file mode 100644 index 0000000000..e1555c0062 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/_config.js @@ -0,0 +1,28 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [increment, hide, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + pop.click(); + await tick(); + hide.click(); // hides the if block, which cancels the pending async inside, which means the batch can complete + await tick(); + assert.htmlEqual( + target.innerHTML, + ` 1` + ); + + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` 1` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/main.svelte new file mode 100644 index 0000000000..5ff3263d39 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-4/main.svelte @@ -0,0 +1,21 @@ + + + + + + + +{await push(count)} +{#if show} + {await push(count)} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/_config.js new file mode 100644 index 0000000000..05d92e9df2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-5/_config.js @@ -0,0 +1,33 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + + const [increment, shift] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

1 = 1

fizz: true

buzz: true

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

1 = 1

fizz: true

buzz: true

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

3 = 3

fizz: true

buzz: false

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

{n} = {await push(n)}

+ +{#if true} +

fizz: {fizz}

+{/if} + +{#if true} +

buzz: {buzz}

+{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js new file mode 100644 index 0000000000..5b951d6c49 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/_config.js @@ -0,0 +1,34 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [button1, button2, pop, shift] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + button1.click(); + await tick(); + button2.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + shift.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `1 + 0 = 1 | 1 0`); + + shift.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `1 + 2 = 3 | 1 1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte new file mode 100644 index 0000000000..4515e7a488 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-6/main.svelte @@ -0,0 +1,21 @@ + + + + + + + +

{a} + {b} = {await push(a + b)} | {await push(c, 2)} {await push(d, 2)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js new file mode 100644 index 0000000000..a6f1833d67 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/_config.js @@ -0,0 +1,34 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [button1, button2, shift_1, pop_1, shift_2] = target.querySelectorAll('button'); + const [p] = target.querySelectorAll('p'); + + button1.click(); + await tick(); + button2.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + pop_1.click(); + await tick(); + shift_2.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `0 + 0 = 0 | 0 0`); + + // Check that the first batch can still resolve before the second even if one of its async values + // is already superseeded (but the subsequent batch as a whole is still pending). + shift_1.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `1 + 0 = 1 | 1 0`); + + shift_1.click(); + await tick(); + shift_2.click(); + await tick(); + assert.htmlEqual(p.innerHTML, `1 + 2 = 3 | 1 1`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/main.svelte new file mode 100644 index 0000000000..fec684c257 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-7/main.svelte @@ -0,0 +1,22 @@ + + + + + + + + +

{a} + {b} = {await push(a + b)} | {await push(c, 2)} {await push(d, 2)}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/_config.js b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/_config.js new file mode 100644 index 0000000000..fd0fc0de48 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/_config.js @@ -0,0 +1,20 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [increment, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + increment.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' 2 2 1'); // showing nothing here yet would also be ok + + pop.click(); + await tick(); + assert.htmlEqual(target.innerHTML, ' 2 2 1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/main.svelte new file mode 100644 index 0000000000..42b7206b56 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-stale-derived-8/main.svelte @@ -0,0 +1,23 @@ + + + + + + +{#if count > 0} + {await push(count)} {count} {other} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte new file mode 100644 index 0000000000..77cdcabaf7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/Icon.svelte @@ -0,0 +1 @@ +x diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js new file mode 100644 index 0000000000..22412de14d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + mode: ['client', 'server'], + + html: `x`, + ssrHtml: `x` +}); diff --git a/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte new file mode 100644 index 0000000000..b0f95b2a31 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/dynamic-component-member/main.svelte @@ -0,0 +1,8 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/List.svelte b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/List.svelte new file mode 100644 index 0000000000..c73ac9a99d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/List.svelte @@ -0,0 +1,11 @@ + + +
    + {#each things as thing} +
  • thing {thing.id}
  • + {/each} +
diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/_config.js b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/_config.js new file mode 100644 index 0000000000..c29022c9dc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/_config.js @@ -0,0 +1,21 @@ +import { normalise_inspect_logs } from '../../../helpers'; +import { test } from '../../test'; +import { flushSync } from 'svelte'; + +export default test({ + compileOptions: { + dev: true + }, + + async test({ assert, target, errors, logs }) { + const button = target.querySelector('button'); + + flushSync(() => { + button?.click(); + }); + + assert.htmlEqual(target.innerHTML, ''); + assert.equal(errors.length, 0); + assert.deepEqual(normalise_inspect_logs(logs), [[{ id: 1 }, { id: 2 }]]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/main.svelte b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/main.svelte new file mode 100644 index 0000000000..89e09f417b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/main.svelte @@ -0,0 +1,15 @@ + + +{#if data} + t)} /> +{/if} + + diff --git a/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js new file mode 100644 index 0000000000..d7293f9b70 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/_config.js @@ -0,0 +1,54 @@ +import { tick } from 'svelte'; +import { ok, test } from '../../test'; + +// Test that the store is unsubscribed from, even if it's not referenced once the store itself is set to null +export default test({ + skip_no_async: true, + + async test({ target, assert }) { + assert.htmlEqual( + target.innerHTML, + `

0

` + ); + + target.querySelector('button')?.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

1

hello 1 ` + ); + + const input = target.querySelector('input'); + ok(input); + + input.stepUp(); + input.dispatchEvent(new Event('input', { bubbles: true })); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

2

hello 2 ` + ); + + target.querySelector('button')?.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

2

` + ); + + input.stepUp(); + input.dispatchEvent(new Event('input', { bubbles: true })); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

2

` + ); + + target.querySelector('button')?.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

3

hello 3 ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte new file mode 100644 index 0000000000..4abfd3c2f9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/store-unsubscribe-not-referenced-after-2/main.svelte @@ -0,0 +1,29 @@ + + + +

{count}

+ +{#if watcherA} + + {#if true} + {await 'hello'} + {/if} + + {$watcherA} + +{:else} + +{/if} diff --git a/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_config.js b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_config.js new file mode 100644 index 0000000000..aaf40e7a52 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + mode: ['async'], + compileOptions: { + dev: true + } +}); diff --git a/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected.html b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected.html new file mode 100644 index 0000000000..93016d569c --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected.html @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected_head.html b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected_head.html new file mode 100644 index 0000000000..96ba2bba28 --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/_expected_head.html @@ -0,0 +1 @@ +Async multiple attributes \ No newline at end of file diff --git a/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/main.svelte b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/main.svelte new file mode 100644 index 0000000000..f14ccd088b --- /dev/null +++ b/packages/svelte/tests/server-side-rendering/samples/async-multiple-attrs/main.svelte @@ -0,0 +1,15 @@ + + + +Async multiple attributes + + +{(await