diff --git a/.changeset/modern-towns-call.md b/.changeset/modern-towns-call.md
new file mode 100644
index 0000000000..77ca8e9185
--- /dev/null
+++ b/.changeset/modern-towns-call.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: discard batches made obsolete by commit
diff --git a/.changeset/quiet-jars-search.md b/.changeset/quiet-jars-search.md
deleted file mode 100644
index 4d229da74a..0000000000
--- a/.changeset/quiet-jars-search.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: properly lazily evaluate RHS when checking for `assignment_value_stale`
diff --git a/.changeset/upset-parts-throw.md b/.changeset/upset-parts-throw.md
deleted file mode 100644
index 5835fd48e0..0000000000
--- a/.changeset/upset-parts-throw.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-chore: rebase batches after process, not during
diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml
new file mode 100644
index 0000000000..706a62e3d6
--- /dev/null
+++ b/.github/workflows/autofix.yml
@@ -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
diff --git a/.prettierignore b/.prettierignore
index ee5ef6d8c6..92d9bc797b 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -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
diff --git a/documentation/docs/07-misc/01-best-practices.md b/documentation/docs/07-misc/01-best-practices.md
index 66f7da2613..e2cb72828f 100644
--- a/documentation/docs/07-misc/01-best-practices.md
+++ b/documentation/docs/07-misc/01-best-practices.md
@@ -143,7 +143,7 @@ The CSS in a component's `
```
-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
diff --git a/documentation/docs/98-reference/.generated/shared-errors.md b/documentation/docs/98-reference/.generated/shared-errors.md
index 136b3f4957..739bd58b35 100644
--- a/documentation/docs/98-reference/.generated/shared-errors.md
+++ b/documentation/docs/98-reference/.generated/shared-errors.md
@@ -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
```
diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md
index 35a0b2df3f..3ca0db8dec 100644
--- a/packages/svelte/CHANGELOG.md
+++ b/packages/svelte/CHANGELOG.md
@@ -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
diff --git a/packages/svelte/messages/shared-errors/errors.md b/packages/svelte/messages/shared-errors/errors.md
index bf053283e4..3ff474768e 100644
--- a/packages/svelte/messages/shared-errors/errors.md
+++ b/packages/svelte/messages/shared-errors/errors.md
@@ -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
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index bd55811602..49c53a26eb 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.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",
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js
index 5282f1ed64..e66e3408e2 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/AssignmentExpression.js
@@ -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
} */
-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;
diff --git a/packages/svelte/src/internal/client/dev/assign.js b/packages/svelte/src/internal/client/dev/assign.js
index 1cda7044b5..e328543b32 100644
--- a/packages/svelte/src/internal/client/dev/assign.js
+++ b/packages/svelte/src/internal/client/dev/assign.js
@@ -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
diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js
index b38a3131ca..b440bb3ba4 100644
--- a/packages/svelte/src/internal/client/dom/blocks/boundary.js
+++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js
@@ -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);
}
/**
diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/select.js b/packages/svelte/src/internal/client/dom/elements/bindings/select.js
index 21be75ba61..eb26062653 100644
--- a/packages/svelte/src/internal/client/dom/elements/bindings/select.js
+++ b/packages/svelte/src/internal/client/dom/elements/bindings/select.js
@@ -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);
}
diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js
index af8df2e32c..988998d067 100644
--- a/packages/svelte/src/internal/client/index.js
+++ b/packages/svelte/src/internal/client/index.js
@@ -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';
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 85025dfe99..dd8b9cbe93 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.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} */
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} dirty_effects
+ * @param {Set} 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();
}
}
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index 8ec46ee5f3..c736ddb6c4 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -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) {
diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js
index 18b0e37bb8..fed098e9b9 100644
--- a/packages/svelte/src/internal/client/reactivity/sources.js
+++ b/packages/svelte/src/internal/client/reactivity/sources.js
@@ -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();
diff --git a/packages/svelte/src/internal/shared/dev.js b/packages/svelte/src/internal/shared/dev.js
index aadb3c7e6d..9c36664dc4 100644
--- a/packages/svelte/src/internal/shared/dev.js
+++ b/packages/svelte/src/internal/shared/dev.js
@@ -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);
+}
diff --git a/packages/svelte/src/internal/shared/errors.js b/packages/svelte/src/internal/shared/errors.js
index b13a65b598..a4ad8cecff 100644
--- a/packages/svelte/src/internal/shared/errors.js
+++ b/packages/svelte/src/internal/shared/errors.js
@@ -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
diff --git a/packages/svelte/src/store/utils.js b/packages/svelte/src/store/utils.js
index db2a62c68c..2d36d64d2d 100644
--- a/packages/svelte/src/store/utils.js
+++ b/packages/svelte/src/store/utils.js
@@ -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';
/**
diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js
index 13f68c6467..5045430cee 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.53.10';
+export const VERSION = '5.53.12';
export const PUBLIC_VERSION = '5';
diff --git a/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/_config.js b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/_config.js
new file mode 100644
index 0000000000..9ad21cffb0
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/_config.js
@@ -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, `go count1: 0, count2: 0
`);
+
+ button.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `go count1: 1, count2: 1
`);
+
+ // 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, `go count1: 2, count2: 1
`);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/main.svelte b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/main.svelte
new file mode 100644
index 0000000000..be4d0ad2f4
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/assignment-value-stale-lazy-rhs-async/main.svelte
@@ -0,0 +1,18 @@
+
+
+go
+count1: {count1}, count2: {count2}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/_config.js b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/_config.js
new file mode 100644
index 0000000000..64e1a4b2b5
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/_config.js
@@ -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,
+ `
+ 1
+ shift
+ pop
+ 1 = 1
+ `
+ );
+
+ increment.click();
+ await tick();
+ increment.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ 3
+ shift
+ pop
+ 1 = 1
+ `
+ );
+
+ shift.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ 3
+ shift
+ pop
+ 1 = 1
+ `
+ );
+
+ shift.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ 3
+ shift
+ pop
+ 3 = 3
+ `
+ );
+
+ increment.click();
+ await tick();
+ increment.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ 5
+ shift
+ pop
+ 3 = 3
+ `
+ );
+
+ pop.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ 5
+ shift
+ pop
+ 5 = 5
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/main.svelte
new file mode 100644
index 0000000000..faa8d139a6
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-discard-obsolete-batch/main.svelte
@@ -0,0 +1,36 @@
+
+
+ n++}>
+ {$state.eager(n)}
+
+
+shift
+pop
+
+{n} = {await push(n)}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-derived/_config.js b/packages/svelte/tests/runtime-runes/samples/async-eager-derived/_config.js
new file mode 100644
index 0000000000..043f1610fb
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/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,
+ `clicks: 0 - 0 - 0 shift true - true
`
+ );
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `clicks: 1 - 1 - 1 shift false - false
`
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-eager-derived/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-eager-derived/main.svelte
new file mode 100644
index 0000000000..d1d979126d
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-eager-derived/main.svelte
@@ -0,0 +1,22 @@
+
+
+ count += 1}>
+ clicks: {count} - {delayedCount} - {derivedCount}
+
+ resolvers.shift()?.()}>shift
+
+{$state.eager(count) !== count} - {$state.eager(derivedCount) !== derivedCount}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/_config.js b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/_config.js
new file mode 100644
index 0000000000..98440b6922
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/_config.js
@@ -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]);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/main.svelte
new file mode 100644
index 0000000000..16c1668480
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-fork-discard-derived-writable-uninitialized/main.svelte
@@ -0,0 +1,21 @@
+
+
+ {
+ 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
+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-effect/Component.svelte b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/Component.svelte
new file mode 100644
index 0000000000..b39ca3dfd4
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/Component.svelte
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-effect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/_config.js
new file mode 100644
index 0000000000..083608e9dc
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/_config.js
@@ -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, `toggle hello
`);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-pending-effect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/main.svelte
new file mode 100644
index 0000000000..d6c09803f9
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-pending-effect/main.svelte
@@ -0,0 +1,19 @@
+
+
+ (condition = !condition)}>toggle
+
+
+
+
+ {#snippet pending()}
+
+ {/snippet}
+
+
+{#if condition}
+ hello
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/_config.js b/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/_config.js
new file mode 100644
index 0000000000..24e2036d39
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/_config.js
@@ -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');
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/main.svelte
new file mode 100644
index 0000000000..968e920f82
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-select-dynamic-options-while-focused/main.svelte
@@ -0,0 +1,31 @@
+
+
+ items.push(String.fromCharCode(97 + items.length))}>add
+ resolvers.shift()?.()}>shift
+ selected = 'a'}>reset
+
+
+
+ {#each items as item}
+ {item}
+ {/each}
+
+
+ {await push(selected)}
+
+ {#snippet pending()}
+ loading...
+ {/snippet}
+
diff --git a/packages/svelte/tests/runtime-runes/samples/async-unresolved-promise/_config.js b/packages/svelte/tests/runtime-runes/samples/async-unresolved-promise/_config.js
index e9ccbba2b6..c6f65c33be 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-unresolved-promise/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-unresolved-promise/_config.js
@@ -18,6 +18,14 @@ export default test({
increment.click();
await tick();
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ increment
+ 0
+ `
+ );
+
increment.click();
await tick();
@@ -28,5 +36,27 @@ export default test({
2
`
);
+
+ increment.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ increment
+ 2
+ `
+ );
+
+ increment.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ increment
+ 4
+ `
+ );
}
});
diff --git a/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/Child.svelte b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/Child.svelte
new file mode 100644
index 0000000000..0f18e43e56
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/Child.svelte
@@ -0,0 +1,5 @@
+
diff --git a/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/_config.js b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/_config.js
new file mode 100644
index 0000000000..21575231ee
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/_config.js
@@ -0,0 +1,7 @@
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, logs }) {
+ assert.deepEqual(logs, ['hello from child']);
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/main.svelte b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/main.svelte
new file mode 100644
index 0000000000..c4c0ef23ab
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/effect-in-pending-boundary/main.svelte
@@ -0,0 +1,11 @@
+
+
+
+
+
+ {#snippet pending()}
+ Loading...
+ {/snippet}
+
diff --git a/packages/svelte/tests/runtime-runes/samples/error-recovery/_config.js b/packages/svelte/tests/runtime-runes/samples/error-recovery/_config.js
new file mode 100644
index 0000000000..52c1bbd1bf
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/error-recovery/_config.js
@@ -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,
+ `
+ toggle
+ count: 1
+ show: false
+ `
+ );
+
+ assert.throws(() => {
+ flushSync(() => toggle.click());
+ }, /NonExistent is not defined/);
+
+ flushSync(() => increment.click());
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ toggle
+ count: 2
+ show: ${compileOptions.experimental?.async ? 'false' : 'true'}
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/error-recovery/main.svelte b/packages/svelte/tests/runtime-runes/samples/error-recovery/main.svelte
new file mode 100644
index 0000000000..03bfae2596
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/error-recovery/main.svelte
@@ -0,0 +1,13 @@
+
+
+ show = !show}>toggle
+ count += 1}>count: {count}
+
+show: {show}
+
+{#if show}
+
+{/if}
diff --git a/packages/svelte/tests/runtime-runes/samples/select-option-added/_config.js b/packages/svelte/tests/runtime-runes/samples/select-option-added/_config.js
new file mode 100644
index 0000000000..c91fd59f26
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/select-option-added/_config.js
@@ -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,
+ `
+ add option
+ selected: 2
+
+ 1
+ 2
+ 3
+
+ `
+ );
+
+ flushSync(() => button.click());
+ await tick();
+
+ assert.equal(select.selectedOptions[0].textContent, '2');
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ add option
+ selected: 2
+
+ 1
+ 2
+ 3
+ 4
+
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/select-option-added/main.svelte b/packages/svelte/tests/runtime-runes/samples/select-option-added/main.svelte
new file mode 100644
index 0000000000..ebf6a6c082
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/select-option-added/main.svelte
@@ -0,0 +1,16 @@
+
+
+ options.push(options.length + 1)}>
+ add option
+
+
+selected: {selected}
+
+
+ {#each options as o}
+ {o}
+ {/each}
+
diff --git a/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/_config.js b/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/_config.js
new file mode 100644
index 0000000000..ead5653dd4
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/_config.js
@@ -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: '0 0 0 ',
+ test({ assert, target }) {
+ const button = target.querySelector('button');
+
+ flushSync(() => button?.click());
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+ 1 1 2
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/main.svelte b/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/main.svelte
new file mode 100644
index 0000000000..f61a23da84
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/untrack-allows-writes/main.svelte
@@ -0,0 +1,14 @@
+
+
+ count++}>{count} {mirrored} {double}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8693c466c1..aa423e4923 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: