From f51c04afcecd8496bc9ecd5d4d5bb4715a7fd0c0 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 4 Sep 2025 04:06:17 +0200 Subject: [PATCH 1/4] fix: ensure batch exists when resetting a failed boundary (#16698) fixes #16681 --- .changeset/ninety-ravens-join.md | 5 +++++ .../internal/client/dom/blocks/boundary.js | 3 +++ .../samples/async-boundary-reset/Test.svelte | 15 +++++++++++++++ .../samples/async-boundary-reset/_config.js | 19 +++++++++++++++++++ .../samples/async-boundary-reset/main.svelte | 12 ++++++++++++ 5 files changed, 54 insertions(+) create mode 100644 .changeset/ninety-ravens-join.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-boundary-reset/Test.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-boundary-reset/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-boundary-reset/main.svelte diff --git a/.changeset/ninety-ravens-join.md b/.changeset/ninety-ravens-join.md new file mode 100644 index 0000000000..f7bc6f8def --- /dev/null +++ b/.changeset/ninety-ravens-join.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ensure batch exists when resetting a failed boundary diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 60fe2b7d3c..12ca547608 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -297,6 +297,9 @@ export class Boundary { e.svelte_boundary_reset_onerror(); } + // If the failure happened while flushing effects, current_batch can be null + Batch.ensure(); + this.#pending_count = 0; if (this.#failed_effect !== null) { diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/Test.svelte b/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/Test.svelte new file mode 100644 index 0000000000..2232a094cb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/Test.svelte @@ -0,0 +1,15 @@ + + + diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/_config.js b/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/_config.js new file mode 100644 index 0000000000..19b273175b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/_config.js @@ -0,0 +1,19 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + let [btn] = target.querySelectorAll('button'); + btn.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ''); + + [btn] = target.querySelectorAll('button'); + btn.click(); + await tick(); + + assert.htmlEqual(target.innerHTML, ''); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/main.svelte new file mode 100644 index 0000000000..42242f26d6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-boundary-reset/main.svelte @@ -0,0 +1,12 @@ + + + + + + {#snippet pending()}pending{/snippet} + {#snippet failed(_, reset)} + + {/snippet} + From 08b3b66865db472e5e6d2262386c1dc030c10a78 Mon Sep 17 00:00:00 2001 From: Kyle Gach Date: Thu, 4 Sep 2025 13:36:34 -0600 Subject: [PATCH 2/4] docs: add testing with storybook (#16701) * docs: add testing with storybook * docs: remove video * docs: address feedback - consistent tabs vs. spaces in snippet - add more Storybook details * Revise Storybook testing documentation condense, remove referer query parameters * Update documentation/docs/07-misc/02-testing.md Co-authored-by: Jeppe Reinhold * Tweaks - Prose updates - More helpful link * make it its own section --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> Co-authored-by: Jeppe Reinhold --- documentation/docs/07-misc/02-testing.md | 46 ++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/documentation/docs/07-misc/02-testing.md b/documentation/docs/07-misc/02-testing.md index bcec4db0a3..23e2e023d3 100644 --- a/documentation/docs/07-misc/02-testing.md +++ b/documentation/docs/07-misc/02-testing.md @@ -160,9 +160,9 @@ export function logger(getValue) { ### Component testing -It is possible to test your components in isolation using Vitest. +It is possible to test your components in isolation, which allows you to render them in a browser (real or simulated), simulate behavior, and make assertions, without spinning up your whole app. -> [!NOTE] Before writing component tests, think about whether you actually need to test the component, or if it's more about the logic _inside_ the component. If so, consider extracting out that logic to test it in isolation, without the overhead of a component +> [!NOTE] Before writing component tests, think about whether you actually need to test the component, or if it's more about the logic _inside_ the component. If so, consider extracting out that logic to test it in isolation, without the overhead of a component. To get started, install jsdom (a library that shims DOM APIs): @@ -246,6 +246,48 @@ test('Component', async () => { When writing component tests that involve two-way bindings, context or snippet props, it's best to create a wrapper component for your specific test and interact with that. `@testing-library/svelte` contains some [examples](https://testing-library.com/docs/svelte-testing-library/example). +### Component testing with Storybook + +[Storybook](https://storybook.js.org) is a tool for developing and documenting UI components, and it can also be used to test your components. They're run with Vitest's browser mode, which renders your components in a real browser for the most realistic testing environment. + +To get started, first install Storybook ([using Svelte's CLI](/docs/cli/storybook)) in your project via `npx sv add storybook` and choose the recommended configuration that includes testing features. If you're already using Storybook, and for more information on Storybook's testing capabilities, follow the [Storybook testing docs](https://storybook.js.org/docs/writing-tests?renderer=svelte) to get started. + +You can create stories for component variations and test interactions with the [play function](https://storybook.js.org/docs/writing-tests/interaction-testing?renderer=svelte#writing-interaction-tests), which allows you to simulate behavior and make assertions using the Testing Library and Vitest APIs. Here's an example of two stories that can be tested, one that renders an empty LoginForm component and one that simulates a user filling out the form: + +```svelte +/// file: LoginForm.stories.svelte + + + + + { + // Simulate a user filling out the form + await userEvent.type(canvas.getByTestId('email'), 'email@provider.com'); + await userEvent.type(canvas.getByTestId('password'), 'a-random-password'); + await userEvent.click(canvas.getByRole('button')); + + // Run assertions + await expect(args.onSubmit).toHaveBeenCalledTimes(1); + await expect(canvas.getByText('You’re in!')).toBeInTheDocument(); + }} +/> +``` + ## E2E tests using Playwright E2E (short for 'end to end') tests allow you to test your full application through the eyes of the user. This section uses [Playwright](https://playwright.dev/) as an example, but you can also use other solutions like [Cypress](https://www.cypress.io/) or [NightwatchJS](https://nightwatchjs.org/). From d92fa432d1032f4e1746af654eb5a7b7f536fbe9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 09:08:32 +0200 Subject: [PATCH 3/4] Version Packages (#16690) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/ninety-olives-report.md | 5 ----- .changeset/ninety-ravens-join.md | 5 ----- .changeset/wise-schools-report.md | 5 ----- packages/svelte/CHANGELOG.md | 10 ++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 6 files changed, 12 insertions(+), 17 deletions(-) delete mode 100644 .changeset/ninety-olives-report.md delete mode 100644 .changeset/ninety-ravens-join.md delete mode 100644 .changeset/wise-schools-report.md diff --git a/.changeset/ninety-olives-report.md b/.changeset/ninety-olives-report.md deleted file mode 100644 index 3e66a41d02..0000000000 --- a/.changeset/ninety-olives-report.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: replace `undefined` with `void(0)` in CallExpressions diff --git a/.changeset/ninety-ravens-join.md b/.changeset/ninety-ravens-join.md deleted file mode 100644 index f7bc6f8def..0000000000 --- a/.changeset/ninety-ravens-join.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: ensure batch exists when resetting a failed boundary diff --git a/.changeset/wise-schools-report.md b/.changeset/wise-schools-report.md deleted file mode 100644 index 47ec887256..0000000000 --- a/.changeset/wise-schools-report.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: place store setup inside async body diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index de94eb1897..535214781c 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,15 @@ # svelte +## 5.38.7 + +### Patch Changes + +- fix: replace `undefined` with `void(0)` in CallExpressions ([#16693](https://github.com/sveltejs/svelte/pull/16693)) + +- fix: ensure batch exists when resetting a failed boundary ([#16698](https://github.com/sveltejs/svelte/pull/16698)) + +- fix: place store setup inside async body ([#16687](https://github.com/sveltejs/svelte/pull/16687)) + ## 5.38.6 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index fe42603184..c6bc40ae2c 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -2,7 +2,7 @@ "name": "svelte", "description": "Cybernetically enhanced web apps", "license": "MIT", - "version": "5.38.6", + "version": "5.38.7", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 67c586790f..d499c06797 100644 --- a/packages/svelte/src/version.js +++ b/packages/svelte/src/version.js @@ -4,5 +4,5 @@ * The current version, as set in package.json. * @type {string} */ -export const VERSION = '5.38.6'; +export const VERSION = '5.38.7'; export const PUBLIC_VERSION = '5'; From 18dd45640b14873ea3485fd1c19b0efaaaccf05f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 9 Sep 2025 10:28:14 -0400 Subject: [PATCH 4/4] fix: send `$effect.pending` count to the correct boundary (#16732) * fix: send `$effect.pending` count to the correct boundary * make boundary.pending private, use boundary.is_pending consistently * move error to correct place * we need that error * update JSDoc --- .changeset/dirty-cycles-smash.md | 5 + .../src/internal/client/dom/blocks/async.js | 4 +- .../internal/client/dom/blocks/boundary.js | 66 ++++++++----- .../src/internal/client/reactivity/async.js | 4 +- .../src/internal/client/reactivity/batch.js | 11 ++- .../internal/client/reactivity/deriveds.js | 2 +- .../async-effect-pending-nested/_config.js | 95 +++++++++++++++++++ .../async-effect-pending-nested/main.svelte | 34 +++++++ 8 files changed, 189 insertions(+), 32 deletions(-) create mode 100644 .changeset/dirty-cycles-smash.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/main.svelte diff --git a/.changeset/dirty-cycles-smash.md b/.changeset/dirty-cycles-smash.md new file mode 100644 index 0000000000..1b031cf0af --- /dev/null +++ b/.changeset/dirty-cycles-smash.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: send `$effect.pending` count to the correct boundary diff --git a/packages/svelte/src/internal/client/dom/blocks/async.js b/packages/svelte/src/internal/client/dom/blocks/async.js index 82f107ab29..5ec50a5988 100644 --- a/packages/svelte/src/internal/client/dom/blocks/async.js +++ b/packages/svelte/src/internal/client/dom/blocks/async.js @@ -1,7 +1,7 @@ /** @import { TemplateNode, Value } from '#client' */ import { flatten } from '../../reactivity/async.js'; import { get } from '../../runtime.js'; -import { get_pending_boundary } from './boundary.js'; +import { get_boundary } from './boundary.js'; /** * @param {TemplateNode} node @@ -9,7 +9,7 @@ import { get_pending_boundary } from './boundary.js'; * @param {(anchor: TemplateNode, ...deriveds: Value[]) => void} fn */ export function async(node, expressions, fn) { - var boundary = get_pending_boundary(); + var boundary = get_boundary(); boundary.update_pending_count(1); diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index 12ca547608..b7f1803782 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -49,11 +49,11 @@ export function boundary(node, props, children) { } export class Boundary { - pending = false; - /** @type {Boundary | null} */ parent; + #pending = false; + /** @type {TemplateNode} */ #anchor; @@ -81,6 +81,7 @@ export class Boundary { /** @type {DocumentFragment | null} */ #offscreen_fragment = null; + #local_pending_count = 0; #pending_count = 0; #is_creating_fallback = false; @@ -95,12 +96,12 @@ export class Boundary { #effect_pending_update = () => { if (this.#effect_pending) { - internal_set(this.#effect_pending, this.#pending_count); + internal_set(this.#effect_pending, this.#local_pending_count); } }; #effect_pending_subscriber = createSubscriber(() => { - this.#effect_pending = source(this.#pending_count); + this.#effect_pending = source(this.#local_pending_count); if (DEV) { tag(this.#effect_pending, '$effect.pending()'); @@ -125,7 +126,7 @@ export class Boundary { this.parent = /** @type {Effect} */ (active_effect).b; - this.pending = !!this.#props.pending; + this.#pending = !!this.#props.pending; this.#effect = block(() => { /** @type {Effect} */ (active_effect).b = this; @@ -156,7 +157,7 @@ export class Boundary { this.#pending_effect = null; }); - this.pending = false; + this.#pending = false; } }); } else { @@ -169,7 +170,7 @@ export class Boundary { if (this.#pending_count > 0) { this.#show_pending_snippet(); } else { - this.pending = false; + this.#pending = false; } } }, flags); @@ -179,6 +180,14 @@ export class Boundary { } } + /** + * Returns `true` if the effect exists inside a boundary whose pending snippet is shown + * @returns {boolean} + */ + is_pending() { + return this.#pending || (!!this.parent && this.parent.is_pending()); + } + has_pending_snippet() { return !!this.#props.pending; } @@ -220,12 +229,25 @@ export class Boundary { } } - /** @param {1 | -1} d */ + /** + * Updates the pending count associated with the currently visible pending snippet, + * if any, such that we can replace the snippet with content once work is done + * @param {1 | -1} d + */ #update_pending_count(d) { + if (!this.has_pending_snippet()) { + if (this.parent) { + this.parent.#update_pending_count(d); + return; + } + + e.await_outside_boundary(); + } + this.#pending_count += d; if (this.#pending_count === 0) { - this.pending = false; + this.#pending = false; if (this.#pending_effect) { pause_effect(this.#pending_effect, () => { @@ -240,14 +262,16 @@ export class Boundary { } } - /** @param {1 | -1} d */ + /** + * Update the source that powers `$effect.pending()` inside this boundary, + * and controls when the current `pending` snippet (if any) is removed. + * Do not call from inside the class + * @param {1 | -1} d + */ update_pending_count(d) { - if (this.has_pending_snippet()) { - this.#update_pending_count(d); - } else if (this.parent) { - this.parent.#update_pending_count(d); - } + this.#update_pending_count(d); + this.#local_pending_count += d; effect_pending_updates.add(this.#effect_pending_update); } @@ -308,7 +332,7 @@ export class Boundary { }); } - this.pending = true; + this.#pending = true; this.#main_effect = this.#run(() => { this.#is_creating_fallback = false; @@ -318,7 +342,7 @@ export class Boundary { if (this.#pending_count > 0) { this.#show_pending_snippet(); } else { - this.pending = false; + this.#pending = false; } }; @@ -384,12 +408,8 @@ function move_effect(effect, fragment) { } } -export function get_pending_boundary() { - var boundary = /** @type {Effect} */ (active_effect).b; - - while (boundary !== null && !boundary.has_pending_snippet()) { - boundary = boundary.parent; - } +export function get_boundary() { + const boundary = /** @type {Effect} */ (active_effect).b; if (boundary === null) { e.await_outside_boundary(); diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 65d004137f..b7a5d5cdb7 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -3,7 +3,7 @@ import { DESTROYED } from '#client/constants'; import { DEV } from 'esm-env'; import { component_context, is_runes, set_component_context } from '../context.js'; -import { get_pending_boundary } from '../dom/blocks/boundary.js'; +import { get_boundary } from '../dom/blocks/boundary.js'; import { invoke_error_boundary } from '../error-handling.js'; import { active_effect, @@ -39,7 +39,7 @@ export function flatten(sync, async, fn) { var parent = /** @type {Effect} */ (active_effect); var restore = capture(); - var boundary = get_pending_boundary(); + var boundary = get_boundary(); Promise.all(async.map((expression) => async_derived(expression))) .then((result) => { diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 82f1de67a9..5176a4f74b 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -15,7 +15,7 @@ import { } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property } from '../../shared/utils.js'; -import { get_pending_boundary } from '../dom/blocks/boundary.js'; +import { get_boundary } from '../dom/blocks/boundary.js'; import { active_effect, is_dirty, @@ -298,7 +298,10 @@ export class Batch { this.#render_effects.push(effect); } else if ((flags & CLEAN) === 0) { if ((flags & ASYNC) !== 0) { - var effects = effect.b?.pending ? this.#boundary_async_effects : this.#async_effects; + var effects = effect.b?.is_pending() + ? this.#boundary_async_effects + : this.#async_effects; + effects.push(effect); } else if (is_dirty(effect)) { if ((effect.f & BLOCK_EFFECT) !== 0) this.#block_effects.push(effect); @@ -668,9 +671,9 @@ export function schedule_effect(signal) { } export function suspend() { - var boundary = get_pending_boundary(); + var boundary = get_boundary(); var batch = /** @type {Batch} */ (current_batch); - var pending = boundary.pending; + var pending = boundary.is_pending(); boundary.update_pending_count(1); if (!pending) batch.increment(); diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 31dc267960..299251a2dc 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -135,7 +135,7 @@ export function async_derived(fn, location) { prev = promise; var batch = /** @type {Batch} */ (current_batch); - var pending = boundary.pending; + var pending = boundary.is_pending(); if (should_suspend) { boundary.update_pending_count(1); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/_config.js b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/_config.js new file mode 100644 index 0000000000..9fe354bac0 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/_config.js @@ -0,0 +1,95 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [increment, shift] = target.querySelectorAll('button'); + + assert.htmlEqual( + target.innerHTML, + ` + + +

loading...

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

0

+

0

+

0

+

inner pending: 0

+

outer pending: 0

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

0

+

0

+

0

+

inner pending: 3

+

outer pending: 0

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

0

+

0

+

0

+

inner pending: 2

+

outer pending: 0

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

0

+

0

+

0

+

inner pending: 1

+

outer pending: 0

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

1

+

1

+

1

+

inner pending: 0

+

outer pending: 0

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/main.svelte new file mode 100644 index 0000000000..eeafbdc3c4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-effect-pending-nested/main.svelte @@ -0,0 +1,34 @@ + + + + + + + +

{await push(value)}

+

{await push(value)}

+

{await push(value)}

+

inner pending: {$effect.pending()}

+
+

outer pending: {$effect.pending()}

+ + {#snippet pending()} +

loading...

+ {/snippet} +
+ +