From 9b5fb3f430d437ac94f752da87cff6d35dc9555c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 17 Oct 2025 06:56:04 -0400 Subject: [PATCH 1/8] fix: error if `each` block has `key` but no `as` clause (#16966) --- .changeset/thirty-rules-dance.md | 5 +++++ .../docs/98-reference/.generated/compile-errors.md | 8 +++++++- packages/svelte/messages/compile-errors/template.md | 6 +++++- packages/svelte/src/compiler/errors.js | 13 +++++++++++-- .../compiler/phases/2-analyze/visitors/EachBlock.js | 4 ++++ .../_config.js | 2 +- .../_config.js | 2 +- .../samples/each-key-without-as/_config.js | 8 ++++++++ .../samples/each-key-without-as/main.svelte | 7 +++++++ 9 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 .changeset/thirty-rules-dance.md create mode 100644 packages/svelte/tests/compiler-errors/samples/each-key-without-as/_config.js create mode 100644 packages/svelte/tests/compiler-errors/samples/each-key-without-as/main.svelte diff --git a/.changeset/thirty-rules-dance.md b/.changeset/thirty-rules-dance.md new file mode 100644 index 0000000000..7fcf8d63d0 --- /dev/null +++ b/.changeset/thirty-rules-dance.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: error if `each` block has `key` but no `as` clause 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/messages/compile-errors/template.md b/packages/svelte/messages/compile-errors/template.md index dc26a02767..ac95bfe4a7 100644 --- a/packages/svelte/messages/compile-errors/template.md +++ b/packages/svelte/messages/compile-errors/template.md @@ -126,7 +126,7 @@ ## 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: @@ -179,6 +179,10 @@ The same applies to components: > `%type%` name cannot be empty +## each_key_without_as + +> An `{#each ...}` block without an `as` clause cannot have a key + ## element_invalid_closing_tag > `` attempted to close an element that was not open diff --git a/packages/svelte/src/compiler/errors.js b/packages/svelte/src/compiler/errors.js index 44fc641ee5..5e3968215f 100644 --- a/packages/svelte/src/compiler/errors.js +++ b/packages/svelte/src/compiler/errors.js @@ -986,13 +986,13 @@ export function const_tag_invalid_placement(node) { } /** - * The `{@const %name% = ...}` declaration is not available in this snippet + * The `{@const %name% = ...}` declaration is not available in this snippet * @param {null | number | NodeLike} node * @param {string} name * @returns {never} */ export function const_tag_invalid_reference(node, name) { - e(node, 'const_tag_invalid_reference', `The \`{@const ${name} = ...}\` declaration is not available in this snippet \nhttps://svelte.dev/e/const_tag_invalid_reference`); + e(node, 'const_tag_invalid_reference', `The \`{@const ${name} = ...}\` declaration is not available in this snippet\nhttps://svelte.dev/e/const_tag_invalid_reference`); } /** @@ -1023,6 +1023,15 @@ export function directive_missing_name(node, type) { e(node, 'directive_missing_name', `\`${type}\` name cannot be empty\nhttps://svelte.dev/e/directive_missing_name`); } +/** + * An `{#each ...}` block without an `as` clause cannot have a key + * @param {null | number | NodeLike} node + * @returns {never} + */ +export function each_key_without_as(node) { + e(node, 'each_key_without_as', `An \`{#each ...}\` block without an \`as\` clause cannot have a key\nhttps://svelte.dev/e/each_key_without_as`); +} + /** * `` attempted to close an element that was not open * @param {null | number | NodeLike} node diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/EachBlock.js index e6a83921b1..d3eb58053e 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/EachBlock.js @@ -28,6 +28,10 @@ export function EachBlock(node, context) { node.key.type !== 'Identifier' || !node.index || node.key.name !== node.index; } + if (node.metadata.keyed && !node.context) { + e.each_key_without_as(node); + } + // evaluate expression in parent scope context.visit(node.expression, { ...context.state, 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} From ee093e4c86a2db0f2ce636d86856fbe0e5540070 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 17 Oct 2025 13:12:31 -0400 Subject: [PATCH 2/8] fix: preserve `` state while focused diff --git a/packages/svelte/src/internal/client/dom/elements/bindings/select.js b/packages/svelte/src/internal/client/dom/elements/bindings/select.js index e39fb865cd..46e8f524f8 100644 --- a/packages/svelte/src/internal/client/dom/elements/bindings/select.js +++ b/packages/svelte/src/internal/client/dom/elements/bindings/select.js @@ -3,6 +3,7 @@ import { listen_to_event_and_reset_event } from './shared.js'; import { is } from '../../../proxy.js'; import { is_array } from '../../../../shared/utils.js'; import * as w from '../../../warnings.js'; +import { Batch, current_batch, previous_batch } from '../../../reactivity/batch.js'; /** * Selects the correct option(s) (depending on whether this is a multiple select) @@ -83,6 +84,7 @@ export function init_select(select) { * @returns {void} */ export function bind_select_value(select, get, set = get) { + var batches = new WeakSet(); var mounting = true; listen_to_event_and_reset_event(select, 'change', (is_reset) => { @@ -102,11 +104,30 @@ export function bind_select_value(select, get, set = get) { } set(value); + + if (current_batch !== null) { + batches.add(current_batch); + } }); // Needs to be an effect, not a render_effect, so that in case of each loops the logic runs after the each block has updated effect(() => { var value = get(); + + if (select === document.activeElement) { + // we need both, because in non-async mode, render effects run before previous_batch is set + var batch = /** @type {Batch} */ (previous_batch ?? current_batch); + + // Don't update the ... + //

{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/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} +
From 53f2693b3b501c0bc0b8c129fa3e6a5d70903050 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 17 Oct 2025 22:59:50 -0400 Subject: [PATCH 3/8] feat: `$state.eager(value)` (#16849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP implement `$effect.pending(...)` * feat: `$state.eager(value)` (#16926) * runtime-first approach * revert these * type safety, lint * fix: better input cursor restoration for `bind:value` (#16925) If cursor was at end and new input is longer, move cursor to new end No test because not possible to reproduce using our test setup. Follow-up to #14649, helps with #16577 * Version Packages (#16920) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * docs: await no longer need pending (#16900) * docs: link to custom renderer issue in Svelte Native discussion (#16896) * fix code block (#16937) Updated code block syntax from Svelte to JavaScript for clarity. * fix: unset context on stale promises (#16935) * fix: unset context on stale promises When a stale promise is rejected in `async_derived`, and the promise eventually resolves, `d.resolve` will be noop and `d.promise.then(handler, ...)` will never run. That in turns means any restored context (via `(await save(..))()`) will never be unset. We have to handle this case and unset the context to prevent errors such as false-positive state mutation errors * fix: unset context on stale promises (slightly different approach) (#16936) * slightly different approach to #16935 * move unset_context call * get rid of logs --------- Co-authored-by: Rich Harris * fix: svg `radialGradient` `fr` attribute missing in types (#16943) * fix(svg radialGradient): fr attribute missing in types * chore: add changeset * Version Packages (#16940) * Version Packages * Update packages/svelte/CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Rich Harris * chore: simplify `batch.apply()` (#16945) * chore: simplify `batch.apply()` * belt and braces * note to self * unused * fix: don't rerun async effects unnecessarily (#16944) Since #16866, when an async effect runs multiple times, we rebase older batches and rerun those effects. This can have unintended consequences: In a case where an async effect only depends on a single source, and that single source was updated in a later batch, we know that we don't need to / should not rerun the older batch. This PR makes it so: We collect all the sources of older batches that are not part of the current batch that just committed, and then only mark those async effects as dirty which depend on one of those other sources. Fixes the bug I noticed while working on #16935 * fix: ensure map iteration order is correct (#16947) quick follow-up to #16944 Resetting a map entry does not change its position in the map when iterating. We need to make sure that reset makes that batch jump "to the front" for the "reject all stale batches" logic below. Edge case for which I can't come up with a test case but it _is_ a possibility. * feat: add `createContext` utility for type-safe context (#16948) * feat: add `createContext` utility for type-safe context * regenerate * Version Packages (#16946) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * chore: Remove annoying sync-async warning (#16949) * fix * use `$state.eager(value)` instead of `$effect.pending(value)` --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Hyunbin Seo <47051820+hyunbinseo@users.noreply.github.com> Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> Co-authored-by: Rich Harris Co-authored-by: Hannes Rüger Co-authored-by: Elliott Johnson * decouple from boundaries * use queue_micro_task * add test * fix * changeset * revert * tidy up * update docs * Update packages/svelte/src/internal/client/reactivity/batch.js Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> * minor tweak --------- Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Hyunbin Seo <47051820+hyunbinseo@users.noreply.github.com> Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> Co-authored-by: Hannes Rüger Co-authored-by: Elliott Johnson --- .changeset/shy-boats-protect.md | 5 ++ documentation/docs/02-runes/02-$state.md | 15 +++++ packages/svelte/src/ambient.d.ts | 12 ++++ .../2-analyze/visitors/CallExpression.js | 7 ++ .../client/visitors/CallExpression.js | 6 ++ .../server/visitors/CallExpression.js | 4 ++ packages/svelte/src/internal/client/index.js | 2 +- .../src/internal/client/reactivity/batch.js | 64 ++++++++++++++++++- packages/svelte/src/utils.js | 1 + .../samples/async-state-eager/_config.js | 36 +++++++++++ .../samples/async-state-eager/main.svelte | 20 ++++++ packages/svelte/types/index.d.ts | 12 ++++ 12 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 .changeset/shy-boats-protect.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-state-eager/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-state-eager/main.svelte diff --git a/.changeset/shy-boats-protect.md b/.changeset/shy-boats-protect.md new file mode 100644 index 0000000000..7efa8ebb31 --- /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 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/packages/svelte/src/ambient.d.ts b/packages/svelte/src/ambient.d.ts index 1f1b0e7b5e..823dbde9a4 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 53a89125a2..76d9cecd9a 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 fcc385c2ba..bf9a09bb74 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 41d3202ce9..d53b631aa5 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 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/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index fd2a6d9f5d..b35e16a409 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(); @@ -684,6 +685,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 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/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/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. From bd2d3db6d0d7a931c2e84c38a5c537e30dda1dbe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Oct 2025 23:02:55 -0400 Subject: [PATCH 4/8] Version Packages (#16970) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/quiet-weeks-camp.md | 5 ----- .changeset/selfish-pets-teach.md | 5 ----- .changeset/shy-boats-protect.md | 5 ----- .changeset/thirty-rules-dance.md | 5 ----- packages/svelte/CHANGELOG.md | 14 ++++++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 7 files changed, 16 insertions(+), 22 deletions(-) delete mode 100644 .changeset/quiet-weeks-camp.md delete mode 100644 .changeset/selfish-pets-teach.md delete mode 100644 .changeset/shy-boats-protect.md delete mode 100644 .changeset/thirty-rules-dance.md diff --git a/.changeset/quiet-weeks-camp.md b/.changeset/quiet-weeks-camp.md deleted file mode 100644 index 9e04031535..0000000000 --- a/.changeset/quiet-weeks-camp.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: preserve `` state while focused ([#16958](https://github.com/sveltejs/svelte/pull/16958)) + +- chore: run boundary async effects in the context of the current batch ([#16968](https://github.com/sveltejs/svelte/pull/16968)) + +- fix: error if `each` block has `key` but no `as` clause ([#16966](https://github.com/sveltejs/svelte/pull/16966)) + ## 5.40.2 ### Patch Changes diff --git a/packages/svelte/package.json b/packages/svelte/package.json index d99ddb502d..6d8463a52a 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.40.2", + "version": "5.41.0", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index 021668f1e6..e88bf0d0ea 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.0'; export const PUBLIC_VERSION = '5'; From c8ef54098584587777993a361cdb68f086433ca9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sun, 19 Oct 2025 14:53:49 -0400 Subject: [PATCH 5/8] fix: improve `each_key_without_as` error (#16983) --- .changeset/three-wasps-work.md | 5 +++++ .../src/compiler/phases/2-analyze/visitors/EachBlock.js | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/three-wasps-work.md diff --git a/.changeset/three-wasps-work.md b/.changeset/three-wasps-work.md new file mode 100644 index 0000000000..318d8668a4 --- /dev/null +++ b/.changeset/three-wasps-work.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: improve `each_key_without_as` error diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/EachBlock.js index d3eb58053e..81a9c1e2d1 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/EachBlock.js @@ -1,3 +1,4 @@ +/** @import { Expression } from 'estree' */ /** @import { AST, Binding } from '#compiler' */ /** @import { Context } from '../types' */ /** @import { Scope } from '../../scope' */ @@ -29,7 +30,7 @@ export function EachBlock(node, context) { } if (node.metadata.keyed && !node.context) { - e.each_key_without_as(node); + e.each_key_without_as(/** @type {Expression} */ (node.key)); } // evaluate expression in parent scope From 9a488d6b25d41d594ee73582e56aff123eec49e9 Mon Sep 17 00:00:00 2001 From: ComputerGuy <63362464+Ocean-OS@users.noreply.github.com> Date: Sun, 19 Oct 2025 18:35:38 -0700 Subject: [PATCH 6/8] fix: place `let:` declarations before `{@const}` declarations (#16985) * fix: place `let:` declarations before `{@const}` declarations * lint * fix --- .changeset/brown-insects-burn.md | 5 +++ .../3-transform/client/transform-client.js | 1 + .../phases/3-transform/client/types.d.ts | 2 + .../3-transform/client/visitors/Fragment.js | 3 +- .../client/visitors/LetDirective.js | 38 ++++++++++--------- .../client/visitors/RegularElement.js | 2 +- .../client/visitors/SlotElement.js | 2 +- .../client/visitors/SvelteFragment.js | 2 +- .../client/visitors/shared/component.js | 4 +- .../let-directive-and-const-tag/_config.js | 5 +++ .../component.svelte | 1 + .../let-directive-and-const-tag/main.svelte | 7 ++++ 12 files changed, 49 insertions(+), 23 deletions(-) create mode 100644 .changeset/brown-insects-burn.md create mode 100644 packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag/_config.js create mode 100644 packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag/component.svelte create mode 100644 packages/svelte/tests/runtime-legacy/samples/let-directive-and-const-tag/main.svelte diff --git a/.changeset/brown-insects-burn.md b/.changeset/brown-insects-burn.md new file mode 100644 index 0000000000..ceccc3fd9b --- /dev/null +++ b/.changeset/brown-insects-burn.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: place `let:` declarations before `{@const}` declarations 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 2629379f63..cd3fb7a64d 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 @@ -172,6 +172,7 @@ export function client_component(analysis, options) { // these are set inside the `Fragment` visitor, and cannot be used until then init: /** @type {any} */ (null), consts: /** @type {any} */ (null), + let_directives: /** @type {any} */ (null), update: /** @type {any} */ (null), after_update: /** @type {any} */ (null), template: /** @type {any} */ (null), 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 932d353671..b9a8691a6b 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 @@ -54,6 +54,8 @@ export interface ComponentClientTransformState extends ClientTransformState { readonly after_update: Statement[]; /** Transformed `{@const }` declarations */ readonly consts: Statement[]; + /** Transformed `let:` directives */ + readonly let_directives: Statement[]; /** Memoized expressions */ readonly memoizer: Memoizer; /** The HTML template string */ diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js index 85d8e3caff..bee4fcaab4 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js @@ -63,6 +63,7 @@ export function Fragment(node, context) { ...context.state, init: [], consts: [], + let_directives: [], update: [], after_update: [], memoizer: new Memoizer(), @@ -150,7 +151,7 @@ export function Fragment(node, context) { } } - body.push(...state.consts); + body.push(...state.let_directives, ...state.consts); if (has_await) { body.push(b.if(b.call('$.aborted'), b.return())); diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/LetDirective.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/LetDirective.js index f33febeeb2..c134b4e1e7 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/LetDirective.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/LetDirective.js @@ -21,22 +21,24 @@ export function LetDirective(node, context) { }; } - return b.const( - name, - b.call( - '$.derived', - b.thunk( - b.block([ - b.let( - /** @type {Expression} */ (node.expression).type === 'ObjectExpression' - ? // @ts-expect-error types don't match, but it can't contain spread elements and the structure is otherwise fine - b.object_pattern(node.expression.properties) - : // @ts-expect-error types don't match, but it can't contain spread elements and the structure is otherwise fine - b.array_pattern(node.expression.elements), - b.member(b.id('$$slotProps'), node.name) - ), - b.return(b.object(bindings.map((binding) => b.init(binding.node.name, binding.node)))) - ]) + context.state.let_directives.push( + b.const( + name, + b.call( + '$.derived', + b.thunk( + b.block([ + b.let( + /** @type {Expression} */ (node.expression).type === 'ObjectExpression' + ? // @ts-expect-error types don't match, but it can't contain spread elements and the structure is otherwise fine + b.object_pattern(node.expression.properties) + : // @ts-expect-error types don't match, but it can't contain spread elements and the structure is otherwise fine + b.array_pattern(node.expression.elements), + b.member(b.id('$$slotProps'), node.name) + ), + b.return(b.object(bindings.map((binding) => b.init(binding.node.name, binding.node)))) + ]) + ) ) ) ); @@ -46,6 +48,8 @@ export function LetDirective(node, context) { read: (node) => b.call('$.get', node) }; - return b.const(name, create_derived(context.state, b.member(b.id('$$slotProps'), node.name))); + context.state.let_directives.push( + b.const(name, create_derived(context.state, b.member(b.id('$$slotProps'), node.name))) + ); } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js index e35b7cbe5a..ab119e8f80 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/RegularElement.js @@ -106,7 +106,7 @@ export function RegularElement(node, context) { case 'LetDirective': // visit let directives before everything else, to set state - lets.push(/** @type {ExpressionStatement} */ (context.visit(attribute))); + context.visit(attribute, { ...context.state, let_directives: lets }); break; case 'OnDirective': diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js index a5c0974738..b87a13253b 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SlotElement.js @@ -49,7 +49,7 @@ export function SlotElement(node, context) { } } } else if (attribute.type === 'LetDirective') { - lets.push(/** @type {ExpressionStatement} */ (context.visit(attribute))); + context.visit(attribute, { ...context.state, let_directives: lets }); } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteFragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteFragment.js index 65cc170ce5..e3b46a4eef 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteFragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SvelteFragment.js @@ -9,7 +9,7 @@ export function SvelteFragment(node, context) { for (const attribute of node.attributes) { if (attribute.type === 'LetDirective') { - context.state.init.push(/** @type {ExpressionStatement} */ (context.visit(attribute))); + context.visit(attribute); } } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js index 5c8ce897f4..5ca941fd70 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/component.js @@ -101,7 +101,7 @@ export function build_component(node, component_name, context) { if (slot_scope_applies_to_itself) { for (const attribute of node.attributes) { if (attribute.type === 'LetDirective') { - lets.push(/** @type {ExpressionStatement} */ (context.visit(attribute))); + context.visit(attribute, { ...context.state, let_directives: lets }); } } } @@ -109,7 +109,7 @@ export function build_component(node, component_name, context) { for (const attribute of node.attributes) { if (attribute.type === 'LetDirective') { if (!slot_scope_applies_to_itself) { - lets.push(/** @type {ExpressionStatement} */ (context.visit(attribute, states.default))); + context.visit(attribute, { ...states.default, let_directives: lets }); } } else if (attribute.type === 'OnDirective') { if (!attribute.expression) { 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 From b8627e511ddfa9310e6952989c7f7de8ef035b0e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 20 Oct 2025 09:27:28 -0400 Subject: [PATCH 7/8] chore: centralise branch management (#16977) * WIP * WIP * WIP * WIP * WIP * fix hydration * simplify * all tests passing * key blocks * snippets * fix * tidy up * WIP await * tidy up * fix * neaten up * unused * tweak * elements * changeset * fix * preserve newer batches * add comment * add comment * no longer necessary apparently? * move legacy logic to key block --- .changeset/yellow-shrimps-provide.md | 5 + .../3-transform/client/transform-client.js | 4 +- .../3-transform/client/visitors/Fragment.js | 6 +- .../src/internal/client/dom/blocks/await.js | 208 ++++++------------ .../internal/client/dom/blocks/boundary.js | 26 +-- .../internal/client/dom/blocks/branches.js | 185 ++++++++++++++++ .../src/internal/client/dom/blocks/if.js | 135 +++--------- .../src/internal/client/dom/blocks/key.js | 70 +----- .../src/internal/client/dom/blocks/snippet.js | 28 +-- .../client/dom/blocks/svelte-component.js | 70 +----- .../client/dom/blocks/svelte-element.js | 79 +++---- .../src/internal/client/reactivity/async.js | 32 ++- .../src/internal/client/reactivity/effects.js | 22 +- .../samples/await-pending-destroy/_config.js | 3 +- 14 files changed, 409 insertions(+), 464 deletions(-) create mode 100644 .changeset/yellow-shrimps-provide.md create mode 100644 packages/svelte/src/internal/client/dom/blocks/branches.js diff --git a/.changeset/yellow-shrimps-provide.md b/.changeset/yellow-shrimps-provide.md new file mode 100644 index 0000000000..a29385660a --- /dev/null +++ b/.changeset/yellow-shrimps-provide.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +chore: centralise branch management 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 cd3fb7a64d..0dd4ae03b9 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 @@ -385,7 +385,9 @@ export function client_component(analysis, options) { .../** @type {ESTree.Statement[]} */ (template.body) ]); - component_block.body.push(b.stmt(b.call(`$.async_body`, b.arrow([], body, true)))); + component_block.body.push( + b.stmt(b.call(`$.async_body`, b.id('$$anchor'), b.arrow([b.id('$$anchor')], body, true))) + ); } else { component_block.body.push( ...state.instance_level_snippets, diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js index bee4fcaab4..8d6a2fac88 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/Fragment.js @@ -178,7 +178,11 @@ export function Fragment(node, context) { } if (has_await) { - return b.block([b.stmt(b.call('$.async_body', b.arrow([], b.block(body), true)))]); + return b.block([ + b.stmt( + b.call('$.async_body', b.id('$$anchor'), b.arrow([b.id('$$anchor')], b.block(body), true)) + ) + ]); } else { return b.block(body); } diff --git a/packages/svelte/src/internal/client/dom/blocks/await.js b/packages/svelte/src/internal/client/dom/blocks/await.js index e7917fbd9e..bac01e4c33 100644 --- a/packages/svelte/src/internal/client/dom/blocks/await.js +++ b/packages/svelte/src/internal/client/dom/blocks/await.js @@ -1,12 +1,9 @@ -/** @import { Effect, Source, TemplateNode } from '#client' */ -import { DEV } from 'esm-env'; +/** @import { Source, TemplateNode } from '#client' */ import { is_promise } from '../../../shared/utils.js'; -import { block, branch, pause_effect, resume_effect } from '../../reactivity/effects.js'; +import { block } from '../../reactivity/effects.js'; import { internal_set, mutable_source, source } from '../../reactivity/sources.js'; -import { set_active_effect, set_active_reaction } from '../../runtime.js'; import { hydrate_next, - hydrate_node, hydrating, skip_nodes, set_hydrate_node, @@ -14,15 +11,10 @@ import { } from '../hydration.js'; import { queue_micro_task } from '../task.js'; import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js'; -import { - component_context, - dev_stack, - is_runes, - set_component_context, - set_dev_current_component_function, - set_dev_stack -} from '../../context.js'; +import { is_runes } from '../../context.js'; import { flushSync, is_flushing_sync } from '../../reactivity/batch.js'; +import { BranchManager } from './branches.js'; +import { capture, unset_context } from '../../reactivity/async.js'; const PENDING = 0; const THEN = 1; @@ -33,7 +25,7 @@ const CATCH = 2; /** * @template V * @param {TemplateNode} node - * @param {(() => Promise)} get_input + * @param {(() => any)} get_input * @param {null | ((anchor: Node) => void)} pending_fn * @param {null | ((anchor: Node, value: Source) => void)} then_fn * @param {null | ((anchor: Node, error: unknown) => void)} catch_fn @@ -44,149 +36,94 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) { hydrate_next(); } - var anchor = node; var runes = is_runes(); - var active_component_context = component_context; - - /** @type {any} */ - var component_function = DEV ? component_context?.function : null; - var dev_original_stack = DEV ? dev_stack : null; - - /** @type {V | Promise | typeof UNINITIALIZED} */ - var input = UNINITIALIZED; - - /** @type {Effect | null} */ - var pending_effect; - - /** @type {Effect | null} */ - var then_effect; - - /** @type {Effect | null} */ - var catch_effect; - - var input_source = runes - ? source(/** @type {V} */ (undefined)) - : mutable_source(/** @type {V} */ (undefined), false, false); - var error_source = runes ? source(undefined) : mutable_source(undefined, false, false); - var resolved = false; - /** - * @param {AwaitState} state - * @param {boolean} restore - */ - function update(state, restore) { - resolved = true; - - if (restore) { - set_active_effect(effect); - set_active_reaction(effect); // TODO do we need both? - set_component_context(active_component_context); - if (DEV) { - set_dev_current_component_function(component_function); - set_dev_stack(dev_original_stack); - } - } - - try { - if (state === PENDING && pending_fn) { - if (pending_effect) resume_effect(pending_effect); - else pending_effect = branch(() => pending_fn(anchor)); - } - - if (state === THEN && then_fn) { - if (then_effect) resume_effect(then_effect); - else then_effect = branch(() => then_fn(anchor, input_source)); - } - - if (state === CATCH && catch_fn) { - if (catch_effect) resume_effect(catch_effect); - else catch_effect = branch(() => catch_fn(anchor, error_source)); - } - - if (state !== PENDING && pending_effect) { - pause_effect(pending_effect, () => (pending_effect = null)); - } - - if (state !== THEN && then_effect) { - pause_effect(then_effect, () => (then_effect = null)); - } - - if (state !== CATCH && catch_effect) { - pause_effect(catch_effect, () => (catch_effect = null)); - } - } finally { - if (restore) { - if (DEV) { - set_dev_current_component_function(null); - set_dev_stack(null); - } - set_component_context(null); - set_active_reaction(null); - set_active_effect(null); + var v = /** @type {V} */ (UNINITIALIZED); + var value = runes ? source(v) : mutable_source(v, false, false); + var error = runes ? source(v) : mutable_source(v, false, false); - // without this, the DOM does not update until two ticks after the promise - // resolves, which is unexpected behaviour (and somewhat irksome to test) - if (!is_flushing_sync) flushSync(); - } - } - } + var branches = new BranchManager(node); - var effect = block(() => { - if (input === (input = get_input())) return; + block(() => { + var input = get_input(); + var destroyed = false; /** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */ - // @ts-ignore coercing `anchor` to a `Comment` causes TypeScript and Prettier to fight - let mismatch = hydrating && is_promise(input) === (anchor.data === HYDRATION_START_ELSE); + // @ts-ignore coercing `node` to a `Comment` causes TypeScript and Prettier to fight + let mismatch = hydrating && is_promise(input) === (node.data === HYDRATION_START_ELSE); if (mismatch) { // Hydration mismatch: remove everything inside the anchor and start fresh - anchor = skip_nodes(); - - set_hydrate_node(anchor); + set_hydrate_node(skip_nodes()); set_hydrating(false); - mismatch = true; } if (is_promise(input)) { - var promise = input; + var restore = capture(); + var resolved = false; + + /** + * @param {() => void} fn + */ + const resolve = (fn) => { + if (destroyed) return; + + resolved = true; + restore(); + + if (hydrating) { + // `restore()` could set `hydrating` to `true`, which we very much + // don't want — we want to restore everything _except_ this + set_hydrating(false); + } - resolved = false; + try { + fn(); + } finally { + unset_context(); - promise.then( - (value) => { - if (promise !== input) return; - // we technically could use `set` here since it's on the next microtick - // but let's use internal_set for consistency and just to be safe - internal_set(input_source, value); - update(THEN, true); + // without this, the DOM does not update until two ticks after the promise + // resolves, which is unexpected behaviour (and somewhat irksome to test) + if (!is_flushing_sync) flushSync(); + } + }; + + input.then( + (v) => { + resolve(() => { + internal_set(value, v); + branches.ensure(THEN, then_fn && ((target) => then_fn(target, value))); + }); }, - (error) => { - if (promise !== input) return; - // we technically could use `set` here since it's on the next microtick - // but let's use internal_set for consistency and just to be safe - internal_set(error_source, error); - update(CATCH, true); - if (!catch_fn) { - // Rethrow the error if no catch block exists - throw error_source.v; - } + (e) => { + resolve(() => { + internal_set(error, e); + branches.ensure(THEN, catch_fn && ((target) => catch_fn(target, error))); + + if (!catch_fn) { + // Rethrow the error if no catch block exists + throw error.v; + } + }); } ); if (hydrating) { - if (pending_fn) { - pending_effect = branch(() => pending_fn(anchor)); - } + branches.ensure(PENDING, pending_fn); } else { // Wait a microtask before checking if we should show the pending state as - // the promise might have resolved by the next microtask. + // the promise might have resolved by then queue_micro_task(() => { - if (!resolved) update(PENDING, true); + if (!resolved) { + resolve(() => { + branches.ensure(PENDING, pending_fn); + }); + } }); } } else { - internal_set(input_source, input); - update(THEN, false); + internal_set(value, input); + branches.ensure(THEN, then_fn && ((target) => then_fn(target, value))); } if (mismatch) { @@ -194,11 +131,8 @@ export function await_block(node, get_input, pending_fn, then_fn, catch_fn) { set_hydrating(true); } - // Set the input to something else, in order to disable the promise callbacks - return () => (input = UNINITIALIZED); + return () => { + destroyed = true; + }; }); - - if (hydrating) { - anchor = hydrate_node; - } } diff --git a/packages/svelte/src/internal/client/dom/blocks/boundary.js b/packages/svelte/src/internal/client/dom/blocks/boundary.js index ca6437e384..026ffb36fc 100644 --- a/packages/svelte/src/internal/client/dom/blocks/boundary.js +++ b/packages/svelte/src/internal/client/dom/blocks/boundary.js @@ -8,7 +8,13 @@ import { import { HYDRATION_START_ELSE } from '../../../../constants.js'; import { component_context, set_component_context } from '../../context.js'; import { handle_error, invoke_error_boundary } from '../../error-handling.js'; -import { block, branch, destroy_effect, pause_effect } from '../../reactivity/effects.js'; +import { + block, + branch, + destroy_effect, + move_effect, + pause_effect +} from '../../reactivity/effects.js'; import { active_effect, active_reaction, @@ -425,24 +431,6 @@ export class Boundary { } } -/** - * - * @param {Effect} effect - * @param {DocumentFragment} fragment - */ -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; - } -} - export function get_boundary() { return /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b); } diff --git a/packages/svelte/src/internal/client/dom/blocks/branches.js b/packages/svelte/src/internal/client/dom/blocks/branches.js new file mode 100644 index 0000000000..827f9f44fa --- /dev/null +++ b/packages/svelte/src/internal/client/dom/blocks/branches.js @@ -0,0 +1,185 @@ +/** @import { Effect, TemplateNode } from '#client' */ +import { is_runes } from '../../context.js'; +import { Batch, current_batch } from '../../reactivity/batch.js'; +import { + branch, + destroy_effect, + move_effect, + pause_effect, + resume_effect +} from '../../reactivity/effects.js'; +import { set_should_intro, should_intro } from '../../render.js'; +import { hydrate_node, hydrating } from '../hydration.js'; +import { create_text, should_defer_append } from '../operations.js'; + +/** + * @typedef {{ effect: Effect, fragment: DocumentFragment }} Branch + */ + +/** + * @template Key + */ +export class BranchManager { + /** @type {TemplateNode} */ + anchor; + + /** @type {Map} */ + #batches = new Map(); + + /** @type {Map} */ + #onscreen = new Map(); + + /** @type {Map} */ + #offscreen = new Map(); + + /** + * Whether to pause (i.e. outro) on change, or destroy immediately. + * This is necessary for `` + */ + #transition = true; + + /** + * @param {TemplateNode} anchor + * @param {boolean} transition + */ + constructor(anchor, transition = true) { + this.anchor = anchor; + this.#transition = transition; + } + + #commit = () => { + var batch = /** @type {Batch} */ (current_batch); + + // if this batch was made obsolete, bail + if (!this.#batches.has(batch)) return; + + var key = /** @type {Key} */ (this.#batches.get(batch)); + + var onscreen = this.#onscreen.get(key); + + if (onscreen) { + // effect is already in the DOM — abort any current outro + resume_effect(onscreen); + } else { + // effect is currently offscreen. put it in the DOM + var offscreen = this.#offscreen.get(key); + + if (offscreen) { + this.#onscreen.set(key, offscreen.effect); + this.#offscreen.delete(key); + + // remove the anchor... + /** @type {TemplateNode} */ (offscreen.fragment.lastChild).remove(); + + // ...and append the fragment + this.anchor.before(offscreen.fragment); + onscreen = offscreen.effect; + } + } + + for (const [b, k] of this.#batches) { + this.#batches.delete(b); + + if (b === batch) { + // keep values for newer batches + break; + } + + const offscreen = this.#offscreen.get(k); + + if (offscreen) { + // for older batches, destroy offscreen effects + // as they will never be committed + destroy_effect(offscreen.effect); + this.#offscreen.delete(k); + } + } + + // outro/destroy all onscreen effects... + for (const [k, effect] of this.#onscreen) { + // ...except the one that was just committed + if (k === key) continue; + + const on_destroy = () => { + const keys = Array.from(this.#batches.values()); + + if (keys.includes(k)) { + // keep the effect offscreen, as another batch will need it + var fragment = document.createDocumentFragment(); + move_effect(effect, fragment); + + fragment.append(create_text()); // TODO can we avoid this? + + this.#offscreen.set(k, { effect, fragment }); + } else { + destroy_effect(effect); + } + + this.#onscreen.delete(k); + }; + + if (this.#transition || !onscreen) { + pause_effect(effect, on_destroy, false); + } else { + on_destroy(); + } + } + }; + + /** + * + * @param {any} key + * @param {null | ((target: TemplateNode) => void)} fn + */ + ensure(key, fn) { + var batch = /** @type {Batch} */ (current_batch); + var defer = should_defer_append(); + + if (fn && !this.#onscreen.has(key) && !this.#offscreen.has(key)) { + if (defer) { + var fragment = document.createDocumentFragment(); + var target = create_text(); + + fragment.append(target); + + this.#offscreen.set(key, { + effect: branch(() => fn(target)), + fragment + }); + } else { + this.#onscreen.set( + key, + branch(() => fn(this.anchor)) + ); + } + } + + this.#batches.set(batch, key); + + if (defer) { + for (const [k, effect] of this.#onscreen) { + if (k === key) { + batch.skipped_effects.delete(effect); + } else { + batch.skipped_effects.add(effect); + } + } + + for (const [k, branch] of this.#offscreen) { + if (k === key) { + batch.skipped_effects.delete(branch.effect); + } else { + batch.skipped_effects.add(branch.effect); + } + } + + batch.add_callback(this.#commit); + } else { + if (hydrating) { + this.anchor = hydrate_node; + } + + this.#commit(); + } + } +} diff --git a/packages/svelte/src/internal/client/dom/blocks/if.js b/packages/svelte/src/internal/client/dom/blocks/if.js index 6349ab8399..7fa5ca464d 100644 --- a/packages/svelte/src/internal/client/dom/blocks/if.js +++ b/packages/svelte/src/internal/client/dom/blocks/if.js @@ -1,19 +1,16 @@ -/** @import { Effect, TemplateNode } from '#client' */ -/** @import { Batch } from '../../reactivity/batch.js'; */ +/** @import { TemplateNode } from '#client' */ import { EFFECT_TRANSPARENT } from '#client/constants'; import { hydrate_next, - hydrate_node, hydrating, read_hydration_instruction, skip_nodes, set_hydrate_node, set_hydrating } from '../hydration.js'; -import { block, branch, pause_effect, resume_effect } from '../../reactivity/effects.js'; -import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js'; -import { create_text, should_defer_append } from '../operations.js'; -import { current_batch } from '../../reactivity/batch.js'; +import { block } from '../../reactivity/effects.js'; +import { HYDRATION_START_ELSE } from '../../../../constants.js'; +import { BranchManager } from './branches.js'; // TODO reinstate https://github.com/sveltejs/svelte/pull/15250 @@ -28,122 +25,46 @@ export function if_block(node, fn, elseif = false) { hydrate_next(); } - var anchor = node; - - /** @type {Effect | null} */ - var consequent_effect = null; - - /** @type {Effect | null} */ - var alternate_effect = null; - - /** @type {typeof UNINITIALIZED | boolean | null} */ - var condition = UNINITIALIZED; - + var branches = new BranchManager(node); var flags = elseif ? EFFECT_TRANSPARENT : 0; - var has_branch = false; - - const set_branch = (/** @type {(anchor: Node) => void} */ fn, flag = true) => { - has_branch = true; - update_branch(flag, fn); - }; - - /** @type {DocumentFragment | null} */ - var offscreen_fragment = null; - - function commit() { - if (offscreen_fragment !== null) { - // remove the anchor - /** @type {Text} */ (offscreen_fragment.lastChild).remove(); - - anchor.before(offscreen_fragment); - offscreen_fragment = null; - } - - var active = condition ? consequent_effect : alternate_effect; - var inactive = condition ? alternate_effect : consequent_effect; - - if (active) { - resume_effect(active); - } - - if (inactive) { - pause_effect(inactive, () => { - if (condition) { - alternate_effect = null; - } else { - consequent_effect = null; - } - }); - } - } - - const update_branch = ( - /** @type {boolean | null} */ new_condition, - /** @type {null | ((anchor: Node) => void)} */ fn - ) => { - if (condition === (condition = new_condition)) return; - - /** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */ - let mismatch = false; - + /** + * @param {boolean} condition, + * @param {null | ((anchor: Node) => void)} fn + */ + function update_branch(condition, fn) { if (hydrating) { - const is_else = read_hydration_instruction(anchor) === HYDRATION_START_ELSE; + const is_else = read_hydration_instruction(node) === HYDRATION_START_ELSE; - if (!!condition === is_else) { + if (condition === is_else) { // Hydration mismatch: remove everything inside the anchor and start fresh. // This could happen with `{#if browser}...{/if}`, for example - anchor = skip_nodes(); + var anchor = skip_nodes(); set_hydrate_node(anchor); - set_hydrating(false); - mismatch = true; - } - } + branches.anchor = anchor; - var defer = should_defer_append(); - var target = anchor; - - if (defer) { - offscreen_fragment = document.createDocumentFragment(); - offscreen_fragment.append((target = create_text())); - } + set_hydrating(false); + branches.ensure(condition, fn); + set_hydrating(true); - if (condition) { - consequent_effect ??= fn && branch(() => fn(target)); - } else { - alternate_effect ??= fn && branch(() => fn(target)); + return; + } } - if (defer) { - var batch = /** @type {Batch} */ (current_batch); - - var active = condition ? consequent_effect : alternate_effect; - var inactive = condition ? alternate_effect : consequent_effect; - - if (active) batch.skipped_effects.delete(active); - if (inactive) batch.skipped_effects.add(inactive); + branches.ensure(condition, fn); + } - batch.add_callback(commit); - } else { - commit(); - } + block(() => { + var has_branch = false; - if (mismatch) { - // continue in hydration mode - set_hydrating(true); - } - }; + fn((fn, flag = true) => { + has_branch = true; + update_branch(flag, fn); + }); - block(() => { - has_branch = false; - fn(set_branch); if (!has_branch) { - update_branch(null, null); + update_branch(false, null); } }, flags); - - if (hydrating) { - anchor = hydrate_node; - } } diff --git a/packages/svelte/src/internal/client/dom/blocks/key.js b/packages/svelte/src/internal/client/dom/blocks/key.js index 5e3c42019f..849b1c2447 100644 --- a/packages/svelte/src/internal/client/dom/blocks/key.js +++ b/packages/svelte/src/internal/client/dom/blocks/key.js @@ -1,12 +1,8 @@ -/** @import { Effect, TemplateNode } from '#client' */ -/** @import { Batch } from '../../reactivity/batch.js'; */ -import { UNINITIALIZED } from '../../../../constants.js'; -import { block, branch, pause_effect } from '../../reactivity/effects.js'; -import { not_equal, safe_not_equal } from '../../reactivity/equality.js'; +/** @import { TemplateNode } from '#client' */ import { is_runes } from '../../context.js'; -import { hydrate_next, hydrate_node, hydrating } from '../hydration.js'; -import { create_text, should_defer_append } from '../operations.js'; -import { current_batch } from '../../reactivity/batch.js'; +import { block } from '../../reactivity/effects.js'; +import { hydrate_next, hydrating } from '../hydration.js'; +import { BranchManager } from './branches.js'; /** * @template V @@ -20,60 +16,18 @@ export function key(node, get_key, render_fn) { hydrate_next(); } - var anchor = node; + var branches = new BranchManager(node); - /** @type {V | typeof UNINITIALIZED} */ - var key = UNINITIALIZED; - - /** @type {Effect} */ - var effect; - - /** @type {Effect} */ - var pending_effect; - - /** @type {DocumentFragment | null} */ - var offscreen_fragment = null; - - var changed = is_runes() ? not_equal : safe_not_equal; - - function commit() { - if (effect) { - pause_effect(effect); - } - - if (offscreen_fragment !== null) { - // remove the anchor - /** @type {Text} */ (offscreen_fragment.lastChild).remove(); - - anchor.before(offscreen_fragment); - offscreen_fragment = null; - } - - effect = pending_effect; - } + var legacy = !is_runes(); block(() => { - if (changed(key, (key = get_key()))) { - var target = anchor; - - var defer = should_defer_append(); - - if (defer) { - offscreen_fragment = document.createDocumentFragment(); - offscreen_fragment.append((target = create_text())); - } - - pending_effect = branch(() => render_fn(target)); + var key = get_key(); - if (defer) { - /** @type {Batch} */ (current_batch).add_callback(commit); - } else { - commit(); - } + // key blocks in Svelte <5 had stupid semantics + if (legacy && key !== null && typeof key === 'object') { + key = /** @type {V} */ ({}); } - }); - if (hydrating) { - anchor = hydrate_node; - } + branches.ensure(key, render_fn); + }); } diff --git a/packages/svelte/src/internal/client/dom/blocks/snippet.js b/packages/svelte/src/internal/client/dom/blocks/snippet.js index 32d88d4c60..0c4948aca0 100644 --- a/packages/svelte/src/internal/client/dom/blocks/snippet.js +++ b/packages/svelte/src/internal/client/dom/blocks/snippet.js @@ -1,8 +1,8 @@ /** @import { Snippet } from 'svelte' */ -/** @import { Effect, TemplateNode } from '#client' */ +/** @import { TemplateNode } from '#client' */ /** @import { Getters } from '#shared' */ import { EFFECT_TRANSPARENT, ELEMENT_NODE } from '#client/constants'; -import { branch, block, destroy_effect, teardown } from '../../reactivity/effects.js'; +import { block, teardown } from '../../reactivity/effects.js'; import { dev_current_component_function, set_dev_current_component_function @@ -14,8 +14,8 @@ import * as w from '../../warnings.js'; import * as e from '../../errors.js'; import { DEV } from 'esm-env'; import { get_first_child, get_next_sibling } from '../operations.js'; -import { noop } from '../../../shared/utils.js'; import { prevent_snippet_stringification } from '../../../shared/validate.js'; +import { BranchManager } from './branches.js'; /** * @template {(node: TemplateNode, ...args: any[]) => void} SnippetFn @@ -25,33 +25,17 @@ import { prevent_snippet_stringification } from '../../../shared/validate.js'; * @returns {void} */ export function snippet(node, get_snippet, ...args) { - var anchor = node; - - /** @type {SnippetFn | null | undefined} */ - // @ts-ignore - var snippet = noop; - - /** @type {Effect | null} */ - var snippet_effect; + var branches = new BranchManager(node); block(() => { - if (snippet === (snippet = get_snippet())) return; - - if (snippet_effect) { - destroy_effect(snippet_effect); - snippet_effect = null; - } + const snippet = get_snippet() ?? null; if (DEV && snippet == null) { e.invalid_snippet(); } - snippet_effect = branch(() => /** @type {SnippetFn} */ (snippet)(anchor, ...args)); + branches.ensure(snippet, snippet && ((anchor) => snippet(anchor, ...args))); }, EFFECT_TRANSPARENT); - - if (hydrating) { - anchor = hydrate_node; - } } /** diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-component.js b/packages/svelte/src/internal/client/dom/blocks/svelte-component.js index 2697722b39..134e57e627 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-component.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-component.js @@ -1,10 +1,8 @@ -/** @import { TemplateNode, Dom, Effect } from '#client' */ -/** @import { Batch } from '../../reactivity/batch.js'; */ +/** @import { TemplateNode, Dom } from '#client' */ import { EFFECT_TRANSPARENT } from '#client/constants'; -import { block, branch, pause_effect } from '../../reactivity/effects.js'; -import { current_batch } from '../../reactivity/batch.js'; -import { hydrate_next, hydrate_node, hydrating } from '../hydration.js'; -import { create_text, should_defer_append } from '../operations.js'; +import { block } from '../../reactivity/effects.js'; +import { hydrate_next, hydrating } from '../hydration.js'; +import { BranchManager } from './branches.js'; /** * @template P @@ -19,64 +17,10 @@ export function component(node, get_component, render_fn) { hydrate_next(); } - var anchor = node; - - /** @type {C} */ - var component; - - /** @type {Effect | null} */ - var effect; - - /** @type {DocumentFragment | null} */ - var offscreen_fragment = null; - - /** @type {Effect | null} */ - var pending_effect = null; - - function commit() { - if (effect) { - pause_effect(effect); - effect = null; - } - - if (offscreen_fragment) { - // remove the anchor - /** @type {Text} */ (offscreen_fragment.lastChild).remove(); - - anchor.before(offscreen_fragment); - offscreen_fragment = null; - } - - effect = pending_effect; - pending_effect = null; - } + var branches = new BranchManager(node); block(() => { - if (component === (component = get_component())) return; - - var defer = should_defer_append(); - - if (component) { - var target = anchor; - - if (defer) { - offscreen_fragment = document.createDocumentFragment(); - offscreen_fragment.append((target = create_text())); - if (effect) { - /** @type {Batch} */ (current_batch).skipped_effects.add(effect); - } - } - pending_effect = branch(() => render_fn(target, component)); - } - - if (defer) { - /** @type {Batch} */ (current_batch).add_callback(commit); - } else { - commit(); - } + var component = get_component() ?? null; + branches.ensure(component, component && ((target) => render_fn(target, component))); }, EFFECT_TRANSPARENT); - - if (hydrating) { - anchor = hydrate_node; - } } diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-element.js b/packages/svelte/src/internal/client/dom/blocks/svelte-element.js index 231a3621b1..6533ff8921 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-element.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-element.js @@ -8,13 +8,7 @@ import { set_hydrating } from '../hydration.js'; import { create_text, get_first_child } from '../operations.js'; -import { - block, - branch, - destroy_effect, - pause_effect, - resume_effect -} from '../../reactivity/effects.js'; +import { block, teardown } from '../../reactivity/effects.js'; import { set_should_intro } from '../../render.js'; import { current_each_item, set_current_each_item } from './each.js'; import { active_effect } from '../../runtime.js'; @@ -23,6 +17,7 @@ import { DEV } from 'esm-env'; import { EFFECT_TRANSPARENT, ELEMENT_NODE } from '#client/constants'; import { assign_nodes } from '../template.js'; import { is_raw_text_element } from '../../../../utils.js'; +import { BranchManager } from './branches.js'; /** * @param {Comment | Element} node @@ -42,12 +37,6 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio var filename = DEV && location && component_context?.function[FILENAME]; - /** @type {string | null} */ - var tag; - - /** @type {string | null} */ - var current_tag; - /** @type {null | Element} */ var element = null; @@ -58,9 +47,6 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio var anchor = /** @type {TemplateNode} */ (hydrating ? hydrate_node : node); - /** @type {Effect | null} */ - var effect; - /** * The keyed `{#each ...}` item block, if any, that this element is inside. * We track this so we can set it when changing the element, allowing any @@ -68,36 +54,24 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio */ var each_item_block = current_each_item; + var branches = new BranchManager(anchor, false); + block(() => { const next_tag = get_tag() || null; var ns = get_namespace ? get_namespace() : is_svg || next_tag === 'svg' ? NAMESPACE_SVG : null; - // Assumption: Noone changes the namespace but not the tag (what would that even mean?) - if (next_tag === tag) return; - - // See explanation of `each_item_block` above - var previous_each_item = current_each_item; - set_current_each_item(each_item_block); - - if (effect) { - if (next_tag === null) { - // start outro - pause_effect(effect, () => { - effect = null; - current_tag = null; - }); - } else if (next_tag === current_tag) { - // same tag as is currently rendered — abort outro - resume_effect(effect); - } else { - // tag is changing — destroy immediately, render contents without intro transitions - destroy_effect(effect); - set_should_intro(false); - } + if (next_tag === null) { + branches.ensure(null, null); + set_should_intro(true); + return; } - if (next_tag && next_tag !== current_tag) { - effect = branch(() => { + branches.ensure(next_tag, (anchor) => { + // See explanation of `each_item_block` above + var previous_each_item = current_each_item; + set_current_each_item(each_item_block); + + if (next_tag) { element = hydrating ? /** @type {Element} */ (element) : ns @@ -149,16 +123,31 @@ export function element(node, get_tag, is_svg, render_fn, get_namespace, locatio /** @type {Effect} */ (active_effect).nodes_end = element; anchor.before(element); - }); - } + } + + set_current_each_item(previous_each_item); + + if (hydrating) { + set_hydrate_node(anchor); + } + }); - tag = next_tag; - if (tag) current_tag = tag; + // revert to the default state after the effect has been created set_should_intro(true); - set_current_each_item(previous_each_item); + return () => { + if (next_tag) { + // if we're in this callback because we're re-running the effect, + // disable intros (unless no element is currently displayed) + set_should_intro(false); + } + }; }, EFFECT_TRANSPARENT); + teardown(() => { + set_should_intro(true); + }); + if (was_hydrating) { set_hydrating(true); set_hydrate_node(anchor); diff --git a/packages/svelte/src/internal/client/reactivity/async.js b/packages/svelte/src/internal/client/reactivity/async.js index 45c78ff926..1d408744fd 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); var pending = boundary.is_pending(); @@ -218,7 +234,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/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/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; From 7d9ea8ea99cfaba36dddb5e7c9994e9c44214b8c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 09:30:51 -0400 Subject: [PATCH 8/8] Version Packages (#16984) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/brown-insects-burn.md | 5 ----- .changeset/three-wasps-work.md | 5 ----- .changeset/yellow-shrimps-provide.md | 5 ----- packages/svelte/CHANGELOG.md | 10 ++++++++++ packages/svelte/package.json | 2 +- packages/svelte/src/version.js | 2 +- 6 files changed, 12 insertions(+), 17 deletions(-) delete mode 100644 .changeset/brown-insects-burn.md delete mode 100644 .changeset/three-wasps-work.md delete mode 100644 .changeset/yellow-shrimps-provide.md diff --git a/.changeset/brown-insects-burn.md b/.changeset/brown-insects-burn.md deleted file mode 100644 index ceccc3fd9b..0000000000 --- a/.changeset/brown-insects-burn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: place `let:` declarations before `{@const}` declarations diff --git a/.changeset/three-wasps-work.md b/.changeset/three-wasps-work.md deleted file mode 100644 index 318d8668a4..0000000000 --- a/.changeset/three-wasps-work.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -fix: improve `each_key_without_as` error diff --git a/.changeset/yellow-shrimps-provide.md b/.changeset/yellow-shrimps-provide.md deleted file mode 100644 index a29385660a..0000000000 --- a/.changeset/yellow-shrimps-provide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'svelte': patch ---- - -chore: centralise branch management diff --git a/packages/svelte/CHANGELOG.md b/packages/svelte/CHANGELOG.md index 81529104d9..4db131114d 100644 --- a/packages/svelte/CHANGELOG.md +++ b/packages/svelte/CHANGELOG.md @@ -1,5 +1,15 @@ # 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 diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 6d8463a52a..b5a20ce82a 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.41.0", + "version": "5.41.1", "type": "module", "types": "./types/index.d.ts", "engines": { diff --git a/packages/svelte/src/version.js b/packages/svelte/src/version.js index e88bf0d0ea..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.41.0'; +export const VERSION = '5.41.1'; export const PUBLIC_VERSION = '5';