From 72185af38b2cbb4fb71646b567c9b8dfa7519c60 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 28 Oct 2025 20:14:59 +0100 Subject: [PATCH 1/2] fix: change title only after any pending work has completed We have to use an effect - not a render effect - for updating the title, and always. That way we change the title only after any pending work has completed. Fixes #17060 --- .changeset/legal-mangos-peel.md | 5 ++++ .../client/visitors/TitleElement.js | 23 ++++++++++++++---- .../internal/client/dom/blocks/svelte-head.js | 4 ++-- .../src/internal/client/reactivity/effects.js | 5 ++-- .../samples/async-head-title-1/Inner.svelte | 15 ++++++++++++ .../samples/async-head-title-1/_config.js | 24 +++++++++++++++++++ .../samples/async-head-title-1/main.svelte | 12 ++++++++++ .../samples/async-head-title-2/Inner.svelte | 13 ++++++++++ .../samples/async-head-title-2/_config.js | 23 ++++++++++++++++++ .../samples/async-head-title-2/main.svelte | 12 ++++++++++ 10 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 .changeset/legal-mangos-peel.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-head-title-1/Inner.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-head-title-1/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-head-title-1/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-head-title-2/Inner.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-head-title-2/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-head-title-2/main.svelte diff --git a/.changeset/legal-mangos-peel.md b/.changeset/legal-mangos-peel.md new file mode 100644 index 000000000000..bddad21bff37 --- /dev/null +++ b/.changeset/legal-mangos-peel.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: change title only after any pending work has completed diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/TitleElement.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/TitleElement.js index 98d7880b255d..edd8835e0033 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/TitleElement.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/TitleElement.js @@ -1,16 +1,19 @@ /** @import { AST } from '#compiler' */ /** @import { ComponentContext } from '../types' */ import * as b from '#compiler/builders'; -import { build_template_chunk } from './shared/utils.js'; +import { build_template_chunk, Memoizer } from './shared/utils.js'; /** * @param {AST.TitleElement} node * @param {ComponentContext} context */ export function TitleElement(node, context) { + const memoizer = new Memoizer(); const { has_state, value } = build_template_chunk( /** @type {any} */ (node.fragment.nodes), - context + context, + context.state, + (value, metadata) => memoizer.add(value, metadata) ); const evaluated = context.state.scope.evaluate(value); @@ -26,9 +29,21 @@ export function TitleElement(node, context) { ) ); + // Always in an $effect so it only changes the title once async work is done if (has_state) { - context.state.update.push(statement); + context.state.after_update.push( + b.stmt( + b.call( + '$.template_effect', + b.arrow(memoizer.apply(), b.block([statement])), + memoizer.sync_values(), + memoizer.async_values(), + memoizer.blockers(), + b.true + ) + ) + ); } else { - context.state.init.push(statement); + context.state.after_update.push(b.stmt(b.call('$.effect', b.thunk(b.block([statement]))))); } } diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js index 13926ccc4b83..1f589e454256 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js @@ -1,7 +1,7 @@ /** @import { TemplateNode } from '#client' */ import { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from '../hydration.js'; import { create_text, get_first_child, get_next_sibling } from '../operations.js'; -import { block } from '../../reactivity/effects.js'; +import { block, branch } from '../../reactivity/effects.js'; import { COMMENT_NODE, HEAD_EFFECT } from '#client/constants'; /** @@ -49,7 +49,7 @@ export function head(hash, render_fn) { } try { - block(() => render_fn(anchor), HEAD_EFFECT); + block(() => branch(() => render_fn(anchor)), HEAD_EFFECT); } finally { if (was_hydrating) { set_hydrating(true); diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index 4a9fce7286a5..8c4b84438c5b 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.js @@ -366,10 +366,11 @@ export function render_effect(fn, flags = 0) { * @param {Array<() => any>} sync * @param {Array<() => Promise>} async * @param {Array>} blockers + * @param {boolean} defer */ -export function template_effect(fn, sync = [], async = [], blockers = []) { +export function template_effect(fn, sync = [], async = [], blockers = [], defer = false) { flatten(blockers, sync, async, (values) => { - create_effect(RENDER_EFFECT, () => fn(...values.map(get)), true); + create_effect(defer ? EFFECT : RENDER_EFFECT, () => fn(...values.map(get)), true); }); } diff --git a/packages/svelte/tests/runtime-runes/samples/async-head-title-1/Inner.svelte b/packages/svelte/tests/runtime-runes/samples/async-head-title-1/Inner.svelte new file mode 100644 index 000000000000..089ba43607dd --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-head-title-1/Inner.svelte @@ -0,0 +1,15 @@ + + + + title + + +

{await push()}

diff --git a/packages/svelte/tests/runtime-runes/samples/async-head-title-1/_config.js b/packages/svelte/tests/runtime-runes/samples/async-head-title-1/_config.js new file mode 100644 index 000000000000..39cbf5becba9 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-head-title-1/_config.js @@ -0,0 +1,24 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [toggle, resolve] = target.querySelectorAll('button'); + toggle.click(); + await tick(); + assert.equal(window.document.title, ''); + + toggle.click(); + await tick(); + assert.equal(window.document.title, ''); + + toggle.click(); + await tick(); + assert.equal(window.document.title, ''); + + resolve.click(); + await tick(); + await tick(); + assert.equal(window.document.title, 'title'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-head-title-1/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-head-title-1/main.svelte new file mode 100644 index 000000000000..353515708785 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-head-title-1/main.svelte @@ -0,0 +1,12 @@ + + + + +{#if show} + +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/async-head-title-2/Inner.svelte b/packages/svelte/tests/runtime-runes/samples/async-head-title-2/Inner.svelte new file mode 100644 index 000000000000..b2a86562769c --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-head-title-2/Inner.svelte @@ -0,0 +1,13 @@ + + + + {await push()} + diff --git a/packages/svelte/tests/runtime-runes/samples/async-head-title-2/_config.js b/packages/svelte/tests/runtime-runes/samples/async-head-title-2/_config.js new file mode 100644 index 000000000000..b89dce62d1ec --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-head-title-2/_config.js @@ -0,0 +1,23 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + const [toggle, resolve] = target.querySelectorAll('button'); + toggle.click(); + await tick(); + assert.equal(window.document.title, ''); + + toggle.click(); + await tick(); + assert.equal(window.document.title, ''); + + toggle.click(); + await tick(); + assert.equal(window.document.title, ''); + + resolve.click(); + await tick(); + assert.equal(window.document.title, 'title'); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-head-title-2/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-head-title-2/main.svelte new file mode 100644 index 000000000000..353515708785 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-head-title-2/main.svelte @@ -0,0 +1,12 @@ + + + + +{#if show} + +{/if} From b90232d48030439165ba9ac8f152eb522349041e Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 28 Oct 2025 20:59:00 +0100 Subject: [PATCH 2/2] fix --- .../internal/client/dom/blocks/svelte-head.js | 4 ++-- .../src/internal/client/reactivity/batch.js | 21 ++++++++----------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js index 1f589e454256..13926ccc4b83 100644 --- a/packages/svelte/src/internal/client/dom/blocks/svelte-head.js +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-head.js @@ -1,7 +1,7 @@ /** @import { TemplateNode } from '#client' */ import { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from '../hydration.js'; import { create_text, get_first_child, get_next_sibling } from '../operations.js'; -import { block, branch } from '../../reactivity/effects.js'; +import { block } from '../../reactivity/effects.js'; import { COMMENT_NODE, HEAD_EFFECT } from '#client/constants'; /** @@ -49,7 +49,7 @@ export function head(hash, render_fn) { } try { - block(() => branch(() => render_fn(anchor)), HEAD_EFFECT); + block(() => render_fn(anchor), HEAD_EFFECT); } finally { if (was_hydrating) { set_hydrating(true); diff --git a/packages/svelte/src/internal/client/reactivity/batch.js b/packages/svelte/src/internal/client/reactivity/batch.js index 9d61b6bbf92b..27c90d770843 100644 --- a/packages/svelte/src/internal/client/reactivity/batch.js +++ b/packages/svelte/src/internal/client/reactivity/batch.js @@ -14,33 +14,25 @@ import { MAYBE_DIRTY, DERIVED, BOUNDARY_EFFECT, - EAGER_EFFECT + EAGER_EFFECT, + HEAD_EFFECT } from '#client/constants'; import { async_mode_flag } from '../../flags/index.js'; import { deferred, define_property } from '../../shared/utils.js'; import { active_effect, get, - increment_write_version, is_dirty, is_updating_effect, set_is_updating_effect, set_signal_status, - tick, update_effect } from '../runtime.js'; 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 { - flush_eager_effects, - eager_effects, - old_values, - set_eager_effects, - source, - update -} from './sources.js'; +import { flush_eager_effects, old_values, set_eager_effects, source, update } from './sources.js'; import { eager_effect, unlink_effect } from './effects.js'; /** @@ -800,7 +792,12 @@ export function schedule_effect(signal) { // if the effect is being scheduled because a parent (each/await/etc) block // updated an internal source, bail out or we'll cause a second flush - if (is_flushing && effect === active_effect && (flags & BLOCK_EFFECT) !== 0) { + if ( + is_flushing && + effect === active_effect && + (flags & BLOCK_EFFECT) !== 0 && + (flags & HEAD_EFFECT) === 0 + ) { return; }