Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wicked-keys-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: memoize repeated state reads in guard expressions
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export function IfBlock(node, context) {
context.state.template.push_comment();
const statements = [];

const { has_await } = node.metadata.expression;
const expression = build_expression(context, node.test, node.metadata.expression);
const test = has_await ? b.call('$.get', b.id('$$condition')) : expression;

const consequent = /** @type {BlockStatement} */ (context.visit(node.consequent));
const consequent_id = b.id(context.state.scope.generate('consequent'));

Expand All @@ -25,10 +29,6 @@ export function IfBlock(node, context) {
statements.push(b.var(alternate_id, b.arrow([b.id('$$anchor')], alternate)));
}

const { has_await } = node.metadata.expression;
const expression = build_expression(context, node.test, node.metadata.expression);
const test = has_await ? b.call('$.get', b.id('$$condition')) : expression;

/** @type {Expression[]} */
const args = [
context.state.node,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @import { AssignmentExpression, Expression, Identifier, MemberExpression, SequenceExpression, Literal, Super, UpdateExpression, ExpressionStatement } from 'estree' */
/** @import { AST, ExpressionMetadata } from '#compiler' */
/** @import { AST, Binding, ExpressionMetadata } from '#compiler' */
/** @import { ComponentClientTransformState, ComponentContext, Context } from '../../types' */
import { walk } from 'zimmerframe';
import { object } from '../../../../../utils/ast.js';
Expand All @@ -10,6 +10,42 @@ import is_reference from 'is-reference';
import { dev, is_ignored, locator, component_name } from '../../../../../state.js';
import { build_getter } from '../../utils.js';

/**
* @param {import('estree').Node | null | undefined} node
* @returns {node is import('estree').Function}
*/
function is_function(node) {
return Boolean(
node &&
(node.type === 'ArrowFunctionExpression' ||
node.type === 'FunctionExpression' ||
node.type === 'FunctionDeclaration')
);
}

/**
* Determines whether repeated reads of a binding within a single expression
* should be memoized to avoid inconsistent results.
* @param {Binding | null} binding
* @returns {binding is Binding}
*/
function should_memoize_binding(binding) {
if (binding === null) return false;

switch (binding.kind) {
case 'state':
case 'raw_state':
case 'derived':
case 'legacy_reactive':
case 'template':
return true;
default:
return false;
}
}
/** @type {WeakMap<any, Map<string, { id: Identifier; getter: (node: Identifier) => Expression }>>} */
const memoized_reads_by_scope = new WeakMap();

/**
* A utility for extracting complex expressions (such as call expressions)
* from templates and replacing them with `$0`, `$1` etc
Expand Down Expand Up @@ -391,7 +427,103 @@ export function validate_mutation(node, context, expression) {
* @param {ExpressionMetadata} metadata
*/
export function build_expression(context, expression, metadata, state = context.state) {
const value = /** @type {Expression} */ (context.visit(expression, state));
/** @type {import('../../types.js').ComponentClientTransformState} */
let child_state = state;
/** @type {import('estree').Statement[]} */
const assignments = [];
/** @type {Set<string> | null} */
let memoized_ids = null;

if (state.analysis.runes && !metadata.has_await) {
const component_state = /** @type {ComponentClientTransformState} */ (context.state);
/** @type {Map<Binding, number>} */
const counts = new Map();

walk(expression, null, {
Identifier(node, { path }) {
const parent = /** @type {Expression} */ (path.at(-1));
if (!is_reference(node, parent)) return;

// avoid memoizing reads that occur within nested functions, since those
// must re-evaluate when the function executes later
if (path.some((ancestor, i) => i < path.length - 1 && is_function(ancestor))) {
return;
}

const binding = state.scope.get(node.name);
if (!should_memoize_binding(binding)) return;

counts.set(binding, (counts.get(binding) ?? 0) + 1);
}
});

memoized_ids = new Set();

for (const [binding, count] of counts) {
if (count <= 1) continue;
const name = binding.node?.name;
if (!name) continue;

const original = state.transform[name];
if (!original?.read) continue;

let scope_records = memoized_reads_by_scope.get(binding.scope);
if (!scope_records) {
scope_records = new Map();
memoized_reads_by_scope.set(binding.scope, scope_records);
}

if (child_state === state) {
child_state = {
...state,
transform: { ...state.transform }
};
}

let record = scope_records.get(name);

if (!record) {
const memo_id = b.id(state.scope.generate(`${name}_value`));
record = {
id: memo_id,
getter: original.read
};
scope_records.set(name, record);
component_state.init.push(b.let(memo_id));
}
memoized_ids.add(record.id.name);

const previous = child_state.transform[name];
child_state.transform[name] = {
...previous,
read() {
return record.id;
},
assign: previous?.assign,
mutate: previous?.mutate,
update: previous?.update
};

assignments.push(b.stmt(b.assignment('=', record.id, record.getter(b.id(name)))));
}
}

let value = /** @type {Expression} */ (context.visit(expression, child_state));

if (memoized_ids !== null && memoized_ids.size > 0) {
const memoized = memoized_ids;
walk(value, null, {
MemberExpression(node) {
if (node.object.type === 'Identifier' && memoized.has(node.object.name)) {
node.optional = true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a suspicion that this is what "fixes" the problem by turning

 centerRow.depth != undefined && centerRow.depth > 0

into

centerRow_value?.depth != undefined && centerRow_value?.depth > 0

which is not really equal.

Copy link
Contributor Author

@LeeWxx LeeWxx Oct 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@7nik Thanks for double-checking! At first glance it might look a bit different, but I think Svelte still seems to emit the leading guard

return (
  centerRow_value != undefined &&
  centerRow_value?.depth != undefined &&
  centerRow_value?.depth > 0
);

From what I can tell, the optional chaining only comes into play after we already know that centerRow_value isn’t null or undefined. In that case, centerRow_value?.depth would effectively behave the same as centerRow_value.depth. The ?. just seems to act as a safety net, in case the value somehow changes between reads while reusing the cached value. So I believe the semantics of the guard essentially remain the same.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look at this variant - it fails on this PR as well. The problem is that the nested if / template effect / etc runs when it shouldn't.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@7nik Oops, you’re right

it looks like this new variant still slips through what I’ve tried so far. From what I can tell, the inner template effect is still calling $.get(centerRow) even after the guard has already short-circuited, which makes the block run when it really shouldn’t.

I see two possible ways we could approach this. One would be to hoist the guard result so that the entire block (effects and nested branches included) reuses the same snapshot. The other would be to change how the template effect checks for guard failure before it runs.

Do you think one of these directions would fit better?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The outer if should execute first and destroy the branch, thus no execution of centerRow.depth when centerRow is undefined. But I'm not so familiar with the reactivity internals (especially async ones) to say why effects are executed in another order and how to fix it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@7nik Thanks for catching that earlier variant! I hoisted the guard value so the entire block (including template effects and nested branches) reuses the same snapshot.
If you’re aware of other guard scenarios we should test, I’d really appreciate the pointers.

}
}
});
}

if (assignments.length > 0) {
value = b.call(b.arrow([], b.block([...assignments, b.return(value)])));
}

// Components not explicitly in legacy mode might be expected to be in runes mode (especially since we didn't
// adjust this behavior until recently, which broke people's existing components), so we also bail in this case.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { test } from '../../test';
import { flushSync } from 'svelte';

export default test({
mode: ['client'],
async test({ target, assert }) {
const button = target.querySelector('button');

flushSync(() => button?.click());

assert.equal(target.textContent?.trim(), 'Trigger');
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script>
let virtualItems = $state([{ index: 0 }, { index: 1 }, { index: 2 }]);
let centerRows = $state([{ depth: 2 }, { depth: 1 }, { depth: 1 }]);

let someChange = $state(false);
$effect(() => {
if (someChange) centerRows = [];
});
</script>

{#each virtualItems as row (row.index)}
{@const centerRow = centerRows[row.index]}
{#if centerRow != undefined && centerRow.depth != undefined && typeof centerRow === "object" && "depth" in centerRow && typeof centerRow.depth === "number"}
{#if centerRow.depth != undefined && centerRow.depth > 0}
Hello World<br />
{/if}
{/if}
{/each}

<button onclick={() => (someChange = true)}>Trigger</button>
Loading