diff --git a/.changeset/selfish-pets-teach.md b/.changeset/selfish-pets-teach.md deleted file mode 100644 index d78fea8f9f..0000000000 --- a/.changeset/selfish-pets-teach.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: run boundary async effects in the context of the current batch diff --git a/documentation/docs/02-runes/02-$state.md b/documentation/docs/02-runes/02-$state.md index 741e24fde0..6fbf3b8895 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/documentation/docs/98-reference/.generated/compile-errors.md b/documentation/docs/98-reference/.generated/compile-errors.md index b9c44163c9..c5703c636b 100644 --- a/documentation/docs/98-reference/.generated/compile-errors.md +++ b/documentation/docs/98-reference/.generated/compile-errors.md @@ -199,7 +199,7 @@ Cyclical dependency detected: %cycle% ### const_tag_invalid_reference ``` -The `{@const %name% = ...}` declaration is not available in this snippet +The `{@const %name% = ...}` declaration is not available in this snippet ``` The following is an error: @@ -453,6 +453,12 @@ This turned out to be buggy and unpredictable, particularly when working with de {/each} ``` +### each_key_without_as + +``` +An `{#each ...}` block without an `as` clause cannot have a key +``` + ### effect_invalid_placement ``` diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 5c8a5e5b58..4db131114d 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,29 @@ # svelte +## 5.41.1 + +### Patch Changes + +- fix: place `let:` declarations before `{@const}` declarations ([#16985](https://github.com/sveltejs/svelte/pull/16985)) + +- fix: improve `each_key_without_as` error ([#16983](https://github.com/sveltejs/svelte/pull/16983)) + +- chore: centralise branch management ([#16977](https://github.com/sveltejs/svelte/pull/16977)) + +## 5.41.0 + +### Minor Changes + +- feat: add `$state.eager(value)` rune ([#16849](https://github.com/sveltejs/svelte/pull/16849)) + +### Patch Changes + +- fix: preserve ` if it is focused. We can get here if, for example, + // an update is deferred because of async work depending on the select: + // + // + //

{await find(selected)}

+ if (batches.has(batch)) { + return; + } + } + select_option(select, value, mounting); // Mounting and value undefined -> take selection from dom diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 3c5409bcfe..471eed299d 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/async.js b/packages/svelte/src/internal/client/reactivity/async.js index a223d1b5be..24ff4793ea 100644 --- a/packages/svelte/src/internal/client/reactivity/async.js +++ b/packages/svelte/src/internal/client/reactivity/async.js @@ -1,8 +1,13 @@ -/** @import { Effect, Value } from '#client' */ - +/** @import { Effect, TemplateNode, Value } from '#client' */ import { DESTROYED } from '#client/constants'; import { DEV } from 'esm-env'; -import { component_context, is_runes, set_component_context } from '../context.js'; +import { + component_context, + dev_stack, + is_runes, + set_component_context, + set_dev_stack +} from '../context.js'; import { get_boundary } from '../dom/blocks/boundary.js'; import { invoke_error_boundary } from '../error-handling.js'; import { @@ -28,6 +33,7 @@ import { set_hydrating, skip_nodes } from '../dom/hydration.js'; +import { create_text } from '../dom/operations.js'; /** * @@ -80,7 +86,7 @@ export function flatten(sync, async, fn) { * some asynchronous work has happened (so that e.g. `await a + b` * causes `b` to be registered as a dependency). */ -function capture() { +export function capture() { var previous_effect = active_effect; var previous_reaction = active_reaction; var previous_component_context = component_context; @@ -92,6 +98,10 @@ function capture() { var previous_hydrate_node = hydrate_node; } + if (DEV) { + var previous_dev_stack = dev_stack; + } + return function restore() { set_active_effect(previous_effect); set_active_reaction(previous_reaction); @@ -105,6 +115,7 @@ function capture() { if (DEV) { set_from_async_derived(null); + set_dev_stack(previous_dev_stack); } }; } @@ -193,13 +204,18 @@ export function unset_context() { set_active_effect(null); set_active_reaction(null); set_component_context(null); - if (DEV) set_from_async_derived(null); + + if (DEV) { + set_from_async_derived(null); + set_dev_stack(null); + } } /** - * @param {() => Promise} fn + * @param {TemplateNode} anchor + * @param {(target: TemplateNode) => Promise} fn */ -export async function async_body(fn) { +export async function async_body(anchor, fn) { var boundary = get_boundary(); var batch = /** @type {Batch} */ (current_batch); @@ -219,7 +235,7 @@ export async function async_body(fn) { } try { - var promise = fn(); + var promise = fn(anchor); } finally { if (next_hydrate_node) { set_hydrate_node(next_hydrate_node); diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index b74ce0ba9b..a9973b465b 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -18,6 +18,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, @@ -28,8 +29,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'; /** * @typedef {{ @@ -726,6 +727,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/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 2c9e4db911..bfbb95a8db 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -553,15 +553,16 @@ export function unlink_effect(effect) { * A paused effect does not update, and the DOM subtree becomes inert. * @param {Effect} effect * @param {() => void} [callback] + * @param {boolean} [destroy] */ -export function pause_effect(effect, callback) { +export function pause_effect(effect, callback, destroy = true) { /** @type {TransitionManager[]} */ var transitions = []; pause_children(effect, transitions, true); run_out_transitions(transitions, () => { - destroy_effect(effect); + if (destroy) destroy_effect(effect); if (callback) callback(); }); } @@ -662,3 +663,20 @@ function resume_children(effect, local) { export function aborted(effect = /** @type {Effect} */ (active_effect)) { return (effect.f & DESTROYED) !== 0; } + +/** + * @param {Effect} effect + * @param {DocumentFragment} fragment + */ +export function move_effect(effect, fragment) { + var node = effect.nodes_start; + var end = effect.nodes_end; + + while (node !== null) { + /** @type {TemplateNode | null} */ + var next = node === end ? null : /** @type {TemplateNode} */ (get_next_sibling(node)); + + fragment.append(node); + node = next; + } +} diff --git a/packages/svelte/src/utils.js b/packages/svelte/src/utils.js index f8a7e8d46d..a54a421418 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/src/version.js b/packages/svelte/src/version.js index 021668f1e6..e33d22d4c4 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.40.2'; +export const VERSION = '5.41.1'; export const PUBLIC_VERSION = '5'; diff --git a/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-1/_config.js b/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-1/_config.js index 7424278180..be9d5a483f 100644 --- a/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-1/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-1/_config.js @@ -4,7 +4,7 @@ export default test({ async: true, error: { code: 'const_tag_invalid_reference', - message: 'The `{@const foo = ...}` declaration is not available in this snippet ', + message: 'The `{@const foo = ...}` declaration is not available in this snippet', position: [376, 379] } }); diff --git a/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-2/_config.js b/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-2/_config.js index 7ff71a61f9..5132bd93b7 100644 --- a/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-2/_config.js +++ b/packages/svelte/tests/compiler-errors/samples/const-tag-snippet-invalid-reference-2/_config.js @@ -4,7 +4,7 @@ export default test({ async: true, error: { code: 'const_tag_invalid_reference', - message: 'The `{@const foo = ...}` declaration is not available in this snippet ', + message: 'The `{@const foo = ...}` declaration is not available in this snippet', position: [298, 301] } }); diff --git a/packages/svelte/tests/compiler-errors/samples/each-key-without-as/_config.js b/packages/svelte/tests/compiler-errors/samples/each-key-without-as/_config.js new file mode 100644 index 0000000000..923fe0c0ac --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/each-key-without-as/_config.js @@ -0,0 +1,8 @@ +import { test } from '../../test'; + +export default test({ + error: { + code: 'each_key_without_as', + message: 'An `{#each ...}` block without an `as` clause cannot have a key' + } +}); diff --git a/packages/svelte/tests/compiler-errors/samples/each-key-without-as/main.svelte b/packages/svelte/tests/compiler-errors/samples/each-key-without-as/main.svelte new file mode 100644 index 0000000000..794740de8f --- /dev/null +++ b/packages/svelte/tests/compiler-errors/samples/each-key-without-as/main.svelte @@ -0,0 +1,7 @@ + + +{#each items, i (items[i].id)} +

{items[i].id}

+{/each} diff --git a/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag/_config.js b/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag/_config.js new file mode 100644 index 0000000000..2f7a7863a7 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: 'foo' +}); diff --git a/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag/component.svelte b/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag/component.svelte new file mode 100644 index 0000000000..44e700bdd4 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag/component.svelte @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag/main.svelte b/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag/main.svelte new file mode 100644 index 0000000000..abca25bab2 --- /dev/null +++ b/packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag/main.svelte @@ -0,0 +1,7 @@ + + + {@const thing = data} + {thing} + \ No newline at end of file diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/_config.js index b0772ad3c0..76a2032c7a 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/_config.js @@ -2,8 +2,9 @@ import { tick } from 'svelte'; import { test } from '../../test'; export default test({ - async test({ assert, target, instance }) { - instance.shift(); + async test({ assert, target }) { + const [shift] = target.querySelectorAll('button'); + shift.click(); await tick(); const [input] = target.querySelectorAll('input'); @@ -13,7 +14,7 @@ export default test({ input.dispatchEvent(new InputEvent('input', { bubbles: true })); await tick(); - assert.htmlEqual(target.innerHTML, `

0

`); + assert.htmlEqual(target.innerHTML, `

0

`); assert.equal(input.value, '1'); input.focus(); @@ -21,17 +22,17 @@ export default test({ input.dispatchEvent(new InputEvent('input', { bubbles: true })); await tick(); - assert.htmlEqual(target.innerHTML, `

0

`); + assert.htmlEqual(target.innerHTML, `

0

`); assert.equal(input.value, '2'); - instance.shift(); + shift.click(); await tick(); - assert.htmlEqual(target.innerHTML, `

1

`); + assert.htmlEqual(target.innerHTML, `

1

`); assert.equal(input.value, '2'); - instance.shift(); + shift.click(); await tick(); - assert.htmlEqual(target.innerHTML, `

2

`); + assert.htmlEqual(target.innerHTML, `

2

`); assert.equal(input.value, '2'); } }); diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte index 2fc898e654..e2f01a66c8 100644 --- a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-2/main.svelte @@ -1,22 +1,23 @@ + + - +

{await push(count)}

{#snippet pending()} diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/_config.js b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/_config.js new file mode 100644 index 0000000000..7fddca0d58 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/_config.js @@ -0,0 +1,82 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [shift] = target.querySelectorAll('button'); + shift.click(); + await tick(); + + const [select] = target.querySelectorAll('select'); + + select.focus(); + select.value = 'three'; + select.dispatchEvent(new InputEvent('change', { bubbles: true })); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

two

+ ` + ); + assert.equal(select.value, 'three'); + + select.focus(); + select.value = 'one'; + select.dispatchEvent(new InputEvent('change', { bubbles: true })); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + +

two

+ ` + ); + assert.equal(select.value, 'one'); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

three

+ ` + ); + assert.equal(select.value, 'one'); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ` + + +

one

+ ` + ); + assert.equal(select.value, 'one'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/main.svelte new file mode 100644 index 0000000000..566ea60ec5 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-binding-update-while-focused-3/main.svelte @@ -0,0 +1,31 @@ + + + + + + + +

{await push(selected)}

+ + {#snippet pending()} +

loading...

+ {/snippet} +
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 0000000000..f84228ec14 --- /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 0000000000..c9168b3984 --- /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/tests/runtime-runes/samples/await-pending-destroy/_config.js b/packages/svelte/tests/runtime-runes/samples/await-pending-destroy/_config.js index 1725cd8f6f..9ef598de6c 100644 --- a/packages/svelte/tests/runtime-runes/samples/await-pending-destroy/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/await-pending-destroy/_config.js @@ -1,3 +1,4 @@ +import { tick } from 'svelte'; import { test } from '../../test'; /** @@ -77,7 +78,7 @@ export default test({ const { promise, reject } = promiseWithResolver(); component.promise = promise; // wait for rendering - await Promise.resolve(); + await tick(); // remove the promise component.promise = null; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index f01edd947f..d260b738c3 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.