pull/17637/merge
Artyom Alekseevich 1 day ago committed by GitHub
commit 588573319b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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.

@ -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;

@ -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));

@ -0,0 +1,25 @@
import { flushSync } from 'svelte';
import { ok, test } from '../../test';
export default test({
html: `<input> <p>initial</p>`,
ssrHtml: `<input value="initial"> <p>initial</p>`,
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,
`
<input>
<p>changed</p>
`
);
}
});

@ -0,0 +1,12 @@
<script>
import { writable } from 'svelte/store';
const store = writable([{ text: 'initial' }]);
const items = $derived($store);
</script>
{#each items as item}
<input bind:value={item.text} />
{/each}
<p>{items[0].text}</p>
Loading…
Cancel
Save