diff --git a/.changeset/fix-each-derived-store-binding.md b/.changeset/fix-each-derived-store-binding.md new file mode 100644 index 0000000000..d8bfdacfda --- /dev/null +++ b/.changeset/fix-each-derived-store-binding.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +Fixed `bind:` not working in `{#each}` blocks when the expression is a `$derived` that references a store (e.g. `{#each $derived($store) as item}`). The compiler now traces through derived bindings to detect underlying store subscriptions, ensuring proper store invalidation and mutable item tracking. diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js index a1371b516a..c3b3caa0ef 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js @@ -15,6 +15,41 @@ import * as b from '#compiler/builders'; import { get_value } from './shared/declarations.js'; import { build_expression, add_svelte_meta } from './shared/utils.js'; +/** + * Walks an AST node tree and returns the name of the first store_sub binding found, or null. + * Used to detect store subscriptions hidden behind derived expressions. + * @param {import('estree').Node | null | undefined} node + * @param {Scope} scope + * @returns {string | null} + */ +function find_store_sub_name(node, scope) { + if (!node || typeof node !== 'object' || !('type' in node)) return null; + + if (node.type === 'Identifier') { + const binding = scope.get(/** @type {string} */ (/** @type {Identifier} */ (node).name)); + if (binding?.kind === 'store_sub') return binding.node.name; + return null; + } + + for (const key of Object.keys(node)) { + if (key === 'type' || key === 'start' || key === 'end' || key === 'loc') continue; + const child = /** @type {any} */ (node)[key]; + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === 'object' && item.type) { + const result = find_store_sub_name(item, scope); + if (result) return result; + } + } + } else if (child && typeof child === 'object' && child.type) { + const result = find_store_sub_name(child, scope); + if (result) return result; + } + } + + return null; +} + /** * @param {AST.EachBlock} node * @param {ComponentContext} context @@ -62,6 +97,15 @@ export function EachBlock(node, context) { uses_store = true; break; } + + // If the expression depends on a derived that itself depends on a store, + // we still need mutable item sources (#13569) + if (binding.kind === 'derived' && binding.initial?.type === 'CallExpression') { + if (find_store_sub_name(binding.initial, binding.scope)) { + uses_store = true; + break; + } + } } for (const binding of node.metadata.expression.dependencies) { @@ -109,6 +153,20 @@ export function EachBlock(node, context) { } } + // If the expression is a derived that depends on a store, we need to + // invalidate that store when bindings mutate items (#13569) + if (!store_to_invalidate) { + for (const binding of node.metadata.expression.dependencies) { + if (binding.kind === 'derived' && binding.initial?.type === 'CallExpression') { + const name = find_store_sub_name(binding.initial, binding.scope); + if (name) { + store_to_invalidate = name; + break; + } + } + } + } + /** @type {Identifier | null} */ let collection_id = null; diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js index 16aa164d84..72074f7d77 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/VariableDeclaration.js @@ -9,6 +9,35 @@ import { get_rune } from '../../../scope.js'; import { get_prop_source, is_prop_source, is_state_source, should_proxy } from '../utils.js'; import { get_value } from './shared/declarations.js'; +/** + * Checks if an AST expression references a store subscription binding. + * Used to detect when $derived wraps a store, so we use safe equality. (#13569) + * @param {import('estree').Node | null | undefined} node + * @param {import('../../../scope.js').Scope} scope + * @returns {boolean} + */ +function references_store_sub(node, scope) { + if (!node || typeof node !== 'object' || !('type' in node)) return false; + + if (node.type === 'Identifier') { + return scope.get(/** @type {string} */ (/** @type {Identifier} */ (node).name))?.kind === 'store_sub'; + } + + for (const key of Object.keys(node)) { + if (key === 'type' || key === 'start' || key === 'end' || key === 'loc') continue; + const child = /** @type {any} */ (node)[key]; + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === 'object' && item.type && references_store_sub(item, scope)) return true; + } + } else if (child && typeof child === 'object' && child.type && references_store_sub(child, scope)) { + return true; + } + } + + return false; +} + /** * @param {VariableDeclaration} node * @param {ComponentContext} context @@ -219,7 +248,10 @@ export function VariableDeclaration(node, context) { } else { if (rune === '$derived') expression = b.thunk(expression); - let call = b.call('$.derived', expression); + // If the derived expression depends on a store subscription, + // use safe equality to detect mutations to same-reference objects (#13569) + const use_safe_equal = references_store_sub(value, context.state.scope); + let call = b.call(use_safe_equal ? '$.derived_safe_equal' : '$.derived', expression); if (dev) call = b.call('$.tag', call, b.literal(declarator.id.name)); declarations.push(b.declarator(declarator.id, call)); diff --git a/packages/svelte/tests/runtime-runes/samples/each-bind-derived-store/_config.js b/packages/svelte/tests/runtime-runes/samples/each-bind-derived-store/_config.js new file mode 100644 index 0000000000..b263e8e14f --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-bind-derived-store/_config.js @@ -0,0 +1,25 @@ +import { flushSync } from 'svelte'; +import { ok, test } from '../../test'; + +export default test({ + html: `

initial

`, + ssrHtml: `

initial

`, + + test({ assert, target, window }) { + const input = target.querySelector('input'); + ok(input); + + const event = new window.Event('input'); + input.value = 'changed'; + input.dispatchEvent(event); + flushSync(); + + assert.htmlEqual( + target.innerHTML, + ` + +

changed

+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/each-bind-derived-store/main.svelte b/packages/svelte/tests/runtime-runes/samples/each-bind-derived-store/main.svelte new file mode 100644 index 0000000000..c055411fad --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/each-bind-derived-store/main.svelte @@ -0,0 +1,12 @@ + + +{#each items as item} + +{/each} + +

{items[0].text}