From 21811b1f7376bfff024adb3940ee6a88b1b9b7b3 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 25 Sep 2025 18:08:10 -0400 Subject: [PATCH 01/12] WIP implement `$effect.pending(...)` --- .../compiler/phases/1-parse/utils/create.js | 3 +- .../2-analyze/visitors/CallExpression.js | 12 +++++++ .../phases/3-transform/client/types.d.ts | 2 ++ .../client/visitors/CallExpression.js | 11 ++++++ .../3-transform/client/visitors/Fragment.js | 5 +++ .../client/visitors/RegularElement.js | 7 +++- .../server/visitors/CallExpression.js | 2 +- .../svelte/src/compiler/types/template.d.ts | 1 + .../internal/client/dom/blocks/boundary.js | 34 +++++++++++++++++-- 9 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/svelte/src/compiler/phases/1-parse/utils/create.js b/packages/svelte/src/compiler/phases/1-parse/utils/create.js index 2fba918f20ee..3bbe70194404 100644 --- a/packages/svelte/src/compiler/phases/1-parse/utils/create.js +++ b/packages/svelte/src/compiler/phases/1-parse/utils/create.js @@ -11,7 +11,8 @@ export function create_fragment(transparent = false) { metadata: { transparent, dynamic: false, - has_await: false + has_await: false, + effect_pending_expressions: [] } }; } 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..bdd31522ea4f 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/CallExpression.js @@ -165,10 +165,22 @@ export function CallExpression(node, context) { break; case '$effect.pending': + if (node.arguments.length > 1) { + e.rune_invalid_arguments_length(node, rune, 'zero or one arguments'); + } + if (context.state.expression) { context.state.expression.has_state = true; } + if (node.arguments[0]) { + const fragment = /** @type {AST.Fragment} */ (context.state.fragment); + + fragment.metadata.effect_pending_expressions.push( + /** @type {Expression} */ (node.arguments[0]) + ); + } + break; case '$inspect': diff --git a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts index 932d35367162..248158992278 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts +++ b/packages/svelte/src/compiler/phases/3-transform/client/types.d.ts @@ -24,6 +24,8 @@ export interface ClientTransformState extends TransformState { /** `true` if we're transforming the contents of ` * - * * ``` @@ -124,7 +136,7 @@ declare namespace $state { * To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snapshot`: * * Example: - * ```ts + * ```svelte * + + + +{count} | {x} diff --git a/packages/svelte/tests/runtime-runes/samples/create-context/Child.svelte b/packages/svelte/tests/runtime-runes/samples/create-context/Child.svelte new file mode 100644 index 000000000000..3e39d5043eff --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/create-context/Child.svelte @@ -0,0 +1,7 @@ + + +

{message}

diff --git a/packages/svelte/tests/runtime-runes/samples/create-context/_config.js b/packages/svelte/tests/runtime-runes/samples/create-context/_config.js new file mode 100644 index 000000000000..4ae28e68bd05 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/create-context/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: `

hello

` +}); diff --git a/packages/svelte/tests/runtime-runes/samples/create-context/main.svelte b/packages/svelte/tests/runtime-runes/samples/create-context/main.svelte new file mode 100644 index 000000000000..8d3c50ba5539 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/create-context/main.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 6dc6629faad5..2bc64d22a36d 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -448,6 +448,10 @@ declare module 'svelte' { }): Snippet; /** Anything except a function */ type NotFunction = T extends Function ? never : T; + /** + * Returns a `[get, set]` pair of functions for working with context in a type-safe way. + * */ + export function createContext(): [() => T, (context: T) => T]; /** * Retrieves the context that belongs to the closest parent component with the specified `key`. * Must be called during component initialisation. @@ -3181,12 +3185,24 @@ 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. * * Example: - * ```ts + * ```svelte * * - * * ``` @@ -3210,7 +3226,7 @@ declare namespace $state { * To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snapshot`: * * Example: - * ```ts + * ```svelte * + + + + + +

{await push(count)}

+ + {#snippet pending()}{/snippet} +
From 05c919661003da11bb74f53570d12d633b17d5b4 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 15 Oct 2025 06:45:24 -0400 Subject: [PATCH 06/12] fix --- .../src/internal/client/reactivity/batch.js | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index a78a5199aa3c..6129f1e66944 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -719,30 +719,45 @@ function eager_flush() { } /** + * Implementation of `$state.eager(fn())` * @template T * @param {() => T} fn * @returns {T} */ export function eager(fn) { - const previous_batch_values = batch_values; + get((version ??= source(0))); + + var initial = true; + var value = /** @type {T} */ (undefined); + + 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; + } - try { - get((version ??= source(0))); + return; + } - inspect_effect(() => { - fn(); + // 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_flushing) { + eager_flushing = true; + queue_micro_task(eager_flush); + } + }); - if (!eager_flushing) { - eager_flushing = true; - queue_micro_task(eager_flush); - } - }); + initial = false; - batch_values = null; - return fn(); - } finally { - batch_values = previous_batch_values; - } + return value; } /** From 6949e92582263f61df453a38ad37a849516bfb07 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 15 Oct 2025 09:35:36 -0400 Subject: [PATCH 07/12] changeset --- .changeset/shy-boats-protect.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/shy-boats-protect.md 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 From 8ac29e412c9e75923dd58bc178cce8caa7bee3de Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 15 Oct 2025 09:37:03 -0400 Subject: [PATCH 08/12] revert --- packages/svelte/src/internal/client/runtime.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index 9eaa5cdd0189..a146659bf688 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -653,7 +653,7 @@ export function get(signal) { if (is_derived) { derived = /** @type {Derived} */ (signal); - var derived_value = derived.v; + var value = derived.v; // if the derived is dirty and has reactions, or depends on the values that just changed, re-execute // (a derived can be maybe_dirty due to the effect destroy removing its last reaction) @@ -661,12 +661,12 @@ export function get(signal) { ((derived.f & CLEAN) === 0 && derived.reactions !== null) || depends_on_old_values(derived) ) { - derived_value = execute_derived(derived); + value = execute_derived(derived); } - old_values.set(derived, derived_value); + old_values.set(derived, value); - return derived_value; + return value; } } else if (is_derived) { derived = /** @type {Derived} */ (signal); From 5bfc484039a3827021e3b336f0fd6d0103d3fdfb Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 15 Oct 2025 09:37:26 -0400 Subject: [PATCH 09/12] tidy up --- .../runtime-runes/samples/async-state-eager/_config.js | 6 ------ 1 file changed, 6 deletions(-) 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 index d1a8eedcd5a6..f84228ec14dc 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-state-eager/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-state-eager/_config.js @@ -2,12 +2,6 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - mode: ['client'], - - compileOptions: { - dev: true - }, - async test({ assert, target }) { const [count, shift] = target.querySelectorAll('button'); From f8c7b171484c7fb317c960cfce5f17c2c6801219 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 15 Oct 2025 09:53:21 -0400 Subject: [PATCH 10/12] update docs --- documentation/docs/02-runes/02-$state.md | 15 +++++++++++++++ packages/svelte/src/ambient.d.ts | 4 ++-- packages/svelte/types/index.d.ts | 4 ++-- 3 files changed, 19 insertions(+), 4 deletions(-) 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 64cdcc93b2d2..823dbde9a4dc 100644 --- a/packages/svelte/src/ambient.d.ts +++ b/packages/svelte/src/ambient.d.ts @@ -101,8 +101,8 @@ declare namespace $state { * * ```svelte * * ``` */ diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index c05a79f039d9..d260b738c3cf 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -3199,8 +3199,8 @@ declare namespace $state { * * ```svelte * * ``` */ From f66778776e378d0ea72b64e75cf7d9de16e29668 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 17 Oct 2025 21:19:24 -0400 Subject: [PATCH 11/12] Update packages/svelte/src/internal/client/reactivity/batch.js Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --- .../src/internal/client/reactivity/batch.js | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 6129f1e66944..b64ab95d9633 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -703,18 +703,18 @@ export function schedule_effect(signal) { queued_root_effects.push(effect); } -/** @type {Source} */ -let version; - -let eager_flushing = false; +/** @type {Source[] | null} */ +let eager_flushing = null; function eager_flush() { try { flushSync(() => { - update(version); + /** @type {Source[]} */ (eager_flushing).forEach((version) => { + update(version); + }); }); } finally { - eager_flushing = false; + eager_flushing = null; } } @@ -725,11 +725,12 @@ function eager_flush() { * @returns {T} */ export function eager(fn) { - get((version ??= source(0))); - + 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 @@ -750,9 +751,10 @@ export function eager(fn) { // `version` update. since this will recreate the effect, // we don't need to evaluate the expression here if (!eager_flushing) { - eager_flushing = true; + eager_flushing = []; queue_micro_task(eager_flush); } + eager_flushing.push(version); }); initial = false; From 2b59618ae7bd88dc401f64f4c158c4e405d94b0f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 17 Oct 2025 21:22:51 -0400 Subject: [PATCH 12/12] minor tweak --- .../src/internal/client/reactivity/batch.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index b64ab95d9633..c27f1fce0693 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -703,18 +703,18 @@ export function schedule_effect(signal) { queued_root_effects.push(effect); } -/** @type {Source[] | null} */ -let eager_flushing = null; +/** @type {Source[]} */ +let eager_versions = []; function eager_flush() { try { flushSync(() => { - /** @type {Source[]} */ (eager_flushing).forEach((version) => { + for (const version of eager_versions) { update(version); - }); + } }); } finally { - eager_flushing = null; + eager_versions = []; } } @@ -750,11 +750,11 @@ export function eager(fn) { // 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_flushing) { - eager_flushing = []; + if (eager_versions.length === 0) { queue_micro_task(eager_flush); } - eager_flushing.push(version); + + eager_versions.push(version); }); initial = false;