diff --git a/.changeset/shy-boats-protect.md b/.changeset/shy-boats-protect.md
new file mode 100644
index 000000000000..7efa8ebb313f
--- /dev/null
+++ b/.changeset/shy-boats-protect.md
@@ -0,0 +1,5 @@
+---
+'svelte': minor
+---
+
+feat: add `$state.eager(value)` rune
diff --git a/documentation/docs/02-runes/02-$state.md b/documentation/docs/02-runes/02-$state.md
index 741e24fde01e..6fbf3b88955b 100644
--- a/documentation/docs/02-runes/02-$state.md
+++ b/documentation/docs/02-runes/02-$state.md
@@ -166,6 +166,21 @@ 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`.
+## `$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).
+
+In some cases, you may want to update the UI as soon as the state changes. For example, you might want to update a navigation bar when the user clicks on a link, so that they get visual feedback while waiting for the new page to load. To do this, use `$state.eager(value)`:
+
+```svelte
+
+```
+
+Use this feature sparingly, and only to provide feedback in response to user action — in general, allowing Svelte to coordinate updates will provide a better user experience.
+
## Passing state into functions
JavaScript is a _pass-by-value_ language — when you call a function, the arguments are the _values_ rather than the _variables_. In other words:
diff --git a/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts
index 1f1b0e7b5e85..823dbde9a4dc 100644
--- a/packages/svelte/src/ambient.d.ts
+++ b/packages/svelte/src/ambient.d.ts
@@ -95,6 +95,18 @@ declare namespace $state {
: never
: never;
+ /**
+ * Returns the latest `value`, even if the rest of the UI is suspending
+ * while async work (such as data loading) completes.
+ *
+ * ```svelte
+ *
+ * ```
+ */
+ export function eager(value: T): T;
/**
* Declares state that is _not_ made deeply reactive — instead of mutating it,
* you must reassign it.
diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js
index 53a89125a28b..76d9cecd9ab1 100644
--- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js
+++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js
@@ -226,6 +226,13 @@ export function CallExpression(node, context) {
break;
}
+ case '$state.eager':
+ if (node.arguments.length !== 1) {
+ e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
+ }
+
+ break;
+
case '$state.snapshot':
if (node.arguments.length !== 1) {
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js
index fcc385c2ba6c..bf9a09bb74b1 100644
--- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js
+++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/CallExpression.js
@@ -49,6 +49,12 @@ export function CallExpression(node, context) {
return b.call('$.derived', rune === '$derived' ? b.thunk(fn) : fn);
}
+ case '$state.eager':
+ return b.call(
+ '$.eager',
+ b.thunk(/** @type {Expression} */ (context.visit(node.arguments[0])))
+ );
+
case '$state.snapshot':
return b.call(
'$.snapshot',
diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/CallExpression.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/CallExpression.js
index 41d3202ce9ea..d53b631aa5ec 100644
--- a/packages/svelte/src/compiler/phases/3-transform/server/visitors/CallExpression.js
+++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/CallExpression.js
@@ -38,6 +38,10 @@ export function CallExpression(node, context) {
return b.call('$.derived', rune === '$derived' ? b.thunk(fn) : fn);
}
+ if (rune === '$state.eager') {
+ return node.arguments[0];
+ }
+
if (rune === '$state.snapshot') {
return b.call(
'$.snapshot',
diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js
index 3c5409bcfec8..471eed299d2a 100644
--- a/packages/svelte/src/internal/client/index.js
+++ b/packages/svelte/src/internal/client/index.js
@@ -103,7 +103,7 @@ export {
save,
track_reactivity_loss
} from './reactivity/async.js';
-export { flushSync as flush } from './reactivity/batch.js';
+export { eager, flushSync as flush } from './reactivity/batch.js';
export {
async_derived,
user_derived as derived,
diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js
index 2956e7ed6afe..c27f1fce0693 100644
--- a/packages/svelte/src/internal/client/reactivity/batch.js
+++ b/packages/svelte/src/internal/client/reactivity/batch.js
@@ -17,6 +17,7 @@ import { async_mode_flag } from '../../flags/index.js';
import { deferred, define_property } from '../../shared/utils.js';
import {
active_effect,
+ get,
is_dirty,
is_updating_effect,
set_is_updating_effect,
@@ -27,8 +28,8 @@ import * as e from '../errors.js';
import { flush_tasks, queue_micro_task } from '../dom/task.js';
import { DEV } from 'esm-env';
import { invoke_error_boundary } from '../error-handling.js';
-import { old_values } from './sources.js';
-import { unlink_effect } from './effects.js';
+import { old_values, source, update } from './sources.js';
+import { inspect_effect, unlink_effect } from './effects.js';
/** @type {Set} */
const batches = new Set();
@@ -702,6 +703,65 @@ export function schedule_effect(signal) {
queued_root_effects.push(effect);
}
+/** @type {Source[]} */
+let eager_versions = [];
+
+function eager_flush() {
+ try {
+ flushSync(() => {
+ for (const version of eager_versions) {
+ update(version);
+ }
+ });
+ } finally {
+ eager_versions = [];
+ }
+}
+
+/**
+ * Implementation of `$state.eager(fn())`
+ * @template T
+ * @param {() => T} fn
+ * @returns {T}
+ */
+export function eager(fn) {
+ var version = source(0);
+ var initial = true;
+ var value = /** @type {T} */ (undefined);
+
+ get(version);
+
+ inspect_effect(() => {
+ if (initial) {
+ // the first time this runs, we create an inspect effect
+ // that will run eagerly whenever the expression changes
+ var previous_batch_values = batch_values;
+
+ try {
+ batch_values = null;
+ value = fn();
+ } finally {
+ batch_values = previous_batch_values;
+ }
+
+ return;
+ }
+
+ // the second time this effect runs, it's to schedule a
+ // `version` update. since this will recreate the effect,
+ // we don't need to evaluate the expression here
+ if (eager_versions.length === 0) {
+ queue_micro_task(eager_flush);
+ }
+
+ eager_versions.push(version);
+ });
+
+ initial = false;
+
+ return value;
+}
+
/**
* Forcibly remove all current batches, to prevent cross-talk between tests
*/
diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js
index f8a7e8d46d1c..a54a421418a3 100644
--- a/packages/svelte/src/utils.js
+++ b/packages/svelte/src/utils.js
@@ -436,6 +436,7 @@ const STATE_CREATION_RUNES = /** @type {const} */ ([
const RUNES = /** @type {const} */ ([
...STATE_CREATION_RUNES,
+ '$state.eager',
'$state.snapshot',
'$props',
'$props.id',
diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-eager/_config.js b/packages/svelte/tests/runtime-runes/samples/async-state-eager/_config.js
new file mode 100644
index 000000000000..f84228ec14dc
--- /dev/null
+++ b/packages/svelte/tests/runtime-runes/samples/async-state-eager/_config.js
@@ -0,0 +1,36 @@
+import { tick } from 'svelte';
+import { test } from '../../test';
+
+export default test({
+ async test({ assert, target }) {
+ const [count, shift] = target.querySelectorAll('button');
+
+ shift.click();
+ await tick();
+ assert.htmlEqual(target.innerHTML, `
+
+ {#snippet pending()}{/snippet}
+
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index f01edd947f45..d260b738c3cf 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -3193,6 +3193,18 @@ declare namespace $state {
: never
: never;
+ /**
+ * Returns the latest `value`, even if the rest of the UI is suspending
+ * while async work (such as data loading) completes.
+ *
+ * ```svelte
+ *
+ * ```
+ */
+ export function eager(value: T): T;
/**
* Declares state that is _not_ made deeply reactive — instead of mutating it,
* you must reassign it.