From bd29b9ef2ba2a140e7e7d8835177e5ad34c6bf18 Mon Sep 17 00:00:00 2001 From: Wesley <2011150255@email.szu.edu.cn> Date: Thu, 30 Apr 2026 00:34:09 +0800 Subject: [PATCH 01/10] docs: document $state.snapshot toJSON behavior (#18154) ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [ ] Ideally, include a test that fails without this PR but passes with it. - [ ] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [ ] Run the tests with `pnpm test` and lint the project with `pnpm lint` `$state.snapshot` already clones the value returned from `toJSON`, and its `Snapshot` type reflects that return type. The `$state.snapshot` docs now call out that behavior explicitly, including the generated ambient types shown by editors. Test plan: `pnpm check`; `pnpm lint`. Fixes #18129 Co-authored-by: sakaenyeniceri5 --- documentation/docs/02-runes/02-$state.md | 2 ++ packages/svelte/src/ambient.d.ts | 2 ++ packages/svelte/types/index.d.ts | 2 ++ 3 files changed, 6 insertions(+) diff --git a/documentation/docs/02-runes/02-$state.md b/documentation/docs/02-runes/02-$state.md index d763b6578f..b90c71366a 100644 --- a/documentation/docs/02-runes/02-$state.md +++ b/documentation/docs/02-runes/02-$state.md @@ -167,6 +167,8 @@ To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snaps This is handy when you want to pass some state to an external library or API that doesn't expect a proxy, such as `structuredClone`. +If a value has a `toJSON` method, the snapshot will clone the value returned from `toJSON` instead of the original object. + ## `$state.eager` When state changes, it may not be reflected in the UI immediately if it is used by an `await` expression, because [updates are synchronized](await-expressions#Synchronized-updates). diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index 159a568477..bbbc86c997 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -147,6 +147,8 @@ declare namespace $state { * * ``` * + * If `state` has a `toJSON` method, the snapshot will clone the value returned from `toJSON` instead of the original object. + * * @see {@link https://svelte.dev/docs/svelte/$state#$state.snapshot Documentation} * * @param state The value to snapshot diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 019baf45dd..3f71d44177 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -3345,6 +3345,8 @@ declare namespace $state { * * ``` * + * If `state` has a `toJSON` method, the snapshot will clone the value returned from `toJSON` instead of the original object. + * * @see {@link https://svelte.dev/docs/svelte/$state#$state.snapshot Documentation} * * @param state The value to snapshot From ada3076967c34054a7450816f933f3c879f137fe Mon Sep 17 00:00:00 2001 From: 7nik Date: Wed, 29 Apr 2026 20:45:55 +0300 Subject: [PATCH 02/10] fix: account for proxified instance when updating `bind:this` (#18147) Fixes #18145 I wonder why nulling happens after updating `bind:this` but not before, which would fix the issue as well, though not as efficiently. ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [x] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --- .changeset/full-waves-tease.md | 5 +++++ .../client/dom/elements/bindings/this.js | 2 +- .../bind-this-proxy-deep/Component.svelte | 5 +++++ .../samples/bind-this-proxy-deep/_config.js | 22 +++++++++++++++++++ .../samples/bind-this-proxy-deep/main.svelte | 16 ++++++++++++++ 5 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 .changeset/full-waves-tease.md create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/Component.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/main.svelte diff --git a/.changeset/full-waves-tease.md b/.changeset/full-waves-tease.md new file mode 100644 index 0000000000..3915334bf7 --- /dev/null +++ b/.changeset/full-waves-tease.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: account for proxified instance when updating `bind:this` diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/this.js b/packages/svelte/src/internal/client/dom/elements/bindings/this.js index c39ca34062..52f0c213d3 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/this.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/this.js @@ -40,7 +40,7 @@ export function bind_this(element_or_component = {}, update, get_value, get_part parts = get_parts?.() || []; untrack(() => { - if (element_or_component !== get_value(...parts)) { + if (!is_bound_this(get_value(...parts), element_or_component)) { update(element_or_component, ...parts); // If this is an effect rerun (cause: each block context changes), then nullify the binding at // the previous position if it isn't already taken over by a different effect. diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/Component.svelte b/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/Component.svelte new file mode 100644 index 0000000000..c43810b6bf --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/Component.svelte @@ -0,0 +1,5 @@ + diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/_config.js b/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/_config.js new file mode 100644 index 0000000000..2f9e60e017 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/_config.js @@ -0,0 +1,22 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target, logs }) { + const btn = target.querySelector('button'); + + flushSync(() => { + btn?.click(); + }); + + flushSync(() => { + btn?.click(); + }); + + assert.deepEqual(logs, [ + {}, + { 0: { name: 'Row 0' } }, + { 0: { name: 'Row 0' }, 1: { name: 'Row 1' } } + ]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/main.svelte b/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/main.svelte new file mode 100644 index 0000000000..2e2a49b3f6 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/bind-this-proxy-deep/main.svelte @@ -0,0 +1,16 @@ + + + +{#each rows as row (row.id)} + +{/each} From 69b4c9f561a6dc14899889d815d555d398799a91 Mon Sep 17 00:00:00 2001 From: Dor Alagem Date: Wed, 29 Apr 2026 20:47:12 +0300 Subject: [PATCH 03/10] fix: skip block comments in read_value to prevent apostrophe parsing error (#18153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #18134. ## Problem `read_value()` in `packages/svelte/src/compiler/phases/1-parse/read/style.js` has no logic to skip CSS block comments (`/* ... */`). When the parser encounters an apostrophe inside a comment, it sets `quote_mark = "'"` — treating it as the start of a string literal — then never finds a matching closing quote, and ultimately throws `unexpected_eof` at the end of the style block. Minimal repro: ```svelte ``` → `Error: Unexpected end of input` ## Fix Add a `/* ... */` skip path inside the `read_value` loop, mirroring the same pattern already used in `allow_comment_or_whitespace`. When `/*` is detected outside a string or url context, the parser advances past the entire comment without adding its content to the value string. --------- Co-authored-by: Dor Alagem Co-authored-by: Rich Harris --- .changeset/three-pears-build.md | 5 +++++ .../src/compiler/phases/1-parse/read/style.js | 15 +++++++++++++++ .../samples/comment-with-apostrophe/expected.css | 4 ++++ .../samples/comment-with-apostrophe/input.svelte | 7 +++++++ 4 files changed, 31 insertions(+) create mode 100644 .changeset/three-pears-build.md create mode 100644 packages/svelte/tests/css/samples/comment-with-apostrophe/expected.css create mode 100644 packages/svelte/tests/css/samples/comment-with-apostrophe/input.svelte diff --git a/.changeset/three-pears-build.md b/.changeset/three-pears-build.md new file mode 100644 index 0000000000..8a638149a3 --- /dev/null +++ b/.changeset/three-pears-build.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ignore comments when reading CSS values diff --git a/packages/svelte/src/compiler/phases/1-parse/read/style.js b/packages/svelte/src/compiler/phases/1-parse/read/style.js index 8cb1d54d54..160e5da277 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/style.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/style.js @@ -524,6 +524,21 @@ function read_value(parser) { in_url = true; } else if ((char === ';' || char === '{' || char === '}') && !in_url && !quote_mark) { return value.trim(); + } else if ( + char === '/' && + !in_url && + !quote_mark && + parser.template[parser.index + 1] === '*' + ) { + parser.index += 2; + while (parser.index < parser.template.length) { + if (parser.template[parser.index] === '*' && parser.template[parser.index + 1] === '/') { + parser.index += 2; + break; + } + parser.index++; + } + continue; } value += char; diff --git a/packages/svelte/tests/css/samples/comment-with-apostrophe/expected.css b/packages/svelte/tests/css/samples/comment-with-apostrophe/expected.css new file mode 100644 index 0000000000..a196d53cc8 --- /dev/null +++ b/packages/svelte/tests/css/samples/comment-with-apostrophe/expected.css @@ -0,0 +1,4 @@ + + p.svelte-xyz { + padding: 0 /* it's a comment */ 1em; + } diff --git a/packages/svelte/tests/css/samples/comment-with-apostrophe/input.svelte b/packages/svelte/tests/css/samples/comment-with-apostrophe/input.svelte new file mode 100644 index 0000000000..0f9e0d2355 --- /dev/null +++ b/packages/svelte/tests/css/samples/comment-with-apostrophe/input.svelte @@ -0,0 +1,7 @@ +

red

+ + From 4c96b469f809c2615bf1a634521bdbd1ca91fbcd Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:50:11 +0200 Subject: [PATCH 04/10] fix: allow `@debug` tags to reference awaited variables (#18138) Fixes #18137 --- .changeset/small-tools-walk.md | 5 +++ .../3-transform/client/visitors/DebugTag.js | 14 ++++++--- .../3-transform/server/visitors/DebugTag.js | 31 +++++++++++++------ .../async-debug-awaited-expression/_config.js | 18 +++++++++++ .../main.svelte | 4 +++ 5 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 .changeset/small-tools-walk.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/main.svelte diff --git a/.changeset/small-tools-walk.md b/.changeset/small-tools-walk.md new file mode 100644 index 0000000000..2b275368b3 --- /dev/null +++ b/.changeset/small-tools-walk.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: allow `@debug` tags to reference awaited variables diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/DebugTag.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/DebugTag.js index ef9a070859..01a7e0e872 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/DebugTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/DebugTag.js @@ -8,6 +8,10 @@ import * as b from '#compiler/builders'; * @param {ComponentContext} context */ export function DebugTag(node, context) { + const blockers = node.identifiers + .map((identifier) => context.state.scope.get(identifier.name)?.blocker) + .filter((blocker) => blocker != null); + const object = b.object( node.identifiers.map((identifier) => { const visited = b.call('$.snapshot', /** @type {Expression} */ (context.visit(identifier))); @@ -20,9 +24,11 @@ export function DebugTag(node, context) { }) ); - const call = b.call('console.log', object); + const args = [b.thunk(b.block([b.stmt(b.call('console.log', object)), b.debugger]))]; - context.state.init.push( - b.stmt(b.call('$.template_effect', b.thunk(b.block([b.stmt(call), b.debugger])))) - ); + if (blockers.length > 0) { + args.push(b.array([]), b.array([]), b.array(blockers)); + } + + context.state.init.push(b.stmt(b.call('$.template_effect', ...args))); } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/DebugTag.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/DebugTag.js index 31b53fd3eb..3c4af2fe04 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/DebugTag.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/DebugTag.js @@ -2,23 +2,34 @@ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types.js' */ import * as b from '#compiler/builders'; +import { create_child_block } from './shared/utils.js'; /** * @param {AST.DebugTag} node * @param {ComponentContext} context */ export function DebugTag(node, context) { + const blockers = node.identifiers + .map((identifier) => context.state.scope.get(identifier.name)?.blocker) + .filter((blocker) => blocker != null); + context.state.template.push( - b.stmt( - b.call( - 'console.log', - b.object( - node.identifiers.map((identifier) => - b.prop('init', identifier, /** @type {Expression} */ (context.visit(identifier))) + ...create_child_block( + [ + b.stmt( + b.call( + 'console.log', + b.object( + node.identifiers.map((identifier) => + b.prop('init', identifier, /** @type {Expression} */ (context.visit(identifier))) + ) + ) ) - ) - ) - ), - b.debugger + ), + b.debugger + ], + b.array(blockers), + false + ) ); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/_config.js b/packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/_config.js new file mode 100644 index 0000000000..304f65cd0a --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/_config.js @@ -0,0 +1,18 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + compileOptions: { + dev: true + }, + mode: ['client', 'async-server'], + + async test({ assert, logs }) { + await tick(); + + assert.deepEqual(logs, [{ data: 'works' }]); + }, + test_ssr({ assert, logs }) { + assert.deepEqual(logs, [{ data: 'works' }]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/main.svelte new file mode 100644 index 0000000000..92b69df8fb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-debug-awaited-expression/main.svelte @@ -0,0 +1,4 @@ + + {@const data = await Promise.resolve("works")} + {@debug data} + \ No newline at end of file From bc82a55647745db00d2c3e565e6b1f4125d6e391 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:57:10 +0200 Subject: [PATCH 05/10] fix: ensure scheduled batch is flushed if not obsolete (#18131) The logic of checking that the current batch is still the generated one is flawed. If microtasks align the current batch can be a different value even if the batch still need to be flushed. This therefore switches the heuristic to what it actually should express: "has this batch run already?" Fixes #18126 (because the batch isn't running, it later runs into the invariant) --- .changeset/many-pandas-add.md | 5 +++++ .../src/internal/client/reactivity/batch.js | 2 +- .../_config.js | 15 +++++++++++++++ .../main.svelte | 18 ++++++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 .changeset/many-pandas-add.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/main.svelte diff --git a/.changeset/many-pandas-add.md b/.changeset/many-pandas-add.md new file mode 100644 index 0000000000..85de7acb35 --- /dev/null +++ b/.changeset/many-pandas-add.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ensure scheduled batch is flushed if not obsolete diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 82be1d1e8d..7adf3be00c 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -716,7 +716,7 @@ export class Batch { if (!is_flushing_sync) { queue_micro_task(() => { - if (current_batch !== batch) { + if (!batches.has(batch) || batch.#pending.size > 0) { // a flushSync happened in the meantime return; } diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/_config.js new file mode 100644 index 0000000000..dea121c456 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/_config.js @@ -0,0 +1,15 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Ensure that microtask timing doesn't influence whether or not a scheduled batch is flushed. +// Timing can be such that the current_batch is reset before the scheduled flush runs, which +// would cause the flush to skip without the fix. +export default test({ + async test({ assert, target }) { + const [btn] = target.querySelectorAll('button'); + + btn.click(); + await tick(); + assert.htmlEqual(target.innerHTML, '1 1'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/main.svelte new file mode 100644 index 0000000000..ebfbf4ca4e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-updates-microtask-separated/main.svelte @@ -0,0 +1,18 @@ + + +{#if a} + {@const toShow = await a} + {toShow} + {b} +{:else} + +{/if} From 146cb5ea6c0fc6c49677a732c047b3ff497936a1 Mon Sep 17 00:00:00 2001 From: Eduardo Kurek <133408246+eduardo-kurek@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:10:19 -0300 Subject: [PATCH 06/10] fix: lazy props reactivity (#18146) Fix #18132 This PR treat lazy fallbacks on `prop()` as derived. Now a default function that uses a $state is recalculated whenever its dependents changes. This change implies that this lazy functions cannot mutate a state anymore (because it is derived), causing a `state_unsafe_mutation`error. This implies on a breaking change, but reasonable. --- ### New breaking change here - **Who does this affect**: Everyone that has updated a $state on a default lazy prop. Example: ```html ``` **Why make this breaking change** This encourages people to not update states on a function that fundamentaly, is readonly. When someone wants to use a default function expecting that it should be tracked, its not likely that this function will change some state. It is anti-pattern to change some state inside a getter function. But what if someone wants to do it, like in the code above? The code above doesn't make sense before this PR, the old way to calculate lazy functions is to execute it one time, and only one, so the `callCount` variable will never change. But let's assume that someone did it, how to migrate? The migration in same example is easy, since the `callCount` is executed only once, it will not be executed after the component is mounted. So the `callCount` doesn't need to be a state, the `callCount` will be in a valid state when the component is created. So here is the migrated code: ```html ``` As we can see, there is no reason for the variable `callCount` in this example (before this PR), and if someone did it, it is more likely that they used a constant instead: ```html ``` There is another example that causes the `state_unsafe_mutation` and how to fix (this happened on the tests that i changed): ```html ``` Here, we can see that `log` variable is a state. Before this PR, as i said, this function `fallbackExample` will be executed once. So the logs will be computed when the component is mounted. So there is no reason to make the `log` a state. The simplest way to fix this is to make it a normal variable: ```html ``` But with this PR, the function might be recalculated at some point, and the `log` with a state makes sense now, so how to migrate in this case? As i said, changing a state inside a lazy prop function is not a good practice, we can think in a way to invert this dependency, and change the approach from push (imperative mutation) to pull (declarative derivation). If a developer really needs to track how many times a fallback is executed or react to its changes, they should use a $derived or an $effect that observes the same dependencies as the fallback, or simply observe the property itself: ```html ``` ### After all, how to migrate? 1. **If there is no state mutation inside the prop function, no need to changes**; 2. **If there is a state mutation inside the prop function, but the value muted is declared inside the same component:** remove the state from it. Before this PR the method will be executed only once, and to get the same result, you do not need the variable to be a state; **Before** ```html ``` **After** ```html ``` 3. **If there is a state mutation inside the prop function, and the value is read in multiple places:** change your approach, use a effect to detect the change on the prop, and apply your mutation inside the effect; Before: ```html ``` After ```html ``` ### Severity (number of people affected x effort): Low - **Affected Users:** Minimal. Mutating state inside a property initializer is a rare edge case and considered an anti-pattern (because its a side effect inside a getter). Most users use constants or pure functions for fallbacks. - **Migration Effort:** Low. As demonstrated in the examples above, the fix usually involves either removing an unnecessary $state or moving the side effect to its proper place, the $effect ### Conclusion This PR encourages users to program in a better way. Forcing a clean separation between data and their side effects. The developer can use this new feature mainly in i18n services, providing better usability and experience. Also, this PR makes the properties more predictable, since the expected behavior is that it works reactively, eliminating this bug for future developers. Even though this PR adds a breaking change, it's easily solvable, and the chance of any user facing this problem is low. **Full example to test reactivity in props** (won't work on web, you can get the PR and test localy to see it working): https://svelte.dev/playground/a6608434d8c642179f0e2b72468c74d7?version=latest *A unit test for this reactivity was created: runtime-runes/props-default-value-reactivity*. --------- Co-authored-by: Rich Harris Co-authored-by: Rich Harris --- .changeset/smooth-poems-tap.md | 5 +++++ .../src/internal/client/reactivity/props.js | 8 +++++++- .../main.svelte | 2 +- .../props-default-value-lazy/sub.svelte | 2 +- .../props-default-value-reactivity/_config.js | 19 +++++++++++++++++++ .../main.svelte | 10 ++++++++++ .../props-default-value-reactivity/sub.svelte | 9 +++++++++ .../translations.svelte.js | 9 +++++++++ 8 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 .changeset/smooth-poems-tap.md create mode 100644 packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/sub.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/translations.svelte.js diff --git a/.changeset/smooth-poems-tap.md b/.changeset/smooth-poems-tap.md new file mode 100644 index 0000000000..ac160656cf --- /dev/null +++ b/.changeset/smooth-poems-tap.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: re-run fallback props if dependencies update diff --git a/packages/svelte/src/internal/client/reactivity/props.js b/packages/svelte/src/internal/client/reactivity/props.js index e208d3b6f6..5626639a84 100644 --- a/packages/svelte/src/internal/client/reactivity/props.js +++ b/packages/svelte/src/internal/client/reactivity/props.js @@ -1,4 +1,4 @@ -/** @import { Effect, Source } from './types.js' */ +/** @import { Derived, Effect, Source } from './types.js' */ import { DEV } from 'esm-env'; import { PROPS_IS_BINDABLE, @@ -283,8 +283,14 @@ export function prop(props, key, flags, fallback) { var fallback_value = /** @type {V} */ (fallback); var fallback_dirty = true; + var fallback_signal = /** @type {Derived | undefined} */ (undefined); var get_fallback = () => { + if (lazy && runes) { + fallback_signal ??= derived(/** @type {() => V} */ (fallback)); + return get(fallback_signal); + } + if (fallback_dirty) { fallback_dirty = false; diff --git a/packages/svelte/tests/runtime-runes/samples/props-default-value-lazy-accessors/main.svelte b/packages/svelte/tests/runtime-runes/samples/props-default-value-lazy-accessors/main.svelte index fe2ac37bd3..f6437d6589 100644 --- a/packages/svelte/tests/runtime-runes/samples/props-default-value-lazy-accessors/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/props-default-value-lazy-accessors/main.svelte @@ -1,5 +1,5 @@ + + + + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/sub.svelte b/packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/sub.svelte new file mode 100644 index 0000000000..b3cd3fae36 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/sub.svelte @@ -0,0 +1,9 @@ + + +

greeting: {p0}

\ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/translations.svelte.js b/packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/translations.svelte.js new file mode 100644 index 0000000000..4aa4dc9999 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/props-default-value-reactivity/translations.svelte.js @@ -0,0 +1,9 @@ +let greeting = $state('Hello'); + +export function get_translation() { + return greeting; +} + +export function set_translation(value) { + greeting = value; +} From 7719a74eefa81a3bee622155ad9318eb997d1081 Mon Sep 17 00:00:00 2001 From: Rohan Santhosh Kumar <181558744+Rohan5commit@users.noreply.github.com> Date: Thu, 30 Apr 2026 04:43:37 +0800 Subject: [PATCH 07/10] chore: fix constructor typo in attributes comment (#18115) ## Summary - Fix the `constructor` typo in the attributes comment. ## Related issue - N/A ## Guideline alignment - Read `CONTRIBUTING.md` and kept this to one focused text-only file change. - No behavior, test, fixture, changeset, or generated-file changes. ## Test plan - `git diff --check` - Not run: comment-only change. Co-authored-by: Codex --- packages/svelte/src/internal/client/dom/elements/attributes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/svelte/src/internal/client/dom/elements/attributes.js b/packages/svelte/src/internal/client/dom/elements/attributes.js index a15fc48596..0cec01191a 100644 --- a/packages/svelte/src/internal/client/dom/elements/attributes.js +++ b/packages/svelte/src/internal/client/dom/elements/attributes.js @@ -584,7 +584,7 @@ function get_setters(element) { var element_proto = Element.prototype; // Stop at Element, from there on there's only unnecessary setters we're not interested in - // Do not use contructor.name here as that's unreliable in some browser environments + // Do not use constructor.name here as that's unreliable in some browser environments while (element_proto !== proto) { descriptors = get_descriptors(proto); From 9521b9f3dcd4a2c214cad6733717ed78fb046a07 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:47:11 +0200 Subject: [PATCH 08/10] fix: don't rebase just-created batches (#18117) It's possible to rebase just-created batches. Case A: - batch A runs effects - one of these effects writes to a source. This creates a new batch B - an effect _after_ that (still part of "flush effects of batch A") executes a derived. This creates an entry in the `current` Map in batch B - batch A commits after processing batch B (`next_batch` etc logic), batch B is pending. Due to derived being part of batchB.current batch A can wrongfully think these are connected and try to rerun/add effects etc on batch B Case B: - like case A but with an additional await inside a pending snippet Case C: - batch A with source a and b, it flushes effects - one of these effects schedules batch B with b and c scheduling an async effect - batch B is deferred - batch A commits. Due to the a/b/c partial overlap it will needlessly rerun the just scheduled async effect All these cases are wrong. We fix it like this: 1. we call `this.#commit()` _before_ running the new batches, which may stick around due to having pending work, and we don't want to rebase these. This fixes case A and C 2. we capture derived values in `previous_batch` if it exists, because it means we're currently flushing effects, and derived writes belong to that batch and not a new one that might have been scheduled already. This fixes case B Discovered this while working on #18097 --------- Co-authored-by: Rich Harris --- .changeset/easy-singers-retire.md | 5 ++ .../src/internal/client/reactivity/batch.js | 40 +++++++------ .../internal/client/reactivity/deriveds.js | 9 ++- .../async-dont-rebase-new-batch-1/_config.js | 27 +++++++++ .../async-dont-rebase-new-batch-1/main.svelte | 32 ++++++++++ .../async-dont-rebase-new-batch-2/_config.js | 25 ++++++++ .../async-dont-rebase-new-batch-2/main.svelte | 29 ++++++++++ .../async-dont-rebase-new-batch-3/_config.js | 31 ++++++++++ .../async-dont-rebase-new-batch-3/main.svelte | 37 ++++++++++++ .../async-dont-rebase-new-batch-4/_config.js | 58 +++++++++++++++++++ .../async-dont-rebase-new-batch-4/main.svelte | 38 ++++++++++++ 11 files changed, 313 insertions(+), 18 deletions(-) create mode 100644 .changeset/easy-singers-retire.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/main.svelte diff --git a/.changeset/easy-singers-retire.md b/.changeset/easy-singers-retire.md new file mode 100644 index 0000000000..4420286e13 --- /dev/null +++ b/.changeset/easy-singers-retire.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: don't rebase just-created batches diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 7adf3be00c..4239cda04b 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -342,6 +342,14 @@ export class Batch { this.#deferred?.resolve(); } + // Order matters here - we need to commit and THEN continue flushing new batches, not the other way around, + // else we could start flushing a new batch and then, if it has pending work, rebase it right afterwards, which is wrong. + // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode + // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed + if (async_mode_flag && !batches.has(this)) { + this.#commit(); + } + var next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch)); // Edge case: During traversal new branches might create effects that run immediately and set state, @@ -363,12 +371,6 @@ export class Batch { next_batch.#process(); } - - // In sync mode flushSync can cause #commit to wrongfully think that there needs to be a rebase, so we only do it in async mode - // TODO fix the underlying cause, otherwise this will likely regress when non-async mode is removed - if (async_mode_flag && !batches.has(this)) { - this.#commit(); - } } /** @@ -575,19 +577,23 @@ export class Batch { checked = new Map(); var current_unequal = [...batch.current.keys()].filter((c) => - this.current.has(c) ? /** @type {[any, boolean]} */ (this.current.get(c))[0] !== c : true + this.current.has(c) + ? /** @type {[any, boolean]} */ (this.current.get(c))[0] !== c.v + : true ); - for (const effect of this.#new_effects) { - if ( - (effect.f & (DESTROYED | INERT | EAGER_EFFECT)) === 0 && - depends_on(effect, current_unequal, checked) - ) { - if ((effect.f & (ASYNC | BLOCK_EFFECT)) !== 0) { - set_signal_status(effect, DIRTY); - batch.schedule(effect); - } else { - batch.#dirty_effects.add(effect); + if (current_unequal.length > 0) { + for (const effect of this.#new_effects) { + if ( + (effect.f & (DESTROYED | INERT | EAGER_EFFECT)) === 0 && + depends_on(effect, current_unequal, checked) + ) { + if ((effect.f & (ASYNC | BLOCK_EFFECT)) !== 0) { + set_signal_status(effect, DIRTY); + batch.schedule(effect); + } else { + batch.#dirty_effects.add(effect); + } } } } diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js index 5af51449ad..4ae49fecba 100644 --- a/packages/svelte/src/internal/client/reactivity/deriveds.js +++ b/packages/svelte/src/internal/client/reactivity/deriveds.js @@ -43,7 +43,7 @@ import { get_error } from '../../shared/dev.js'; import { async_mode_flag, tracing_mode_flag } from '../../flags/index.js'; import { component_context } from '../context.js'; import { UNINITIALIZED } from '../../../constants.js'; -import { batch_values, current_batch } from './batch.js'; +import { batch_values, current_batch, previous_batch } from './batch.js'; import { increment_pending, unset_context } from './async.js'; import { deferred, includes, noop } from '../../shared/utils.js'; import { set_signal_status, update_derived_status } from './status.js'; @@ -399,7 +399,14 @@ export function update_derived(derived) { // change, `derived.equals` may incorrectly return `true` if (!current_batch?.is_fork || derived.deps === null) { if (current_batch !== null) { + // We also write to previous_batch because if it exists, it is a sign that we're + // currently in the process of flushing effects. These updates to deriveds may belong + // to the previous batch, not the new one (which can already exist if an earlier + // effect wrote to a source). This can cause bugs when running batch.#commit() later, + // but not adding it to current_batch can, too, so we add it to both. + // See https://github.com/sveltejs/svelte/pull/18117 for more details. current_batch.capture(derived, value, true); + previous_batch?.capture(derived, value, true); } else { derived.v = value; } diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/_config.js new file mode 100644 index 0000000000..fb6f3388c9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/_config.js @@ -0,0 +1,27 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(), +// rescheduling an effect on the new batch that shouldn't run. +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, resolve] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.deepEqual(logs, []); + + // This resolve + // - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing + // - shouldn't result in #commit() rebasing the new batch + resolve.click(); + await tick(); + assert.deepEqual(logs, [2]); + + // As a result, this resolve shouldn't result in another execution of the effect depending on the derived + resolve.click(); + await tick(); + assert.deepEqual(logs, [2]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/main.svelte new file mode 100644 index 0000000000..af470363bf --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-1/main.svelte @@ -0,0 +1,32 @@ + + + + + +{#if count} + + + {(() => { + $effect(() => { + count_mirror = count; + }) + })()} + + {(() => { + $effect(() => { + console.log(double); + }) + })()} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/_config.js new file mode 100644 index 0000000000..d8a86f77da --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/_config.js @@ -0,0 +1,25 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(), +// rescheduling an effect on the new batch that shouldn't run. +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, resolve] = target.querySelectorAll('button'); + assert.deepEqual(logs, ['delay 0']); + + increment.click(); + await tick(); + assert.deepEqual(logs, ['delay 0', 'delay 2']); + + // This resolve should trigger the async effect only once + resolve.click(); + await tick(); + assert.deepEqual(logs, ['delay 0', 'delay 2', 'effect run', 'delay 4']); + + resolve.click(); + await tick(); + assert.deepEqual(logs, ['delay 0', 'delay 2', 'effect run', 'delay 4']); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/main.svelte new file mode 100644 index 0000000000..fc90ae2ba4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-2/main.svelte @@ -0,0 +1,29 @@ + + + + +{await delay(a + b + c)} diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/_config.js new file mode 100644 index 0000000000..b430e408c7 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/_config.js @@ -0,0 +1,31 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(), +// rescheduling an effect on the new batch that shouldn't run. +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, shift, pop] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.deepEqual(logs, []); + + // Resolve the blocking await which shouldn't result in the derived execution capturing + // the new derived value on the new batch, but on the previous batch which is currently flushing + pop.click(); + await tick(); + assert.deepEqual(logs, [2]); + + // Resolve the non-blocking await which shouldn't result in #commit() rebasing the new batch + shift.click(); + await tick(); + assert.deepEqual(logs, [2]); + + // Resolve the new batch's await + shift.click(); + await tick(); + assert.deepEqual(logs, [2]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/main.svelte new file mode 100644 index 0000000000..9dec14cd13 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-3/main.svelte @@ -0,0 +1,37 @@ + + + + + + +{#if count} + + {await delay(count)} + {#snippet pending()}loading{/snippet} + + + + {(() => { + $effect(() => { + count_mirror = count; + }) + })()} + + {(() => { + $effect(() => { + console.log(double); + }) + })()} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/_config.js b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/_config.js new file mode 100644 index 0000000000..804c1f53bb --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/_config.js @@ -0,0 +1,58 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Tests that a newly created batch during an effect flush isn't rebased right away by the previous batch.#commit(), +// rescheduling an effect on the new batch that shouldn't run. +export default test({ + async test({ assert, target, logs }) { + await tick(); + const [increment, unrelated, resolve] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.deepEqual(logs, []); + + // This resolve + // - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing + // - shouldn't result in #commit() rebasing the new batch + resolve.click(); + await tick(); + assert.deepEqual(logs, [2]); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + + // This resolve + // - shouldn't result in the derived execution capturing the new derived value on the new batch, but on the previous batch which is currently flushing + // - shouldn't result in #commit() rebasing the new batch + unrelated.click(); + await tick(); + assert.deepEqual(logs, [2]); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + + // As a result, this resolve shouldn't result in another execution of the effect depending on the derived + resolve.click(); + await tick(); + assert.deepEqual(logs, [2]); + assert.htmlEqual( + target.innerHTML, + ` + + + + ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/main.svelte new file mode 100644 index 0000000000..fdc2447e3e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-dont-rebase-new-batch-4/main.svelte @@ -0,0 +1,38 @@ + + + + + + +{#if count} + + + {(() => { + $effect(() => { + count_mirror = count; + untrack(() => count_mirror_d); // execute derived; should associate value with the right batch + }) + })()} + + {(() => { + $effect(() => { + console.log(double); + }) + })()} +{/if} \ No newline at end of file From 90a70cb012733be22858dc4db9a18868159bd9fa Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 30 Apr 2026 00:18:09 +0200 Subject: [PATCH 09/10] fix: flush eager effects in production (#18107) While working on #18106 I noticed that we're not adding eager effects inside `mark_reactions` when `DEV` is `false`. As a result production build could have `$state.eager` or `$state.pending` not working correctly. ~No test because I can't get Vitest to not run with `DEV` being `true`.~ added a test --- .changeset/flat-shrimps-worry.md | 5 ++++ .../src/internal/client/reactivity/sources.js | 21 ++++++----------- .../samples/async-eager-derived/_config.js | 23 +++++++++++++++++++ .../samples/async-eager-derived/main.svelte | 22 ++++++++++++++++++ 4 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 .changeset/flat-shrimps-worry.md create mode 100644 packages/svelte/tests/runtime-production/samples/async-eager-derived/_config.js create mode 100644 packages/svelte/tests/runtime-production/samples/async-eager-derived/main.svelte diff --git a/.changeset/flat-shrimps-worry.md b/.changeset/flat-shrimps-worry.md new file mode 100644 index 0000000000..a5f76a0f9d --- /dev/null +++ b/.changeset/flat-shrimps-worry.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: flush eager effects in production diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index 1831183e6f..a5dd94badf 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -47,7 +47,7 @@ import { proxy } from '../proxy.js'; import { execute_derived } from './deriveds.js'; import { set_signal_status, update_derived_status } from './status.js'; -/** @type {Set} */ +/** @type {Set} */ export let eager_effects = new Set(); /** @type {Map} */ @@ -266,12 +266,6 @@ export function flush_eager_effects() { eager_effects_deferred = false; for (const effect of eager_effects) { - // Mark clean inspect-effects as maybe dirty and then check their dirtiness - // instead of just updating the effects - this way we avoid overfiring. - if ((effect.f & CLEAN) !== 0) { - set_signal_status(effect, MAYBE_DIRTY); - } - if (is_dirty(effect)) { update_effect(effect); } @@ -338,12 +332,6 @@ function mark_reactions(signal, status, updated_during_traversal) { // In legacy mode, skip the current effect to prevent infinite loops if (!runes && reaction === active_effect) continue; - // Inspect effects need to run immediately, so that the stack trace makes sense - if (DEV && (flags & EAGER_EFFECT) !== 0) { - eager_effects.add(reaction); - continue; - } - var not_dirty = (flags & DIRTY) === 0; // don't set a DIRTY reaction to MAYBE_DIRTY @@ -351,7 +339,12 @@ function mark_reactions(signal, status, updated_during_traversal) { set_signal_status(reaction, status); } - if ((flags & DERIVED) !== 0) { + if ((flags & EAGER_EFFECT) !== 0) { + // Eager effects need to run immediately: + // - for $inspect so that the stack trace makes sense + // - for $state.eager because they might be without an effect parent + eager_effects.add(/** @type {Effect} */ (reaction)); + } else if ((flags & DERIVED) !== 0) { var derived = /** @type {Derived} */ (reaction); batch_values?.delete(derived); diff --git a/packages/svelte/tests/runtime-production/samples/async-eager-derived/_config.js b/packages/svelte/tests/runtime-production/samples/async-eager-derived/_config.js new file mode 100644 index 0000000000..043f1610fb --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/async-eager-derived/_config.js @@ -0,0 +1,23 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [increment, shift] = target.querySelectorAll('button'); + + increment.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + `

true - true

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

false - false

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

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

From 572444a6961ca73b0972280b9c955c74a02f15a4 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:20:43 +0200 Subject: [PATCH 10/10] fix: ignore false-positive errors of `$inspect` dependencies (#18106) We had logic in place to ignore errors of `$inspect` effects that are about to destroy, but we didn't take into account that we can get these transient errors while checking for `is_dirty` in preparation for running the effect, too. Now effects are marked as dirty in case an error occurs while evaluating their dependencies, which guarantees we will see the error again but we can then handle it properly. Fixes #15741 --------- Co-authored-by: Rich Harris --- .changeset/twelve-cooks-speak.md | 5 +++++ .../svelte/src/internal/client/dev/inspect.js | 2 ++ .../src/internal/client/reactivity/sources.js | 19 ++++++++++++++++- packages/svelte/tests/helpers.js | 2 +- .../inspect-derived-if-destroy/List.svelte | 11 ++++++++++ .../inspect-derived-if-destroy/_config.js | 21 +++++++++++++++++++ .../inspect-derived-if-destroy/main.svelte | 15 +++++++++++++ 7 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 .changeset/twelve-cooks-speak.md create mode 100644 packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/List.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/main.svelte diff --git a/.changeset/twelve-cooks-speak.md b/.changeset/twelve-cooks-speak.md new file mode 100644 index 0000000000..d4fcd5c339 --- /dev/null +++ b/.changeset/twelve-cooks-speak.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: ignore false-positive errors of `$inspect` dependencies diff --git a/packages/svelte/src/internal/client/dev/inspect.js b/packages/svelte/src/internal/client/dev/inspect.js index 75b29ce9b1..7a8fa0e963 100644 --- a/packages/svelte/src/internal/client/dev/inspect.js +++ b/packages/svelte/src/internal/client/dev/inspect.js @@ -20,6 +20,8 @@ export function inspect(get_value, inspector, show_stack = false) { // in an error (an `$inspect(object.property)` will run before the // `{#if object}...{/if}` that contains it) eager_effect(() => { + error = UNINITIALIZED; + try { var value = get_value(); } catch (e) { diff --git a/packages/svelte/src/internal/client/reactivity/sources.js b/packages/svelte/src/internal/client/reactivity/sources.js index a5dd94badf..f374f6a26b 100644 --- a/packages/svelte/src/internal/client/reactivity/sources.js +++ b/packages/svelte/src/internal/client/reactivity/sources.js @@ -266,7 +266,24 @@ export function flush_eager_effects() { eager_effects_deferred = false; for (const effect of eager_effects) { - if (is_dirty(effect)) { + // Mark clean inspect-effects as maybe dirty and then check their dirtiness + // instead of just updating the effects - this way we avoid overfiring. + if ((effect.f & CLEAN) !== 0) { + set_signal_status(effect, MAYBE_DIRTY); + } + + let dirty; + + try { + dirty = is_dirty(effect); + } catch { + // Dirty-checking can evaluate derived dependencies and throw in cases where + // parent effects are about to destroy this eager effect. Run the effect so + // its own error handling can deal with transient failures. + dirty = true; + } + + if (dirty) { update_effect(effect); } } diff --git a/packages/svelte/tests/helpers.js b/packages/svelte/tests/helpers.js index d0ec8b6e44..52bd47dfae 100644 --- a/packages/svelte/tests/helpers.js +++ b/packages/svelte/tests/helpers.js @@ -201,7 +201,7 @@ export const async_mode = process.env.SVELTE_NO_ASYNC !== 'true'; * @param {any[]} logs */ export function normalise_inspect_logs(logs) { - /** @type {string[]} */ + /** @type {any[]} */ const normalised = []; for (const log of logs) { diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/List.svelte b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/List.svelte new file mode 100644 index 0000000000..c73ac9a99d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/List.svelte @@ -0,0 +1,11 @@ + + +
    + {#each things as thing} +
  • thing {thing.id}
  • + {/each} +
diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/_config.js b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/_config.js new file mode 100644 index 0000000000..c29022c9dc --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/_config.js @@ -0,0 +1,21 @@ +import { normalise_inspect_logs } from '../../../helpers'; +import { test } from '../../test'; +import { flushSync } from 'svelte'; + +export default test({ + compileOptions: { + dev: true + }, + + async test({ assert, target, errors, logs }) { + const button = target.querySelector('button'); + + flushSync(() => { + button?.click(); + }); + + assert.htmlEqual(target.innerHTML, ''); + assert.equal(errors.length, 0); + assert.deepEqual(normalise_inspect_logs(logs), [[{ id: 1 }, { id: 2 }]]); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/main.svelte b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/main.svelte new file mode 100644 index 0000000000..89e09f417b --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/inspect-derived-if-destroy/main.svelte @@ -0,0 +1,15 @@ + + +{#if data} + t)} /> +{/if} + +