Merge branch 'main' into entangle-batches

entangle-batches
Simon H 6 hours ago committed by GitHub
commit 42b3c68eac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: discard batches made obsolete by commit

@ -1,5 +0,0 @@
---
'svelte': patch
---
fix: properly lazily evaluate RHS when checking for `assignment_value_stale`

@ -1,5 +0,0 @@
---
'svelte': patch
---
chore: rebase batches after process, not during

@ -0,0 +1,69 @@
name: Autofix Lint
on:
issue_comment:
types: [created]
workflow_dispatch:
permissions: {}
jobs:
autofix-lint:
permissions:
contents: write # to push the generated types commit
pull-requests: read # to resolve the PR head ref
# prevents this action from running on forks
if: |
github.repository == 'sveltejs/svelte' &&
(
github.event_name == 'workflow_dispatch' ||
(
github.event.issue.pull_request != null &&
github.event.comment.body == '/autofix' &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
)
)
runs-on: ubuntu-latest
steps:
- name: Get PR ref
if: github.event_name != 'workflow_dispatch'
id: pr
uses: actions/github-script@v8
with:
script: |
const { data: pull } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
if (pull.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'Cannot autofix: this PR is from a forked repository. The autofix workflow can only push to branches within this repository.'
});
core.setFailed('PR is from a fork');
}
core.setOutput('ref', pull.head.ref);
- uses: actions/checkout@v6
if: github.event_name == 'workflow_dispatch' || steps.pr.outcome == 'success'
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || steps.pr.outputs.ref }}
- uses: pnpm/action-setup@v4.3.0
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build
run: pnpm -F svelte build
- name: Run prettier
run: pnpm format
- name: Commit changes
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --staged --quiet || git commit -m "chore: autofix"
git push origin HEAD

@ -31,6 +31,8 @@ packages/svelte/tests/parser-modern/samples/*/_actual.json
packages/svelte/tests/parser-modern/samples/*/output.json
packages/svelte/types
packages/svelte/compiler/index.js
playgrounds/sandbox/dist/*
playgrounds/sandbox/output/*
playgrounds/sandbox/src/*
**/node_modules

@ -143,7 +143,7 @@ The CSS in a component's `<style>` is scoped to that component. If a parent comp
</style>
```
If this impossible (for example, the child component comes from a library) you can use `:global` to override styles:
If this is impossible (for example, the child component comes from a library) you can use `:global` to override styles:
```svelte
<div>

@ -42,6 +42,12 @@ Here, `List.svelte` is using `{@render children(item)` which means it expects `P
A snippet function was passed invalid arguments. Snippets should only be instantiated via `{@render ...}`
```
### invariant_violation
```
An invariant violation occurred, meaning Svelte's internal assumptions were flawed. This is a bug in Svelte, not your app — please open an issue at https://github.com/sveltejs/svelte, citing the following message: "%message%"
```
### lifecycle_outside_component
```

@ -1,5 +1,33 @@
# svelte
## 5.53.12
### Patch Changes
- fix: update `select.__value` on `change` ([#17745](https://github.com/sveltejs/svelte/pull/17745))
- chore: add `invariant` helper for debugging ([#17929](https://github.com/sveltejs/svelte/pull/17929))
- fix: ensure deriveds values are correct across batches ([#17917](https://github.com/sveltejs/svelte/pull/17917))
- fix: handle async RHS in `assignment_value_stale` ([#17925](https://github.com/sveltejs/svelte/pull/17925))
- fix: avoid traversing clean roots ([#17928](https://github.com/sveltejs/svelte/pull/17928))
## 5.53.11
### Patch Changes
- fix: remove `untrack` circular dependency ([#17910](https://github.com/sveltejs/svelte/pull/17910))
- fix: recover from errors that leave a corrupted effect tree ([#17888](https://github.com/sveltejs/svelte/pull/17888))
- fix: properly lazily evaluate RHS when checking for `assignment_value_stale` ([#17906](https://github.com/sveltejs/svelte/pull/17906))
- fix: resolve boundary in correct batch when hydrating ([#17914](https://github.com/sveltejs/svelte/pull/17914))
- chore: rebase batches after process, not during ([#17900](https://github.com/sveltejs/svelte/pull/17900))
## 5.53.10
### Patch Changes

@ -34,6 +34,10 @@ Here, `List.svelte` is using `{@render children(item)` which means it expects `P
> A snippet function was passed invalid arguments. Snippets should only be instantiated via `{@render ...}`
## invariant_violation
> An invariant violation occurred, meaning Svelte's internal assumptions were flawed. This is a bug in Svelte, not your app — please open an issue at https://github.com/sveltejs/svelte, citing the following message: "%message%"
## lifecycle_outside_component
> `%name%(...)` can only be used during component initialisation

@ -2,7 +2,7 @@
"name": "svelte",
"description": "Cybernetically enhanced web apps",
"license": "MIT",
"version": "5.53.10",
"version": "5.53.12",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
@ -138,14 +138,15 @@
"templating"
],
"scripts": {
"build": "node scripts/process-messages && rollup -c && pnpm generate:types && node scripts/check-treeshakeability.js",
"build": "rollup -c && pnpm generate",
"dev": "node scripts/process-messages -w & rollup -cw",
"check": "tsc --project tsconfig.runtime.json && tsc && cd ./tests/types && tsc",
"check:tsgo": "tsgo --project tsconfig.runtime.json --skipLibCheck && tsgo --skipLibCheck",
"check:watch": "tsc --watch",
"generate": "node scripts/process-messages && node ./scripts/generate-types.js",
"generate:version": "node ./scripts/generate-version.js",
"generate:types": "node ./scripts/generate-types.js && tsc -p tsconfig.generated.json",
"prepublishOnly": "pnpm build",
"prepublishOnly": "pnpm build && node scripts/check-treeshakeability.js",
"knip": "pnpm dlx knip"
},
"devDependencies": {
@ -175,7 +176,7 @@
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.6.3",
"devalue": "^5.6.4",
"esm-env": "^1.2.1",
"esrap": "^2.2.2",
"is-reference": "^3.0.3",

@ -5,7 +5,8 @@ import * as b from '#compiler/builders';
import {
build_assignment_value,
get_attribute_expression,
is_event_attribute
is_event_attribute,
is_expression_async
} from '../../../../utils/ast.js';
import { dev, locate_node } from '../../../../state.js';
import { build_getter, should_proxy } from '../utils.js';
@ -36,14 +37,6 @@ function is_non_coercive_operator(operator) {
return ['=', '||=', '&&=', '??='].includes(operator);
}
/** @type {Record<string, string>} */
const callees = {
'=': '$.assign',
'&&=': '$.assign_and',
'||=': '$.assign_or',
'??=': '$.assign_nullish'
};
/**
* @param {AssignmentOperator} operator
* @param {Pattern} left
@ -179,7 +172,7 @@ function build_assignment(operator, left, right, context) {
// in cases like `(object.items ??= []).push(value)`, we may need to warn
// if the value gets proxified, since the proxy _isn't_ the thing that
// will be pushed to. we do this by transforming it to something like
// `$.assign_nullish(object, 'items', () => [])`
// `$.assign(object, 'items', '??=', () => [])`
let should_transform =
dev &&
path.at(-1) !== 'ExpressionStatement' &&
@ -225,22 +218,23 @@ function build_assignment(operator, left, right, context) {
}
if (left.type === 'MemberExpression' && should_transform) {
const callee = callees[operator];
return /** @type {Expression} */ (
context.visit(
b.call(
callee,
/** @type {Expression} */ (left.object),
/** @type {Expression} */ (
left.computed
? left.property
: b.literal(/** @type {Identifier} */ (left.property).name)
),
b.arrow([], right),
b.literal(locate_node(left))
)
)
const needs_lazy_getter = operator !== '=';
const needs_async = needs_lazy_getter && is_expression_async(right);
/** @type {Expression} */
let e = b.call(
needs_async ? '$.assign_async' : '$.assign',
/** @type {Expression} */ (left.object),
/** @type {Expression} */ (
left.computed ? left.property : b.literal(/** @type {Identifier} */ (left.property).name)
),
b.literal(operator),
needs_lazy_getter ? b.arrow([], right, needs_async) : right,
b.literal(locate_node(left))
);
if (needs_async) {
e = b.await(e);
}
return /** @type {Expression} */ (context.visit(e));
}
return null;

@ -21,12 +21,21 @@ function compare(a, b, property, location) {
/**
* @param {any} object
* @param {string} property
* @param {() => any} rhs_getter
* @param {string} operator
* @param {any} rhs
* @param {string} location
*/
export function assign(object, property, rhs_getter, location) {
export function assign(object, property, operator, rhs, location) {
return compare(
(object[property] = rhs_getter()),
operator === '='
? (object[property] = rhs)
: operator === '&&='
? (object[property] &&= rhs())
: operator === '||='
? (object[property] ||= rhs())
: operator === '??='
? (object[property] ??= rhs())
: null,
untrack(() => object[property]),
property,
location
@ -36,42 +45,21 @@ export function assign(object, property, rhs_getter, location) {
/**
* @param {any} object
* @param {string} property
* @param {() => any} rhs_getter
* @param {string} operator
* @param {any} rhs
* @param {string} location
*/
export function assign_and(object, property, rhs_getter, location) {
export async function assign_async(object, property, operator, rhs, location) {
return compare(
(object[property] &&= rhs_getter()),
untrack(() => object[property]),
property,
location
);
}
/**
* @param {any} object
* @param {string} property
* @param {() => any} rhs_getter
* @param {string} location
*/
export function assign_or(object, property, rhs_getter, location) {
return compare(
(object[property] ||= rhs_getter()),
untrack(() => object[property]),
property,
location
);
}
/**
* @param {any} object
* @param {string} property
* @param {() => any} rhs_getter
* @param {string} location
*/
export function assign_nullish(object, property, rhs_getter, location) {
return compare(
(object[property] ??= rhs_getter()),
operator === '='
? (object[property] = await rhs)
: operator === '&&='
? (object[property] &&= await rhs())
: operator === '||='
? (object[property] ||= await rhs())
: operator === '??='
? (object[property] ??= await rhs())
: null,
untrack(() => object[property]),
property,
location

@ -218,8 +218,6 @@ export class Boundary {
this.is_pending = true;
this.#pending_effect = branch(() => pending(this.#anchor));
var batch = /** @type {Batch} */ (current_batch);
queue_micro_task(() => {
var fragment = (this.#offscreen_fragment = document.createDocumentFragment());
var anchor = create_text();
@ -238,14 +236,12 @@ export class Boundary {
this.#pending_effect = null;
});
this.#resolve(batch);
this.#resolve(/** @type {Batch} */ (current_batch));
}
});
}
#render() {
var batch = /** @type {Batch} */ (current_batch);
try {
this.is_pending = this.has_pending_snippet();
this.#pending_count = 0;
@ -262,7 +258,7 @@ export class Boundary {
const pending = /** @type {(anchor: Node) => void} */ (this.#props.pending);
this.#pending_effect = branch(() => pending(this.#anchor));
} else {
this.#resolve(batch);
this.#resolve(/** @type {Batch} */ (current_batch));
}
} catch (error) {
this.error(error);
@ -275,21 +271,9 @@ export class Boundary {
#resolve(batch) {
this.is_pending = false;
// any effects that were previously deferred should be rescheduled —
// after the next traversal (which will happen immediately, due to the
// same update that brought us here) the effects will be flushed
for (const e of this.#dirty_effects) {
set_signal_status(e, DIRTY);
batch.schedule(e);
}
for (const e of this.#maybe_dirty_effects) {
set_signal_status(e, MAYBE_DIRTY);
batch.schedule(e);
}
this.#dirty_effects.clear();
this.#maybe_dirty_effects.clear();
// any effects that were previously deferred should be transferred
// to the batch, which will flush in the next microtask
batch.transfer_effects(this.#dirty_effects, this.#maybe_dirty_effects);
}
/**

@ -106,6 +106,9 @@ export function bind_select_value(select, get, set = get) {
set(value);
// @ts-ignore
select.__value = value;
if (current_batch !== null) {
batches.add(current_batch);
}

@ -1,7 +1,7 @@
export { createAttachmentKey as attachment } from '../../attachments/index.js';
export { FILENAME, HMR, NAMESPACE_SVG } from '../../constants.js';
export { push, pop, add_svelte_meta } from './context.js';
export { assign, assign_and, assign_or, assign_nullish } from './dev/assign.js';
export { assign, assign_async } from './dev/assign.js';
export { cleanup_styles } from './dev/css.js';
export { add_locations } from './dev/elements.js';
export { hmr } from './dev/hmr.js';

@ -40,6 +40,8 @@ import { defer_effect } from './utils.js';
import { UNINITIALIZED } from '../../../constants.js';
import { set_signal_status } from './status.js';
import { legacy_is_updating_store } from './store.js';
import { invariant } from '../../shared/dev.js';
import { log_effect_tree } from '../dev/debug.js';
/** @type {Set<Batch>} */
const batches = new Set();
@ -242,9 +244,25 @@ export class Batch {
#process() {
if (flush_count++ > 1000) {
batches.delete(this);
infinite_loop_guard();
}
// we only reschedule previously-deferred effects if we expect
// to be able to run them after processing the batch
if (!this.#is_deferred()) {
for (const e of this.#dirty_effects) {
this.#maybe_dirty_effects.delete(e);
set_signal_status(e, DIRTY);
this.schedule(e);
}
for (const e of this.#maybe_dirty_effects) {
set_signal_status(e, MAYBE_DIRTY);
this.schedule(e);
}
}
const roots = this.#roots;
this.#roots = [];
@ -263,7 +281,12 @@ export class Batch {
var updates = (legacy_updates = []);
for (const root of roots) {
this.#traverse(root, effects, render_effects);
try {
this.#traverse(root, effects, render_effects);
} catch (e) {
reset_all(root);
throw e;
}
}
// any writes should take effect in a subsequent batch
@ -401,18 +424,16 @@ export class Batch {
* Associate a change to a given source with the current
* batch, noting its previous and current values
* @param {Source} source
* @param {any} value
* @param {any} old_value
*/
capture(source, value) {
capture(source, old_value) {
if (this.successor) {
this.successor.capture(source, value);
this.successor.capture(source, old_value);
return;
}
if (!this.is_fork) source.batch = Batch.upsert(source);
if (value !== UNINITIALIZED && !this.previous.has(source)) {
this.previous.set(source, value);
if (old_value !== UNINITIALIZED && !this.previous.has(source)) {
this.previous.set(source, old_value);
}
// Don't save errors in `batch_values`, or they won't be thrown in `runtime.js#get`
@ -453,21 +474,6 @@ export class Batch {
is_processing = true;
current_batch = this;
// we only reschedule previously-deferred effects if we expect
// to be able to run them after processing the batch
if (!this.#is_deferred()) {
for (const e of this.#dirty_effects) {
this.#maybe_dirty_effects.delete(e);
set_signal_status(e, DIRTY);
this.schedule(e);
}
for (const e of this.#maybe_dirty_effects) {
set_signal_status(e, MAYBE_DIRTY);
this.schedule(e);
}
}
this.#process();
} finally {
flush_count = 0;
@ -497,6 +503,8 @@ export class Batch {
for (const fn of this.#discard_callbacks) fn(this);
this.#discard_callbacks.clear();
batches.delete(this);
}
#commit() {
@ -542,6 +550,23 @@ export class Batch {
});
}
/**
* @param {Set<Effect>} dirty_effects
* @param {Set<Effect>} maybe_dirty_effects
*/
transfer_effects(dirty_effects, maybe_dirty_effects) {
for (const e of dirty_effects) {
this.#dirty_effects.add(e);
}
for (const e of maybe_dirty_effects) {
this.#maybe_dirty_effects.add(e);
}
dirty_effects.clear();
maybe_dirty_effects.clear();
}
/** @param {(batch: Batch) => void} fn */
oncommit(fn) {
if (this.successor) {
@ -750,7 +775,7 @@ export class Batch {
// ...and undo changes belonging to other batches
for (const batch of batches) {
if (batch === this) continue;
if (batch === this || batch.is_fork) continue;
for (const [source, previous] of batch.previous) {
if (!batch_values.has(source)) {
@ -1102,6 +1127,20 @@ function reset_branch(effect, tracked) {
}
}
/**
* Mark an entire effect tree clean following an error
* @param {Effect} effect
*/
function reset_all(effect) {
set_signal_status(effect, CLEAN);
var e = effect.first;
while (e !== null) {
reset_all(e);
e = e.next;
}
}
/**
* Creates a 'fork', in which state changes are evaluated but not applied to the DOM.
* This is useful for speculatively loading data (for example) when you suspect that
@ -1144,13 +1183,6 @@ export function fork(fn) {
source.v = value;
}
// make writable deriveds dirty, so they recalculate correctly
for (source of batch.current.keys()) {
if ((source.f & DERIVED) !== 0) {
set_signal_status(source, DIRTY);
}
}
return {
commit: async () => {
if (committed) {
@ -1209,7 +1241,6 @@ export function fork(fn) {
}
if (!committed && batches.has(batch)) {
batches.delete(batch);
batch.discard();
}
}

@ -394,6 +394,7 @@ export function execute_derived(derived) {
* @returns {void}
*/
export function update_derived(derived) {
var old_value = derived.v;
var value = execute_derived(derived);
if (!derived.equals(value)) {
@ -406,6 +407,7 @@ export function update_derived(derived) {
if (!current_batch?.is_fork || derived.deps === null) {
derived.v = value;
derived.batch = current_batch;
current_batch?.capture(derived, old_value); // TODO came in from main merge; check if correct still
// deriveds without dependencies should never be recomputed
if (derived.deps === null) {

@ -230,7 +230,11 @@ export function internal_set(source, value, updated_during_traversal = null) {
execute_derived(derived);
}
update_derived_status(derived);
// During time traveling we don't want to reset the status so that
// traversal of the graph in the other batches still happens
if (batch_values === null) {
update_derived_status(derived);
}
}
source.wv = increment_write_version();

@ -1,4 +1,6 @@
import { DEV } from 'esm-env';
import { define_property } from './utils.js';
import * as e from './errors.js';
/**
* @param {string} label
@ -63,3 +65,15 @@ export function get_stack() {
return new_lines;
}
/**
* @param {boolean} condition
* @param {string} message
*/
export function invariant(condition, message) {
if (!DEV) {
throw new Error('invariant(...) was not guarded by if (DEV)');
}
if (!condition) e.invariant_violation(message);
}

@ -51,6 +51,23 @@ export function invalid_snippet_arguments() {
}
}
/**
* An invariant violation occurred, meaning Svelte's internal assumptions were flawed. This is a bug in Svelte, not your app please open an issue at https://github.com/sveltejs/svelte, citing the following message: "%message%"
* @param {string} message
* @returns {never}
*/
export function invariant_violation(message) {
if (DEV) {
const error = new Error(`invariant_violation\nAn invariant violation occurred, meaning Svelte's internal assumptions were flawed. This is a bug in Svelte, not your app — please open an issue at https://github.com/sveltejs/svelte, citing the following message: "${message}"\nhttps://svelte.dev/e/invariant_violation`);
error.name = 'Svelte error';
throw error;
} else {
throw new Error(`https://svelte.dev/e/invariant_violation`);
}
}
/**
* `%name%(...)` can only be used during component initialisation
* @param {string} name

@ -1,5 +1,5 @@
/** @import { Readable } from './public' */
import { untrack } from '../index-client.js';
import { untrack } from '../internal/client/runtime.js';
import { noop } from '../internal/shared/utils.js';
/**

@ -4,5 +4,5 @@
* The current version, as set in package.json.
* @type {string}
*/
export const VERSION = '5.53.10';
export const VERSION = '5.53.12';
export const PUBLIC_VERSION = '5';

@ -0,0 +1,25 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
compileOptions: {
dev: true
},
async test({ assert, target }) {
const button = /** @type {HTMLElement} */ (target.querySelector('button'));
await tick();
assert.htmlEqual(target.innerHTML, `<button>go</button><p>count1: 0, count2: 0</p>`);
button.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>go</button><p>count1: 1, count2: 1</p>`);
// additional tick necessary in legacy mode because it's using Promise.resolve() which finishes before the await in the component,
// causing the cache to not be set yet, which would result in count2 becoming 2
await tick();
button.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>go</button><p>count1: 2, count2: 1</p>`);
}
});

@ -0,0 +1,18 @@
<script>
let count1 = $state(0);
let count2 = $state(0);
let cache = $state({});
async function go() {
count1++;
const value = cache.value ??= await get_value();
}
function get_value() {
count2++;
return 42;
}
</script>
<button onclick={go}>go</button>
<p>count1: {count1}, count2: {count2}</p>

@ -0,0 +1,89 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
await tick();
const [increment, shift, pop] = target.querySelectorAll('button');
assert.htmlEqual(
target.innerHTML,
`
<button>1</button>
<button>shift</button>
<button>pop</button>
<p>1 = 1</p>
`
);
increment.click();
await tick();
increment.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>3</button>
<button>shift</button>
<button>pop</button>
<p>1 = 1</p>
`
);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>3</button>
<button>shift</button>
<button>pop</button>
<p>1 = 1</p>
`
);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>3</button>
<button>shift</button>
<button>pop</button>
<p>3 = 3</p>
`
);
increment.click();
await tick();
increment.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>5</button>
<button>shift</button>
<button>pop</button>
<p>3 = 3</p>
`
);
pop.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>5</button>
<button>shift</button>
<button>pop</button>
<p>5 = 5</p>
`
);
}
});

@ -0,0 +1,36 @@
<script>
import { getAbortSignal } from 'svelte';
const queue = [];
function push(value) {
if (value === 1) return 1;
const d = Promise.withResolvers();
queue.push(() => d.resolve(value));
const signal = getAbortSignal();
signal.onabort = () => d.reject(signal.reason);
return d.promise;
}
function shift() {
queue.shift()?.();
}
function pop() {
queue.pop()?.();
}
let n = $state(1);
</script>
<button onclick={() => n++}>
{$state.eager(n)}
</button>
<button onclick={shift}>shift</button>
<button onclick={pop}>pop</button>
<p>{n} = {await push(n)}</p>

@ -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,
`<button>clicks: 0 - 0 - 0</button> <button>shift</button> <p>true - true</p>`
);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`<button>clicks: 1 - 1 - 1</button> <button>shift</button> <p>false - false</p>`
);
}
});

@ -0,0 +1,22 @@
<script>
let count = $state(0);
const delayedCount = $derived(await push(count));
const derivedCount = $derived(count);
let resolvers = [];
function push(value) {
if (!value) return value;
const { promise, resolve } = Promise.withResolvers();
resolvers.push(() => resolve(value));
return promise;
}
</script>
<button onclick={() => count += 1}>
clicks: {count} - {delayedCount} - {derivedCount}
</button>
<button onclick={() => resolvers.shift()?.()}>shift</button>
<p>{$state.eager(count) !== count} - {$state.eager(derivedCount) !== derivedCount}</p>

@ -0,0 +1,16 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, logs }) {
const [btn] = target.querySelectorAll('button');
btn.click();
await tick();
assert.deepEqual(logs, [10]);
btn.click();
await tick();
assert.deepEqual(logs, [10, 10]);
}
});

@ -0,0 +1,21 @@
<script>
import { fork } from 'svelte';
let s = $state(1);
let d = $derived(s * 10);
</script>
<button
onclick={() => {
const f = fork(() => {
// d has not been read yet, so this write happens with an uninitialized old value
s = 2;
d = 99;
});
f.discard();
console.log(d);
}}
>
test
</button>

@ -0,0 +1,11 @@
<script lang="ts">
let x = $state(false);
$effect(() => {
x = true;
return () => {
x = false;
}
});
</script>

@ -0,0 +1,16 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
// This test causes two batches to be scheduled such that the same root is traversed multiple times,
// some of the time while it was already marked clean by a previous batch processing. It tests
// that the app stays reactive after, i.e. that the root is not improperly marked as unclean.
await tick();
const [button] = target.querySelectorAll('button');
button.click();
await tick();
assert.htmlEqual(target.innerHTML, `<button>toggle</button><p>hello</p>`);
}
});

@ -0,0 +1,19 @@
<script lang="ts">
import Component from './Component.svelte';
let condition = $state(false);
</script>
<button onclick={() => (condition = !condition)}>toggle</button>
<svelte:boundary>
<Component whatever={await 1} />
{#snippet pending()}
<Component />
{/snippet}
</svelte:boundary>
{#if condition}
<p>hello</p>
{/if}

@ -0,0 +1,54 @@
import { tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target }) {
const [add, shift, reset] = target.querySelectorAll('button');
// resolve initial pending state
shift.click();
await tick();
const [p] = target.querySelectorAll('p');
const select = /** @type {HTMLSelectElement} */ (target.querySelector('select'));
assert.equal(select.value, 'a');
// add option 'c', making items ['a', 'b', 'c']
add.click();
await tick();
// select 'b' while focused
select.focus();
select.value = 'b';
select.dispatchEvent(new InputEvent('change', { bubbles: true }));
await tick();
assert.equal(select.value, 'b');
assert.equal(p.textContent, 'a');
// add option 'd', making items ['a', 'b', 'c', 'd']
// this triggers MutationObserver which uses select.__value
add.click();
await tick();
// select should still show 'b', not snap to a stale value
assert.equal(select.value, 'b');
assert.equal(p.textContent, 'a');
shift.click();
await tick();
assert.equal(select.value, 'b');
assert.equal(p.textContent, 'b');
reset.click();
await tick();
assert.equal(select.value, 'b');
assert.equal(p.textContent, 'b');
shift.click();
await tick();
assert.equal(select.value, 'a');
assert.equal(p.textContent, 'a');
}
});

@ -0,0 +1,31 @@
<script lang="ts">
let selected = $state('a');
let items = $state(['a', 'b']);
let resolvers = [];
let select;
function push(value) {
const { promise, resolve } = Promise.withResolvers();
resolvers.push(() => resolve(value));
return promise;
}
</script>
<button onclick={() => items.push(String.fromCharCode(97 + items.length))}>add</button>
<button onclick={() => resolvers.shift()?.()}>shift</button>
<button onclick={() => selected = 'a'}>reset</button>
<svelte:boundary>
<select bind:this={select} bind:value={selected}>
{#each items as item}
<option value={item}>{item}</option>
{/each}
</select>
<p>{await push(selected)}</p>
{#snippet pending()}
<p>loading...</p>
{/snippet}
</svelte:boundary>

@ -18,6 +18,14 @@ export default test({
increment.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<p>0</p>
`
);
increment.click();
await tick();
@ -28,5 +36,27 @@ export default test({
<p>2</p>
`
);
increment.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<p>2</p>
`
);
increment.click();
await tick();
assert.htmlEqual(
target.innerHTML,
`
<button>increment</button>
<p>4</p>
`
);
}
});

@ -0,0 +1,5 @@
<script lang="ts">
$effect(() => {
console.log('hello from child');
});
</script>

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
async test({ assert, logs }) {
assert.deepEqual(logs, ['hello from child']);
}
});

@ -0,0 +1,11 @@
<script>
import Child from './Child.svelte';
</script>
<svelte:boundary>
<Child />
{#snippet pending()}
<p>Loading...</p>
{/snippet}
</svelte:boundary>

@ -0,0 +1,32 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, compileOptions }) {
const [toggle, increment] = target.querySelectorAll('button');
flushSync(() => increment.click());
assert.htmlEqual(
target.innerHTML,
`
<button>toggle</button>
<button>count: 1</button>
<p>show: false</p>
`
);
assert.throws(() => {
flushSync(() => toggle.click());
}, /NonExistent is not defined/);
flushSync(() => increment.click());
assert.htmlEqual(
target.innerHTML,
`
<button>toggle</button>
<button>count: 2</button>
<p>show: ${compileOptions.experimental?.async ? 'false' : 'true'}</p>
`
);
}
});

@ -0,0 +1,13 @@
<script>
let show = $state(false);
let count = $state(0);
</script>
<button onclick={() => show = !show}>toggle</button>
<button onclick={() => count += 1}>count: {count}</button>
<p>show: {show}</p>
{#if show}
<NonExistent />
{/if}

@ -0,0 +1,49 @@
import { flushSync, tick } from 'svelte';
import { test } from '../../test';
export default test({
async test({ assert, target, variant }) {
const [button] = target.querySelectorAll('button');
const [select] = target.querySelectorAll('select');
flushSync(() => {
select.focus();
select.value = '2';
select.dispatchEvent(new InputEvent('change', { bubbles: true }));
});
assert.equal(select.selectedOptions[0].textContent, '2');
assert.htmlEqual(
target.innerHTML,
`
<button>add option</button>
<p>selected: 2</p>
<select>
<option${variant === 'hydrate' ? ' selected=""' : ''}>1</option>
<option>2</option>
<option>3</option>
</select>
`
);
flushSync(() => button.click());
await tick();
assert.equal(select.selectedOptions[0].textContent, '2');
assert.htmlEqual(
target.innerHTML,
`
<button>add option</button>
<p>selected: 2</p>
<select>
<option${variant === 'hydrate' ? ' selected=""' : ''}>1</option>
<option>2</option>
<option>3</option>
<option>4</option>
</select>
`
);
}
});

@ -0,0 +1,16 @@
<script>
let options = $state([1, 2, 3]);
let selected = $state(1);
</script>
<button onclick={() => options.push(options.length + 1)}>
add option
</button>
<p>selected: {selected}</p>
<select bind:value={selected}>
{#each options as o}
<option>{o}</option>
{/each}
</select>

@ -0,0 +1,19 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
// While we don't officially document it, `untrack` also allows to opt out of the "unsafe mutation" validation, which is what we test here
export default test({
html: '<button>0 0 0</button>',
test({ assert, target }) {
const button = target.querySelector('button');
flushSync(() => button?.click());
assert.htmlEqual(
target.innerHTML,
`
<button>1 1 2</button>
`
);
}
});

@ -0,0 +1,14 @@
<script>
import { untrack } from "svelte";
let count = $state(0);
let mirrored = $state(0);
let double = $derived.by(() => {
untrack(() => {
mirrored = count;
});
return count * 2;
})
</script>
<button onclick={() => count++}>{count} {mirrored} {double}</button>

@ -96,8 +96,8 @@ importers:
specifier: ^2.1.1
version: 2.1.1
devalue:
specifier: ^5.6.3
version: 5.6.3
specifier: ^5.6.4
version: 5.6.4
esm-env:
specifier: ^1.2.1
version: 1.2.1
@ -1267,8 +1267,8 @@ packages:
engines: {node: '>=0.10'}
hasBin: true
devalue@5.6.3:
resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==}
devalue@5.6.4:
resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==}
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
@ -3563,7 +3563,7 @@ snapshots:
detect-libc@1.0.3:
optional: true
devalue@5.6.3: {}
devalue@5.6.4: {}
dir-glob@3.0.1:
dependencies:

Loading…
Cancel
Save