+ {/snippet}
+
From 6a303c3638b351386dfc6523db5def622428ce62 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 17 Mar 2026 10:23:19 -0400
Subject: [PATCH 06/10] Version Packages (#17936)
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.
# Releases
## svelte@5.53.13
### Patch Changes
- fix: ensure `$inspect` after top level await doesn't break builds
([#17943](https://github.com/sveltejs/svelte/pull/17943))
- fix: resume inert effects when they come from offscreen
([#17942](https://github.com/sveltejs/svelte/pull/17942))
- fix: don't eagerly access not-yet-initialized functions in template
([#17938](https://github.com/sveltejs/svelte/pull/17938))
- fix: discard batches made obsolete by commit
([#17934](https://github.com/sveltejs/svelte/pull/17934))
- fix: ensure "is standalone child" is correctly reset
([#17944](https://github.com/sveltejs/svelte/pull/17944))
- fix: remove nodes in boundary when work is pending and HMR is active
([#17932](https://github.com/sveltejs/svelte/pull/17932))
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
.changeset/clear-birds-invite.md | 5 -----
.changeset/fair-hands-relate.md | 5 -----
.changeset/lemon-geese-post.md | 5 -----
.changeset/modern-towns-call.md | 5 -----
.changeset/shy-parks-enjoy.md | 5 -----
.changeset/tangy-women-shout.md | 5 -----
packages/svelte/CHANGELOG.md | 16 ++++++++++++++++
packages/svelte/package.json | 2 +-
packages/svelte/src/version.js | 2 +-
9 files changed, 18 insertions(+), 32 deletions(-)
delete mode 100644 .changeset/clear-birds-invite.md
delete mode 100644 .changeset/fair-hands-relate.md
delete mode 100644 .changeset/lemon-geese-post.md
delete mode 100644 .changeset/modern-towns-call.md
delete mode 100644 .changeset/shy-parks-enjoy.md
delete mode 100644 .changeset/tangy-women-shout.md
diff --git a/.changeset/clear-birds-invite.md b/.changeset/clear-birds-invite.md
deleted file mode 100644
index 3c1b73d845..0000000000
--- a/.changeset/clear-birds-invite.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: ensure `$inspect` after top level await doesn't break builds
diff --git a/.changeset/fair-hands-relate.md b/.changeset/fair-hands-relate.md
deleted file mode 100644
index fd7b3585ad..0000000000
--- a/.changeset/fair-hands-relate.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: resume inert effects when they come from offscreen
diff --git a/.changeset/lemon-geese-post.md b/.changeset/lemon-geese-post.md
deleted file mode 100644
index 42d01461c5..0000000000
--- a/.changeset/lemon-geese-post.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: don't eagerly access not-yet-initialized functions in template
diff --git a/.changeset/modern-towns-call.md b/.changeset/modern-towns-call.md
deleted file mode 100644
index 77ca8e9185..0000000000
--- a/.changeset/modern-towns-call.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: discard batches made obsolete by commit
diff --git a/.changeset/shy-parks-enjoy.md b/.changeset/shy-parks-enjoy.md
deleted file mode 100644
index d57577e6f2..0000000000
--- a/.changeset/shy-parks-enjoy.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: ensure "is standalone child" is correctly reset
diff --git a/.changeset/tangy-women-shout.md b/.changeset/tangy-women-shout.md
deleted file mode 100644
index f62d799d93..0000000000
--- a/.changeset/tangy-women-shout.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: remove nodes in boundary when work is pending and HMR is active
diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md
index 3ca0db8dec..a22b7b2174 100644
--- a/packages/svelte/CHANGELOG.md
+++ b/packages/svelte/CHANGELOG.md
@@ -1,5 +1,21 @@
# svelte
+## 5.53.13
+
+### Patch Changes
+
+- fix: ensure `$inspect` after top level await doesn't break builds ([#17943](https://github.com/sveltejs/svelte/pull/17943))
+
+- fix: resume inert effects when they come from offscreen ([#17942](https://github.com/sveltejs/svelte/pull/17942))
+
+- fix: don't eagerly access not-yet-initialized functions in template ([#17938](https://github.com/sveltejs/svelte/pull/17938))
+
+- fix: discard batches made obsolete by commit ([#17934](https://github.com/sveltejs/svelte/pull/17934))
+
+- fix: ensure "is standalone child" is correctly reset ([#17944](https://github.com/sveltejs/svelte/pull/17944))
+
+- fix: remove nodes in boundary when work is pending and HMR is active ([#17932](https://github.com/sveltejs/svelte/pull/17932))
+
## 5.53.12
### Patch Changes
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index 49c53a26eb..f8bd216073 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.12",
+ "version": "5.53.13",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js
index 5045430cee..a10453ad9f 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.12';
+export const VERSION = '5.53.13';
export const PUBLIC_VERSION = '5';
From 5faf102782b6efc60df4290ad2858650594f1ff2 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Tue, 17 Mar 2026 16:06:37 -0400
Subject: [PATCH 07/10] fix: reinstate reactivity loss tracking (#17801)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
We commented out this code in #17038 because it was broken. I suspect it
was broken because we weren't correctly calling `unset_context` inside
`run`, leading to false positives — this is now fixed, and as such I
_think_ we can safely reinstate it.
One small change — I got rid of the `was_read` check. I assume this
existed to prevent duplicate warnings, but it actually causes false
negatives in the case where you read a signal while the reaction is
being tracked then again while it's untracked.
### 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.
- [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`
---------
Co-authored-by: Tee Ming
---
.changeset/twelve-stars-serve.md | 5 ++++
.../src/internal/client/reactivity/async.js | 12 ++++----
.../internal/client/reactivity/deriveds.js | 29 ++++++++++++++-----
.../svelte/src/internal/client/runtime.js | 28 +++++++++---------
.../_config.js | 17 ++++-------
.../samples/async-reactivity-loss/_config.js | 3 --
6 files changed, 53 insertions(+), 41 deletions(-)
create mode 100644 .changeset/twelve-stars-serve.md
diff --git a/.changeset/twelve-stars-serve.md b/.changeset/twelve-stars-serve.md
new file mode 100644
index 0000000000..6a4d7ecfa7
--- /dev/null
+++ b/.changeset/twelve-stars-serve.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: reinstate reactivity loss tracking
diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js
index f8f5fec290..9b24400840 100644
--- a/packages/svelte/src/internal/client/reactivity/async.js
+++ b/packages/svelte/src/internal/client/reactivity/async.js
@@ -19,10 +19,10 @@ import {
import { Batch, current_batch } from './batch.js';
import {
async_derived,
- current_async_effect,
+ reactivity_loss_tracker,
derived,
derived_safe_equal,
- set_from_async_derived
+ set_reactivity_loss_tracker
} from './deriveds.js';
import { aborted } from './effects.js';
@@ -131,7 +131,7 @@ export function capture() {
}
if (DEV) {
- set_from_async_derived(null);
+ set_reactivity_loss_tracker(null);
set_dev_stack(previous_dev_stack);
}
};
@@ -163,11 +163,11 @@ export async function save(promise) {
* @returns {Promise<() => T>}
*/
export async function track_reactivity_loss(promise) {
- var previous_async_effect = current_async_effect;
+ var previous_async_effect = reactivity_loss_tracker;
var value = await promise;
return () => {
- set_from_async_derived(previous_async_effect);
+ set_reactivity_loss_tracker(previous_async_effect);
return value;
};
}
@@ -224,7 +224,7 @@ export function unset_context(deactivate_batch = true) {
if (deactivate_batch) current_batch?.deactivate();
if (DEV) {
- set_from_async_derived(null);
+ set_reactivity_loss_tracker(null);
set_dev_stack(null);
}
}
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index aed55f7fba..7b932a8356 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -45,12 +45,16 @@ 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';
-/** @type {Effect | null} */
-export let current_async_effect = null;
+/**
+ * This allows us to track 'reactivity loss' that occurs when signals
+ * are read after a non-context-restoring `await`. Dev-only
+ * @type {{ effect: Effect, warned: boolean } | null}
+ */
+export let reactivity_loss_tracker = null;
-/** @param {Effect | null} v */
-export function set_from_async_derived(v) {
- current_async_effect = v;
+/** @param {{ effect: Effect, warned: boolean } | null} v */
+export function set_reactivity_loss_tracker(v) {
+ reactivity_loss_tracker = v;
}
export const recent_async_deriveds = new Set();
@@ -124,7 +128,12 @@ export function async_derived(fn, label, location) {
var deferreds = new Map();
async_effect(() => {
- if (DEV) current_async_effect = active_effect;
+ if (DEV) {
+ reactivity_loss_tracker = {
+ effect: /** @type {Effect} */ (active_effect),
+ warned: false
+ };
+ }
var effect = /** @type {Effect} */ (active_effect);
@@ -142,7 +151,9 @@ export function async_derived(fn, label, location) {
unset_context();
}
- if (DEV) current_async_effect = null;
+ if (DEV) {
+ reactivity_loss_tracker = null;
+ }
var batch = /** @type {Batch} */ (current_batch);
@@ -174,7 +185,9 @@ export function async_derived(fn, label, location) {
* @param {unknown} error
*/
const handler = (value, error = undefined) => {
- if (DEV) current_async_effect = null;
+ if (DEV) {
+ reactivity_loss_tracker = null;
+ }
if (decrement_pending) {
// don't trigger an update if we're only here because
diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js
index 3260c24b8c..906d68fbf0 100644
--- a/packages/svelte/src/internal/client/runtime.js
+++ b/packages/svelte/src/internal/client/runtime.js
@@ -27,7 +27,7 @@ import {
} from './constants.js';
import { old_values } from './reactivity/sources.js';
import {
- destroy_derived_effects,
+ reactivity_loss_tracker,
execute_derived,
freeze_derived_effects,
recent_async_deriveds,
@@ -58,6 +58,7 @@ import { UNINITIALIZED } from '../../constants.js';
import { captured_signals } from './legacy.js';
import { without_reactive_context } from './dom/elements/bindings/shared.js';
import { set_signal_status, update_derived_status } from './reactivity/status.js';
+import * as w from './warnings.js';
let is_updating_effect = false;
@@ -568,19 +569,20 @@ export function get(signal) {
}
if (DEV) {
- // TODO reinstate this, but make it actually work
- // if (current_async_effect) {
- // var tracking = (current_async_effect.f & REACTION_IS_UPDATING) !== 0;
- // var was_read = current_async_effect.deps?.includes(signal);
+ if (
+ !untracking &&
+ reactivity_loss_tracker &&
+ !reactivity_loss_tracker.warned &&
+ (reactivity_loss_tracker.effect.f & REACTION_IS_UPDATING) === 0
+ ) {
+ reactivity_loss_tracker.warned = true;
- // if (!tracking && !untracking && !was_read) {
- // w.await_reactivity_loss(/** @type {string} */ (signal.label));
+ w.await_reactivity_loss(/** @type {string} */ (signal.label));
- // var trace = get_error('traced at');
- // // eslint-disable-next-line no-console
- // if (trace) console.warn(trace);
- // }
- // }
+ var trace = get_error('traced at');
+ // eslint-disable-next-line no-console
+ if (trace) console.warn(trace);
+ }
recent_async_deriveds.delete(signal);
@@ -595,7 +597,7 @@ export function get(signal) {
if (signal.trace) {
signal.trace();
} else {
- var trace = get_error('traced at');
+ trace = get_error('traced at');
if (trace) {
var entry = tracing_expressions.entries.get(signal);
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js
index ce7cd6bd49..a5dd7fa28a 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss-for-await/_config.js
@@ -1,10 +1,8 @@
import { tick } from 'svelte';
import { test } from '../../test';
+import { normalise_trace_logs } from '../../../helpers.js';
export default test({
- // TODO reinstate
- skip: true,
-
compileOptions: {
dev: true
},
@@ -15,13 +13,10 @@ export default test({
await tick();
assert.htmlEqual(target.innerHTML, '
3
');
- assert.equal(
- warnings[0],
- 'Detected reactivity loss when reading `values[1]`. This happens when state is read in an async function after an earlier `await`'
- );
-
- assert.equal(warnings[1].name, 'traced at');
-
- assert.equal(warnings.length, 2);
+ assert.deepEqual(normalise_trace_logs(warnings), [
+ {
+ log: 'Detected reactivity loss when reading `values.length`. This happens when state is read in an async function after an earlier `await`'
+ }
+ ]);
}
});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js
index ad333a573a..747648e83f 100644
--- a/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js
+++ b/packages/svelte/tests/runtime-runes/samples/async-reactivity-loss/_config.js
@@ -2,9 +2,6 @@ import { tick } from 'svelte';
import { test } from '../../test';
export default test({
- // TODO reinstate this
- skip: true,
-
compileOptions: {
dev: true
},
From c89f6abae863c6db00557e0a283aa0e6f31b45c6 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Tue, 17 Mar 2026 16:11:32 -0400
Subject: [PATCH 08/10] feat: allow `css`, `runes`, `customElement` compiler
options to be functions (#17951)
Alternative to #17950. Closes #17952
The goal of this is to allow svelte.config.js to contain functions for
setting certain options, so that there's a single source of truth for
everything that needs to interact with Svelte config (plugins, editor
extensions, etc):
```js
// svelte.config.js
export default {
compilerOptions: {
css: ({ filename }) => filename.endsWith('/OG.svelte') ? 'injected' : 'external',
experimental: {
async: true
},
runes: ({ filename }) => !filename.split(/\/\\/).includes('node_modules')
}
};
```
Once this ships, we can deprecate `dynamicCompileOptions` in
`vite-plugin-svelte`.
### 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.
- [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/evil-nails-live.md | 5 ++
packages/svelte/src/compiler/index.js | 5 +-
packages/svelte/src/compiler/migrate/index.js | 2 +
.../src/compiler/phases/2-analyze/index.js | 21 ++++--
.../3-transform/client/transform-client.js | 2 +-
.../compiler/phases/3-transform/css/index.js | 2 +-
.../3-transform/server/transform-server.js | 2 +-
packages/svelte/src/compiler/types/index.d.ts | 16 +++-
.../svelte/src/compiler/validate-options.js | 73 ++++++++++++++-----
packages/svelte/types/index.d.ts | 20 +++--
10 files changed, 108 insertions(+), 40 deletions(-)
create mode 100644 .changeset/evil-nails-live.md
diff --git a/.changeset/evil-nails-live.md b/.changeset/evil-nails-live.md
new file mode 100644
index 0000000000..16dc3f633e
--- /dev/null
+++ b/.changeset/evil-nails-live.md
@@ -0,0 +1,5 @@
+---
+'svelte': minor
+---
+
+feat: allow `css`, `runes`, `customElement` compiler options to be functions
diff --git a/packages/svelte/src/compiler/index.js b/packages/svelte/src/compiler/index.js
index e864c4a1f4..1d822514b9 100644
--- a/packages/svelte/src/compiler/index.js
+++ b/packages/svelte/src/compiler/index.js
@@ -23,6 +23,7 @@ export { print } from './print/index.js';
export function compile(source, options) {
source = remove_bom(source);
state.reset({ warning: options.warningFilter, filename: options.filename });
+
const validated = validate_component_options(options, '');
let parsed = _parse(source);
@@ -33,7 +34,9 @@ export function compile(source, options) {
const combined_options = {
...validated,
...parsed_options,
- customElementOptions
+ customElementOptions,
+ css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : validated.css,
+ runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes
};
if (parsed.metadata.ts) {
diff --git a/packages/svelte/src/compiler/migrate/index.js b/packages/svelte/src/compiler/migrate/index.js
index ce5387f4dd..0370155c12 100644
--- a/packages/svelte/src/compiler/migrate/index.js
+++ b/packages/svelte/src/compiler/migrate/index.js
@@ -146,6 +146,8 @@ export function migrate(source, { filename, use_ts } = {}) {
...parsed_options,
customElementOptions,
filename: filename ?? UNKNOWN_FILENAME,
+ css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : () => 'external',
+ runes: 'runes' in parsed_options ? () => parsed_options.runes : () => undefined,
experimental: {
async: true
}
diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js
index fbd2e1cb8a..cadd159b3e 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/index.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/index.js
@@ -345,6 +345,8 @@ export function analyze_component(root, source, options) {
let synthetic_stores_legacy_check = [];
+ const runes_option = options.runes?.({ filename: options.filename });
+
// create synthetic bindings for store subscriptions
for (const [name, references] of module.scope.references) {
if (name[0] !== '$' || RESERVED.includes(name)) continue;
@@ -359,7 +361,7 @@ export function analyze_component(root, source, options) {
// If we're not in legacy mode through the compiler option, assume the user
// is referencing a rune and not a global store.
if (
- options.runes === false ||
+ runes_option === false ||
!is_rune(name) ||
(declaration !== null &&
// const state = $state(0) is valid
@@ -395,7 +397,7 @@ export function analyze_component(root, source, options) {
e.store_invalid_scoped_subscription(is_nested_store_subscription_node);
}
- if (options.runes !== false) {
+ if (runes_option !== false) {
if (declaration === null && /[a-z]/.test(store_name[0])) {
e.global_reference_invalid(references[0].node, name);
} else if (declaration !== null && is_rune(name)) {
@@ -447,7 +449,7 @@ export function analyze_component(root, source, options) {
const component_name = get_component_name(options.filename);
const runes =
- options.runes ??
+ runes_option ??
(has_await || instance.has_await || Array.from(module.scope.references.keys()).some(is_rune));
if (!runes) {
@@ -463,7 +465,10 @@ export function analyze_component(root, source, options) {
}
}
- const is_custom_element = !!options.customElementOptions || options.customElement;
+ const custom_element_from_option = options.customElement({ filename: options.filename });
+ const css = options.css({ filename: options.filename });
+ const custom_element = options.customElementOptions ?? custom_element_from_option;
+ const is_custom_element = !!options.customElementOptions || custom_element_from_option;
const name = module.scope.generate(options.name ?? component_name);
@@ -491,7 +496,7 @@ export function analyze_component(root, source, options) {
maybe_runes:
!runes &&
// if they explicitly disabled runes, use the legacy behavior
- options.runes !== false &&
+ runes_option !== false &&
![...module.scope.references.keys()].some((name) =>
['$$props', '$$restProps'].includes(name)
) &&
@@ -523,8 +528,8 @@ export function analyze_component(root, source, options) {
needs_props: false,
event_directive_node: null,
uses_event_attributes: false,
- custom_element: is_custom_element,
- inject_styles: options.css === 'injected' || is_custom_element,
+ custom_element,
+ inject_styles: css === 'injected' || is_custom_element,
accessors:
is_custom_element ||
(runes ? false : !!options.accessors) ||
@@ -680,7 +685,7 @@ export function analyze_component(root, source, options) {
w.options_deprecated_accessors(attribute);
}
- if (attribute.name === 'customElement' && !options.customElement) {
+ if (attribute.name === 'customElement' && !custom_element_from_option) {
w.options_missing_custom_element(attribute);
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
index b50a73b8b6..9328d20be3 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js
@@ -595,7 +595,7 @@ export function client_component(analysis, options) {
);
}
- const ce = options.customElementOptions ?? options.customElement;
+ const ce = analysis.custom_element;
if (ce) {
const ce_props = typeof ce === 'boolean' ? {} : ce.props || {};
diff --git a/packages/svelte/src/compiler/phases/3-transform/css/index.js b/packages/svelte/src/compiler/phases/3-transform/css/index.js
index cee7ab2791..92b2706f22 100644
--- a/packages/svelte/src/compiler/phases/3-transform/css/index.js
+++ b/packages/svelte/src/compiler/phases/3-transform/css/index.js
@@ -65,7 +65,7 @@ export function render_stylesheet(source, analysis, options) {
merge_with_preprocessor_map(css, options, css.map.sources[0]);
- if (dev && options.css === 'injected' && css.code) {
+ if (dev && analysis.inject_styles && css.code) {
css.code += `\n/*# sourceMappingURL=${css.map.toUrl()} */`;
}
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js
index b9f1441bff..90a693b99a 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js
@@ -300,7 +300,7 @@ export function server_component(analysis, options) {
const body = [...state.hoisted, ...module.body];
- if (analysis.css.ast !== null && options.css === 'injected' && !options.customElement) {
+ if (analysis.css.ast !== null && analysis.inject_styles && !analysis.custom_element) {
const hash = b.literal(analysis.css.hash);
const code = b.literal(render_stylesheet(analysis.source, analysis, options).code);
diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts
index fce3f62c5c..c04466a24d 100644
--- a/packages/svelte/src/compiler/types/index.d.ts
+++ b/packages/svelte/src/compiler/types/index.d.ts
@@ -73,9 +73,11 @@ export interface CompileOptions extends ModuleCompileOptions {
/**
* If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component.
*
+ * You can also pass a function that receives `{ filename }` and returns a boolean.
+ *
* @default false
*/
- customElement?: boolean;
+ customElement?: boolean | ((options: { filename: string }) => boolean);
/**
* If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`.
*
@@ -101,8 +103,10 @@ export interface CompileOptions extends ModuleCompileOptions {
* - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root.
* - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files.
* This is always `'injected'` when compiling with `customElement` mode.
+ *
+ * You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`.
*/
- css?: 'injected' | 'external';
+ css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external');
/**
* A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS.
* It defaults to returning `svelte-${hash(filename ?? css)}`.
@@ -142,7 +146,7 @@ export interface CompileOptions extends ModuleCompileOptions {
* which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead.
* @default undefined
*/
- runes?: boolean | undefined;
+ runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined);
/**
* If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`.
*
@@ -248,18 +252,22 @@ export type ValidatedCompileOptions = ValidatedModuleCompileOptions &
Required,
| keyof ModuleCompileOptions
| 'name'
+ | 'customElement'
| 'compatibility'
| 'outputFilename'
| 'cssOutputFilename'
| 'sourcemap'
+ | 'css'
| 'runes'
> & {
name: CompileOptions['name'];
+ customElement: (options: { filename: string }) => boolean;
outputFilename: CompileOptions['outputFilename'];
cssOutputFilename: CompileOptions['cssOutputFilename'];
sourcemap: CompileOptions['sourcemap'];
compatibility: Required['compatibility']>;
- runes: CompileOptions['runes'];
+ css: (options: { filename: string }) => 'injected' | 'external';
+ runes: (options: { filename: string }) => boolean | undefined;
customElementOptions: AST.SvelteOptions['customElement'];
hmr: CompileOptions['hmr'];
};
diff --git a/packages/svelte/src/compiler/validate-options.js b/packages/svelte/src/compiler/validate-options.js
index a94a553311..f7c56aa33c 100644
--- a/packages/svelte/src/compiler/validate-options.js
+++ b/packages/svelte/src/compiler/validate-options.js
@@ -51,24 +51,28 @@ const common_options = {
const component_options = {
accessors: deprecate(w.options_deprecated_accessors, boolean(false)),
- css: validator('external', (input) => {
- if (input === true || input === false) {
- throw_error(
- 'The boolean options have been removed from the css option. Use "external" instead of false and "injected" instead of true'
- );
- }
- if (input === 'none') {
- throw_error(
- 'css: "none" is no longer a valid option. If this was crucial for you, please open an issue on GitHub with your use case.'
- );
- }
+ /** @type {Validator<'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external'), (options: { filename: string }) => 'injected' | 'external'>} */
+ css: parametric(
+ /** @type {(options: { filename: string }) => 'injected' | 'external'} */ (() => 'external'),
+ (input) => {
+ if (input === true || input === false) {
+ throw_error(
+ 'The boolean options have been removed from the css option. Use "external" instead of false and "injected" instead of true'
+ );
+ }
+ if (input === 'none') {
+ throw_error(
+ 'css: "none" is no longer a valid option. If this was crucial for you, please open an issue on GitHub with your use case.'
+ );
+ }
- if (input !== 'external' && input !== 'injected') {
- throw_error(`css should be either "external" (default, recommended) or "injected"`);
- }
+ if (input !== 'external' && input !== 'injected') {
+ throw_error(`css should be either "external" (default, recommended) or "injected"`);
+ }
- return input;
- }),
+ return /** @type {'external' | 'injected'} */ (input);
+ }
+ ),
cssHash: fun(({ css, filename, hash }) => {
return `svelte-${hash(filename === '(unknown)' ? css : filename ?? css)}`;
@@ -77,7 +81,17 @@ const component_options = {
// TODO this is a sourcemap option, would be good to put under a sourcemap namespace
cssOutputFilename: string(undefined),
- customElement: boolean(false),
+ /** @type {Validator boolean), (options: { filename: string }) => boolean>} */
+ customElement: parametric(
+ /** @type {(options: { filename: string }) => boolean} */ (() => false),
+ (input, keypath) => {
+ if (typeof input !== 'boolean') {
+ throw_error(`${keypath} should be true or false`);
+ }
+
+ return /** @type {boolean} */ (input);
+ }
+ ),
discloseVersion: boolean(true),
@@ -107,7 +121,8 @@ const component_options = {
preserveWhitespace: boolean(false),
- runes: boolean(undefined),
+ /** @type {Validator boolean | undefined), () => boolean | undefined>} */
+ runes: parametric(() => /** @type {boolean | undefined} */ (undefined)),
hmr: boolean(false),
@@ -318,6 +333,28 @@ function fun(fallback) {
});
}
+/**
+ * @template {(...args: any[]) => any} F
+ * @param {F} fallback
+ * @param {(value: unknown, keypath: string) => ReturnType} [normalize]
+ * @returns {Validator}
+ */
+function parametric(fallback, normalize = (value) => /** @type {ReturnType} */ (value)) {
+ return validator(fallback, (input, keypath) => {
+ if (typeof input === 'function') {
+ /** @type {(...args: Parameters) => ReturnType} */
+ const normalized = (...args) => normalize(input(...args), keypath);
+
+ return /** @type {F} */ (/** @type {unknown} */ (normalized));
+ }
+
+ /** @type {(...args: Parameters) => ReturnType} */
+ const normalized = (..._args) => normalize(input, keypath);
+
+ return /** @type {F} */ (/** @type {unknown} */ (normalized));
+ });
+}
+
/** @param {string} msg */
function throw_error(msg) {
e.options_invalid_value(null, msg);
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index 31ef9110df..371a823225 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -1030,9 +1030,11 @@ declare module 'svelte/compiler' {
/**
* If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component.
*
+ * You can also pass a function that receives `{ filename }` and returns a boolean.
+ *
* @default false
*/
- customElement?: boolean;
+ customElement?: boolean | ((options: { filename: string }) => boolean);
/**
* If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`.
*
@@ -1058,8 +1060,10 @@ declare module 'svelte/compiler' {
* - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root.
* - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files.
* This is always `'injected'` when compiling with `customElement` mode.
+ *
+ * You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`.
*/
- css?: 'injected' | 'external';
+ css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external');
/**
* A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS.
* It defaults to returning `svelte-${hash(filename ?? css)}`.
@@ -1099,7 +1103,7 @@ declare module 'svelte/compiler' {
* which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead.
* @default undefined
*/
- runes?: boolean | undefined;
+ runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined);
/**
* If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`.
*
@@ -3006,9 +3010,11 @@ declare module 'svelte/types/compiler/interfaces' {
/**
* If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component.
*
+ * You can also pass a function that receives `{ filename }` and returns a boolean.
+ *
* @default false
*/
- customElement?: boolean;
+ customElement?: boolean | ((options: { filename: string }) => boolean);
/**
* If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`.
*
@@ -3034,8 +3040,10 @@ declare module 'svelte/types/compiler/interfaces' {
* - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root.
* - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files.
* This is always `'injected'` when compiling with `customElement` mode.
+ *
+ * You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`.
*/
- css?: 'injected' | 'external';
+ css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external');
/**
* A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS.
* It defaults to returning `svelte-${hash(filename ?? css)}`.
@@ -3075,7 +3083,7 @@ declare module 'svelte/types/compiler/interfaces' {
* which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead.
* @default undefined
*/
- runes?: boolean | undefined;
+ runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined);
/**
* If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`.
*
From 7ec156a7b025a916da2745808c72c92dc6e05593 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 17 Mar 2026 17:39:27 -0400
Subject: [PATCH 09/10] Version Packages (#17953)
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.
# Releases
## svelte@5.54.0
### Minor Changes
- feat: allow `css`, `runes`, `customElement` compiler options to be
functions ([#17951](https://github.com/sveltejs/svelte/pull/17951))
### Patch Changes
- fix: reinstate reactivity loss tracking
([#17801](https://github.com/sveltejs/svelte/pull/17801))
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
.changeset/evil-nails-live.md | 5 -----
.changeset/twelve-stars-serve.md | 5 -----
packages/svelte/CHANGELOG.md | 10 ++++++++++
packages/svelte/package.json | 2 +-
packages/svelte/src/version.js | 2 +-
5 files changed, 12 insertions(+), 12 deletions(-)
delete mode 100644 .changeset/evil-nails-live.md
delete mode 100644 .changeset/twelve-stars-serve.md
diff --git a/.changeset/evil-nails-live.md b/.changeset/evil-nails-live.md
deleted file mode 100644
index 16dc3f633e..0000000000
--- a/.changeset/evil-nails-live.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': minor
----
-
-feat: allow `css`, `runes`, `customElement` compiler options to be functions
diff --git a/.changeset/twelve-stars-serve.md b/.changeset/twelve-stars-serve.md
deleted file mode 100644
index 6a4d7ecfa7..0000000000
--- a/.changeset/twelve-stars-serve.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'svelte': patch
----
-
-fix: reinstate reactivity loss tracking
diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md
index a22b7b2174..7c1311ae84 100644
--- a/packages/svelte/CHANGELOG.md
+++ b/packages/svelte/CHANGELOG.md
@@ -1,5 +1,15 @@
# svelte
+## 5.54.0
+
+### Minor Changes
+
+- feat: allow `css`, `runes`, `customElement` compiler options to be functions ([#17951](https://github.com/sveltejs/svelte/pull/17951))
+
+### Patch Changes
+
+- fix: reinstate reactivity loss tracking ([#17801](https://github.com/sveltejs/svelte/pull/17801))
+
## 5.53.13
### Patch Changes
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
index f8bd216073..270d02d621 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.13",
+ "version": "5.54.0",
"type": "module",
"types": "./types/index.d.ts",
"engines": {
diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js
index a10453ad9f..191d53016a 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.13';
+export const VERSION = '5.54.0';
export const PUBLIC_VERSION = '5';
From 0adc22c9ae5f98718bf5b7b83753767cc919b0e1 Mon Sep 17 00:00:00 2001
From: Rich Harris
Date: Wed, 18 Mar 2026 12:43:17 -0400
Subject: [PATCH 10/10] fix: defer batch resolution until earlier intersecting
batches have committed (#17162)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This fixes an awkward bug with `each` blocks containing `await`,
especially keyed `each` blocks.
If you do `array.push(...)` multiple times in distinct batches,
something weird happens — to the `each` block, the array looks this...
```js
[1]
```
...then this...
```js
[undefined, 2]
```
...then this...
```js
[undefined, undefined, 3]
```
...and so on. That's because as far as Svelte's reactivity is concerned,
what we're _really_ doing is assigning to `array[0]` then `array[1]`
then `array[2]`. Those (along with `array.length`) are each backed by
independent sources, which we can rewind individually when we need to
'apply' a batch. When it comes to sources that actually _are_
independent, this is useful, since we apply the changes from batch B to
the DOM while we're still waiting for a promise in batch A to resolve.
But in this case it's not ideal, because you would expect these changes
to accumulate.
In particular, this fails with keyed `each` blocks because duplicate
keys are disallowed (assuming the key function doesn't break on
`undefined` _before_ the duplicate check happens).
This PR fixes it by stacking batches that are interconnected.
Specifically, if a later batch has some (but not all) sources in common
with an earlier batch, then when we apply the batch we include the
sources from the earlier batch, and block it until the earlier batch
commits. When the earlier batch commits, it will check to see if doing
so unblocks any later batches, and if so process them.
In the course of working on this I realised that `SvelteSet` and
`SvelteMap` aren't async-ready — will follow up this PR with ones for
those.
Fixes #17050
---------
Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
Co-authored-by: Simon Holthausen
---
.changeset/spicy-teeth-tan.md | 5 +
.../src/internal/client/reactivity/async.js | 7 +-
.../src/internal/client/reactivity/batch.js | 148 ++++++++++---
.../internal/client/reactivity/deriveds.js | 2 +-
.../samples/async-derived-indirect/_config.js | 29 +++
.../async-derived-indirect/main.svelte | 21 ++
.../samples/async-each-overlap/_config.js | 102 ---------
.../async-ignore-skipped-block/_config.js | 29 +++
.../async-ignore-skipped-block/main.svelte | 14 ++
.../async-later-sync-overlaps/_config.js | 30 +++
.../async-later-sync-overlaps/main.svelte | 17 ++
.../async-overlapping-array/_config.js | 205 ++++++++++++++++++
.../main.svelte | 8 +-
13 files changed, 474 insertions(+), 143 deletions(-)
create mode 100644 .changeset/spicy-teeth-tan.md
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte
delete mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-ignore-skipped-block/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/_config.js
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-later-sync-overlaps/main.svelte
create mode 100644 packages/svelte/tests/runtime-runes/samples/async-overlapping-array/_config.js
rename packages/svelte/tests/runtime-runes/samples/{async-each-overlap => async-overlapping-array}/main.svelte (84%)
diff --git a/.changeset/spicy-teeth-tan.md b/.changeset/spicy-teeth-tan.md
new file mode 100644
index 0000000000..c7efa65130
--- /dev/null
+++ b/.changeset/spicy-teeth-tan.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+fix: defer batch resolution until earlier intersecting batches have committed
diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js
index 9b24400840..d713385c75 100644
--- a/packages/svelte/src/internal/client/reactivity/async.js
+++ b/packages/svelte/src/internal/client/reactivity/async.js
@@ -307,15 +307,16 @@ export function wait(blockers) {
* @returns {(skip?: boolean) => void}
*/
export function increment_pending() {
- var boundary = /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b);
+ var effect = /** @type {Effect} */ (active_effect);
+ var boundary = /** @type {Boundary} */ (effect.b);
var batch = /** @type {Batch} */ (current_batch);
var blocking = boundary.is_rendered();
boundary.update_pending_count(1, batch);
- batch.increment(blocking);
+ batch.increment(blocking, effect);
return (skip = false) => {
boundary.update_pending_count(-1, batch);
- batch.decrement(blocking, skip);
+ batch.decrement(blocking, effect, skip);
};
}
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index b100d559d2..3b10d6ebe6 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -90,20 +90,20 @@ var source_stacks = DEV ? new Set() : null;
let uid = 1;
export class Batch {
- // for debugging. TODO remove once async is stable
id = uid++;
/**
- * The current values of any sources that are updated in this batch
+ * The current values of any signals that are updated in this batch.
+ * Tuple format: [value, is_derived] (note: is_derived is false for deriveds, too, if they were overridden via assignment)
* They keys of this map are identical to `this.#previous`
- * @type {Map}
+ * @type {Map}
*/
current = new Map();
/**
- * The values of any sources that are updated in this batch _before_ those updates took place.
+ * The values of any signals (sources and deriveds) that are updated in this batch _before_ those updates took place.
* They keys of this map are identical to `this.#current`
- * @type {Map}
+ * @type {Map}
*/
previous = new Map();
@@ -121,14 +121,16 @@ export class Batch {
#discard_callbacks = new Set();
/**
- * The number of async effects that are currently in flight
+ * Async effects that are currently in flight
+ * @type {Map}
*/
- #pending = 0;
+ #pending = new Map();
/**
- * The number of async effects that are currently in flight, _not_ inside a pending boundary
+ * Async effects that are currently in flight, _not_ inside a pending boundary
+ * @type {Map}
*/
- #blocking_pending = 0;
+ #blocking_pending = new Map();
/**
* A deferred that resolves when the batch is committed, used with `settled()`
@@ -168,8 +170,35 @@ export class Batch {
#decrement_queued = false;
+ /** @type {Set} */
+ #blockers = new Set();
+
#is_deferred() {
- return this.is_fork || this.#blocking_pending > 0;
+ return this.is_fork || this.#blocking_pending.size > 0;
+ }
+
+ #is_blocked() {
+ for (const batch of this.#blockers) {
+ for (const effect of batch.#blocking_pending.keys()) {
+ var skipped = false;
+ var e = effect;
+
+ while (e.parent !== null) {
+ if (this.#skipped_branches.has(e)) {
+ skipped = true;
+ break;
+ }
+
+ e = e.parent;
+ }
+
+ if (!skipped) {
+ return true;
+ }
+ }
+ }
+
+ return false;
}
/**
@@ -264,7 +293,7 @@ export class Batch {
collected_effects = null;
legacy_updates = null;
- if (this.#is_deferred()) {
+ if (this.#is_deferred() || this.#is_blocked()) {
this.#defer_effects(render_effects);
this.#defer_effects(effects);
@@ -272,7 +301,7 @@ export class Batch {
reset_branch(e, t);
}
} else {
- if (this.#pending === 0) {
+ if (this.#pending.size === 0) {
batches.delete(this);
}
@@ -383,17 +412,18 @@ export class Batch {
/**
* Associate a change to a given source with the current
* batch, noting its previous and current values
- * @param {Source} source
+ * @param {Value} source
* @param {any} old_value
+ * @param {boolean} [is_derived]
*/
- capture(source, old_value) {
+ capture(source, old_value, is_derived = false) {
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`
if ((source.f & ERROR_VALUE) === 0) {
- this.current.set(source, source.v);
+ this.current.set(source, [source.v, is_derived]);
batch_values?.set(source, source.v);
}
}
@@ -453,11 +483,13 @@ export class Batch {
/** @type {Source[]} */
var sources = [];
- for (const [source, value] of this.current) {
+ for (const [source, [value, is_derived]] of this.current) {
if (batch.current.has(source)) {
- if (is_earlier && value !== batch.current.get(source)) {
+ var batch_value = /** @type {[any, boolean]} */ (batch.current.get(source))[0]; // faster than destructuring
+
+ if (is_earlier && value !== batch_value) {
// bring the value up to date
- batch.current.set(source, value);
+ batch.current.set(source, [value, is_derived]);
} else {
// same value or later batch has more recent value,
// no need to re-run these effects
@@ -507,24 +539,56 @@ export class Batch {
batch.deactivate();
}
}
+
+ for (const batch of batches) {
+ if (batch.#blockers.has(this)) {
+ batch.#blockers.delete(this);
+
+ if (batch.#blockers.size === 0 && !batch.#is_deferred()) {
+ batch.activate();
+ batch.#process();
+ }
+ }
+ }
}
/**
- *
* @param {boolean} blocking
+ * @param {Effect} effect
*/
- increment(blocking) {
- this.#pending += 1;
- if (blocking) this.#blocking_pending += 1;
+ increment(blocking, effect) {
+ let pending_count = this.#pending.get(effect) ?? 0;
+ this.#pending.set(effect, pending_count + 1);
+
+ if (blocking) {
+ let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0;
+ this.#blocking_pending.set(effect, blocking_pending_count + 1);
+ }
}
/**
* @param {boolean} blocking
+ * @param {Effect} effect
* @param {boolean} skip - whether to skip updates (because this is triggered by a stale reaction)
*/
- decrement(blocking, skip) {
- this.#pending -= 1;
- if (blocking) this.#blocking_pending -= 1;
+ decrement(blocking, effect, skip) {
+ let pending_count = this.#pending.get(effect) ?? 0;
+
+ if (pending_count === 1) {
+ this.#pending.delete(effect);
+ } else {
+ this.#pending.set(effect, pending_count - 1);
+ }
+
+ if (blocking) {
+ let blocking_pending_count = this.#blocking_pending.get(effect) ?? 0;
+
+ if (blocking_pending_count === 1) {
+ this.#blocking_pending.delete(effect);
+ } else {
+ this.#blocking_pending.set(effect, blocking_pending_count - 1);
+ }
+ }
if (this.#decrement_queued || skip) return;
this.#decrement_queued = true;
@@ -597,15 +661,37 @@ export class Batch {
// if there are multiple batches, we are 'time travelling' —
// we need to override values with the ones in this batch...
- batch_values = new Map(this.current);
+ batch_values = new Map();
+ for (const [source, [value]] of this.current) {
+ batch_values.set(source, value);
+ }
- // ...and undo changes belonging to other batches
+ // ...and undo changes belonging to other batches unless they block this one
for (const batch of batches) {
if (batch === this || batch.is_fork) continue;
- for (const [source, previous] of batch.previous) {
- if (!batch_values.has(source)) {
- batch_values.set(source, previous);
+ // A batch is blocked on an earlier batch if it overlaps with the earlier batch's changes but is not a superset
+ var intersects = false;
+ var differs = false;
+
+ if (batch.id < this.id) {
+ for (const [source, [, is_derived]] of batch.current) {
+ // Derived values don't partake in the blocking mechanism, because a derived could
+ // be triggered in one batch already but not the other one yet, causing a false-positive
+ if (is_derived) continue;
+
+ intersects ||= this.current.has(source);
+ differs ||= !this.current.has(source);
+ }
+ }
+
+ if (intersects && differs) {
+ this.#blockers.add(batch);
+ } else {
+ for (const [source, previous] of batch.previous) {
+ if (!batch_values.has(source)) {
+ batch_values.set(source, previous);
+ }
}
}
}
@@ -1065,7 +1151,7 @@ export function fork(fn) {
batch.is_fork = false;
// apply changes and update write versions so deriveds see the change
- for (var [source, value] of batch.current) {
+ for (var [source, [value]] of batch.current) {
source.v = value;
source.wv = increment_write_version();
}
diff --git a/packages/svelte/src/internal/client/reactivity/deriveds.js b/packages/svelte/src/internal/client/reactivity/deriveds.js
index 7b932a8356..5da0df0670 100644
--- a/packages/svelte/src/internal/client/reactivity/deriveds.js
+++ b/packages/svelte/src/internal/client/reactivity/deriveds.js
@@ -396,7 +396,7 @@ export function update_derived(derived) {
// change, `derived.equals` may incorrectly return `true`
if (!current_batch?.is_fork || derived.deps === null) {
derived.v = value;
- current_batch?.capture(derived, old_value);
+ current_batch?.capture(derived, old_value, true);
// deriveds without dependencies should never be recomputed
if (derived.deps === null) {
diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js
new file mode 100644
index 0000000000..9d97b736be
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/_config.js
@@ -0,0 +1,29 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ await tick();
+ const [a, b, resolve] = target.querySelectorAll('button');
+
+ a.click();
+ await tick();
+ b.click();
+ await tick();
+ resolve.click();
+ await tick();
+ resolve.click();
+ await tick();
+
+ assert.htmlEqual(
+ target.innerHTML,
+ `
+
+
+
+ hi
+ 1
+ `
+ );
+ }
+});
diff --git a/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte
new file mode 100644
index 0000000000..f0d3bdc809
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-derived-indirect/main.svelte
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+{#if a_b}hi{/if}
+{await push(a_b)}
diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js
deleted file mode 100644
index d03f9ad09d..0000000000
--- a/packages/svelte/tests/runtime-runes/samples/async-each-overlap/_config.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import { tick } from 'svelte';
-import { test } from '../../test';
-
-export default test({
- skip: true,
- async test({ assert, target }) {
- const [add, shift] = target.querySelectorAll('button');
-
- add.click();
- await tick();
- add.click();
- await tick();
- add.click();
- await tick();
-
- // TODO pending count / number of pushes is off
-
- assert.htmlEqual(
- target.innerHTML,
- `
-
-
-
`);
+
+ // if we don't skip over the never-resolving promise in the `else` block, we will never update
+ b.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `