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, `

0

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

0

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

0

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

0

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

1

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

2

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

3

`); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-state-eager/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-state-eager/main.svelte new file mode 100644 index 000000000000..c9168b3984c4 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-state-eager/main.svelte @@ -0,0 +1,20 @@ + + + + + + +

{await push(count)}

+ + {#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.