boundary-batch-first-run
Rich Harris 2 weeks ago
commit a1225750e5

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: send `$effect.pending` count to the correct boundary

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: replace `undefined` with `void(0)` in CallExpressions

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: place store setup inside async body

@ -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
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { expect, fn } from 'storybook/test';
import LoginForm from './LoginForm.svelte';
const { Story } = defineMeta({
component: LoginForm,
args: {
// Pass a mock function to the `onSubmit` prop
onSubmit: fn(),
}
});
</script>
<Story name="Empty Form" />
<Story
name="Filled Form"
play={async ({ args, canvas, userEvent }) => {
// 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('Youre 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/).

@ -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

@ -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": {

@ -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);

@ -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);
}
@ -297,6 +321,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) {
@ -305,7 +332,7 @@ export class Boundary {
});
}
this.pending = true;
this.#pending = true;
this.#main_effect = this.#run(() => {
this.#is_creating_fallback = false;
@ -315,7 +342,7 @@ export class Boundary {
if (this.#pending_count > 0) {
this.#show_pending_snippet();
} else {
this.pending = false;
this.#pending = false;
}
};
@ -381,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();

@ -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) => {

@ -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,
@ -285,7 +285,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);
@ -697,12 +700,12 @@ export function schedule_effect(signal) {
}
export function suspend() {
var boundary = get_pending_boundary();
var boundary = get_boundary();
var batch = /** @type {Batch} */ (current_batch);
// In case the pending snippet is shown, we want to update the UI immediately
// and not have the batch be blocked on async work,
// since the async work is happening "hidden" behind the pending snippet.
var ignore_async = boundary.pending;
var ignore_async = boundary.is_pending();
boundary.update_pending_count(1);
if (!ignore_async) batch.increment();

@ -137,7 +137,7 @@ export function async_derived(fn, location) {
// In case the pending snippet is shown, we want to update the UI immediately
// and not have the batch be blocked on async work,
// since the async work is happening "hidden" behind the pending snippet.
var ignore_async = boundary.pending;
var ignore_async = boundary.is_pending();
if (should_suspend) {
boundary.update_pending_count(1);

@ -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';

@ -0,0 +1,15 @@
<script>
async function c(a) {
await Promise.resolve()
if (a) {
throw new Error('error');
} else {
return 'ok';
}
}
let a = $state();
let b = $derived(await c(a));
</script>
<button onclick={() => a = 1}>{b}</button>

@ -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, '<button>reset</button>');
[btn] = target.querySelectorAll('button');
btn.click();
await tick();
assert.htmlEqual(target.innerHTML, '<button>ok</button>');
}
});

@ -0,0 +1,12 @@
<script>
import Test from './Test.svelte';
</script>
<svelte:boundary>
<Test />
{#snippet pending()}pending{/snippet}
{#snippet failed(_, reset)}
<button onclick={reset}>reset</button>
{/snippet}
</svelte:boundary>

@ -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,
`
<button>increment</button>
<button>shift</button>
<p>loading...</p>
`
);
shift.click();
shift.click();
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<button>shift</button>
<p>0</p>
<p>0</p>
<p>0</p>
<p>inner pending: 0</p>
<p>outer pending: 0</p>
`
);
increment.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<button>shift</button>
<p>0</p>
<p>0</p>
<p>0</p>
<p>inner pending: 3</p>
<p>outer pending: 0</p>
`
);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<button>shift</button>
<p>0</p>
<p>0</p>
<p>0</p>
<p>inner pending: 2</p>
<p>outer pending: 0</p>
`
);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<button>shift</button>
<p>0</p>
<p>0</p>
<p>0</p>
<p>inner pending: 1</p>
<p>outer pending: 0</p>
`
);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<button>shift</button>
<p>1</p>
<p>1</p>
<p>1</p>
<p>inner pending: 0</p>
<p>outer pending: 0</p>
`
);
}
});

@ -0,0 +1,34 @@
<script>
let value = $state(0);
let deferreds = [];
function push(value) {
const deferred = Promise.withResolvers();
deferreds.push({ value, deferred });
return deferred.promise;
}
function shift() {
const d = deferreds.shift();
d?.deferred.resolve(d.value);
}
</script>
<button onclick={() => value++}>increment</button>
<button onclick={() => shift()}>shift</button>
<svelte:boundary>
<svelte:boundary>
<p>{await push(value)}</p>
<p>{await push(value)}</p>
<p>{await push(value)}</p>
<p>inner pending: {$effect.pending()}</p>
</svelte:boundary>
<p>outer pending: {$effect.pending()}</p>
{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>
Loading…
Cancel
Save