Skip to content

Commit 0bca1c7

Browse files
committed
fix: memoize repeated state reads in guard expressions
1 parent 31541c8 commit 0bca1c7

File tree

2 files changed

+138
-6
lines changed

2 files changed

+138
-6
lines changed

packages/svelte/src/compiler/phases/3-transform/client/visitors/IfBlock.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export function IfBlock(node, context) {
1212
context.state.template.push_comment();
1313
const statements = [];
1414

15+
const { has_await } = node.metadata.expression;
16+
const expression = build_expression(context, node.test, node.metadata.expression);
17+
const test = has_await ? b.call('$.get', b.id('$$condition')) : expression;
18+
1519
const consequent = /** @type {BlockStatement} */ (context.visit(node.consequent));
1620
const consequent_id = b.id(context.state.scope.generate('consequent'));
1721

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

28-
const { has_await } = node.metadata.expression;
29-
const expression = build_expression(context, node.test, node.metadata.expression);
30-
const test = has_await ? b.call('$.get', b.id('$$condition')) : expression;
31-
3232
/** @type {Expression[]} */
3333
const args = [
3434
context.state.node,

packages/svelte/src/compiler/phases/3-transform/client/visitors/shared/utils.js

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/** @import { AssignmentExpression, Expression, Identifier, MemberExpression, SequenceExpression, Literal, Super, UpdateExpression, ExpressionStatement } from 'estree' */
2-
/** @import { AST, ExpressionMetadata } from '#compiler' */
2+
/** @import { AST, Binding, ExpressionMetadata } from '#compiler' */
33
/** @import { ComponentClientTransformState, ComponentContext, Context } from '../../types' */
44
import { walk } from 'zimmerframe';
55
import { object } from '../../../../../utils/ast.js';
@@ -10,6 +10,42 @@ import is_reference from 'is-reference';
1010
import { dev, is_ignored, locator, component_name } from '../../../../../state.js';
1111
import { build_getter } from '../../utils.js';
1212

13+
/**
14+
* @param {import('estree').Node | null | undefined} node
15+
* @returns {node is import('estree').Function}
16+
*/
17+
function is_function(node) {
18+
return Boolean(
19+
node &&
20+
(node.type === 'ArrowFunctionExpression' ||
21+
node.type === 'FunctionExpression' ||
22+
node.type === 'FunctionDeclaration')
23+
);
24+
}
25+
26+
/**
27+
* Determines whether repeated reads of a binding within a single expression
28+
* should be memoized to avoid inconsistent results.
29+
* @param {Binding | null} binding
30+
* @returns {binding is Binding}
31+
*/
32+
function should_memoize_binding(binding) {
33+
if (binding === null) return false;
34+
35+
switch (binding.kind) {
36+
case 'state':
37+
case 'raw_state':
38+
case 'derived':
39+
case 'legacy_reactive':
40+
case 'template':
41+
return true;
42+
default:
43+
return false;
44+
}
45+
}
46+
/** @type {WeakMap<any, Map<string, { id: Identifier; getter: (node: Identifier) => Expression }>>} */
47+
const memoized_reads_by_scope = new WeakMap();
48+
1349
/**
1450
* A utility for extracting complex expressions (such as call expressions)
1551
* from templates and replacing them with `$0`, `$1` etc
@@ -391,7 +427,103 @@ export function validate_mutation(node, context, expression) {
391427
* @param {ExpressionMetadata} metadata
392428
*/
393429
export function build_expression(context, expression, metadata, state = context.state) {
394-
const value = /** @type {Expression} */ (context.visit(expression, state));
430+
/** @type {import('../../types.js').ComponentClientTransformState} */
431+
let child_state = state;
432+
/** @type {import('estree').Statement[]} */
433+
const assignments = [];
434+
/** @type {Set<string> | null} */
435+
let memoized_ids = null;
436+
437+
if (state.analysis.runes && !metadata.has_await) {
438+
const component_state = /** @type {ComponentClientTransformState} */ (context.state);
439+
/** @type {Map<Binding, number>} */
440+
const counts = new Map();
441+
442+
walk(expression, null, {
443+
Identifier(node, { path }) {
444+
const parent = /** @type {Expression} */ (path.at(-1));
445+
if (!is_reference(node, parent)) return;
446+
447+
// avoid memoizing reads that occur within nested functions, since those
448+
// must re-evaluate when the function executes later
449+
if (path.some((ancestor, i) => i < path.length - 1 && is_function(ancestor))) {
450+
return;
451+
}
452+
453+
const binding = state.scope.get(node.name);
454+
if (!should_memoize_binding(binding)) return;
455+
456+
counts.set(binding, (counts.get(binding) ?? 0) + 1);
457+
}
458+
});
459+
460+
memoized_ids = new Set();
461+
462+
for (const [binding, count] of counts) {
463+
if (count <= 1) continue;
464+
const name = binding.node?.name;
465+
if (!name) continue;
466+
467+
const original = state.transform[name];
468+
if (!original?.read) continue;
469+
470+
let scope_records = memoized_reads_by_scope.get(binding.scope);
471+
if (!scope_records) {
472+
scope_records = new Map();
473+
memoized_reads_by_scope.set(binding.scope, scope_records);
474+
}
475+
476+
if (child_state === state) {
477+
child_state = {
478+
...state,
479+
transform: { ...state.transform }
480+
};
481+
}
482+
483+
let record = scope_records.get(name);
484+
485+
if (!record) {
486+
const memo_id = b.id(state.scope.generate(`${name}_value`));
487+
record = {
488+
id: memo_id,
489+
getter: original.read
490+
};
491+
scope_records.set(name, record);
492+
component_state.init.push(b.let(memo_id));
493+
}
494+
memoized_ids.add(record.id.name);
495+
496+
const previous = child_state.transform[name];
497+
child_state.transform[name] = {
498+
...previous,
499+
read() {
500+
return record.id;
501+
},
502+
assign: previous?.assign,
503+
mutate: previous?.mutate,
504+
update: previous?.update
505+
};
506+
507+
assignments.push(b.stmt(b.assignment('=', record.id, record.getter(b.id(name)))));
508+
}
509+
}
510+
511+
let value = /** @type {Expression} */ (context.visit(expression, child_state));
512+
513+
if (memoized_ids !== null && memoized_ids.size > 0) {
514+
const memoized = memoized_ids;
515+
walk(value, null, {
516+
MemberExpression(node) {
517+
if (node.object.type === 'Identifier' && memoized.has(node.object.name)) {
518+
node.optional = true;
519+
}
520+
}
521+
});
522+
}
523+
524+
if (assignments.length > 0) {
525+
value = b.call(b.arrow([], b.block([...assignments, b.return(value)])));
526+
}
395527

396528
// Components not explicitly in legacy mode might be expected to be in runes mode (especially since we didn't
397529
// adjust this behavior until recently, which broke people's existing components), so we also bail in this case.

0 commit comments

Comments
 (0)