Skip to content

Commit 88a83d6

Browse files
committed
fix: rework binding ownership validation
Previously we were doing stack-based retrieval of the owner, which while catching more cases was costly (performance-wise) and prone to errors (as shown by many issues over the months). This drastically simplifies the ownership validation - we now only do simple static analysis to check which props are mutated and wrap them with runtime checks to see if a binding was established. Besides making the implementation simpler and more performant, this also follows an insight we had over the months: Most people don't really know what to do with this warning when it's shown beyond very simple cases. Either it's not actionable because they don't really know how to fix it or they question if they should at all (in some cases rightfully so). Now that the warning is only shown in simple and easy-to-reason-about cases, it has a much better signal-to-noise-ratio and will hopefully guide people in the right direction early on (learn from the obvious cases to not write spaghetti code in more complex cases). closes #15532 closes #15210 closes #14893 closes #13607 closes #13139 closes #11861
1 parent 909301f commit 88a83d6

File tree

10 files changed

+167
-6
lines changed

10 files changed

+167
-6
lines changed

.changeset/sweet-ants-care.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: rework binding ownership validation

packages/svelte/src/compiler/phases/2-analyze/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,7 @@ export function analyze_component(root, source, options) {
432432
uses_component_bindings: false,
433433
uses_render_tags: false,
434434
needs_context: false,
435+
needs_mutation_validation: false,
435436
needs_props: false,
436437
event_directive_node: null,
437438
uses_event_attributes: false,

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,20 @@ export function client_component(analysis, options) {
402402
// we want the cleanup function for the stores to run as the very last thing
403403
// so that it can effectively clean up the store subscription even after the user effects runs
404404
if (should_inject_context) {
405+
if (analysis.needs_mutation_validation) {
406+
// It's easier to have the mutation validator instance be on the module level because of hoisted events
407+
state.hoisted.push(b.let('$$ownership_validator'));
408+
component_block.body.unshift(
409+
b.stmt(
410+
b.assignment(
411+
'=',
412+
b.id('$$ownership_validator'),
413+
b.call('$.create_ownership_validator', b.id('$$props'))
414+
)
415+
)
416+
);
417+
}
418+
405419
component_block.body.unshift(b.stmt(b.call('$.push', ...push_args)));
406420

407421
let to_push;

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,18 @@ import {
1010
import { dev, locate_node } from '../../../../state.js';
1111
import { should_proxy } from '../utils.js';
1212
import { visit_assignment_expression } from '../../shared/assignments.js';
13+
import { validate_mutation } from './shared/utils.js';
1314

1415
/**
1516
* @param {AssignmentExpression} node
1617
* @param {Context} context
1718
*/
1819
export function AssignmentExpression(node, context) {
19-
return /** @type {Expression} */ (
20+
const expression = /** @type {Expression} */ (
2021
visit_assignment_expression(node, context, build_assignment) ?? context.next()
2122
);
23+
24+
return validate_mutation(node, context, expression);
2225
}
2326

2427
/**

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
/** @import { Context } from '../types' */
33
import { object } from '../../../../utils/ast.js';
44
import * as b from '../../../../utils/builders.js';
5+
import { validate_mutation } from './shared/utils.js';
56

67
/**
78
* @param {UpdateExpression} node
@@ -50,5 +51,5 @@ export function UpdateExpression(node, context) {
5051
);
5152
}
5253

53-
return update;
54+
return validate_mutation(node, context, update);
5455
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,23 @@ export function build_component(node, component_name, context, anchor = context.
178178
}
179179
} else if (attribute.type === 'BindDirective') {
180180
const expression = /** @type {Expression} */ (context.visit(attribute.expression));
181+
182+
if (dev && attribute.name !== 'this' && !is_ignored(node, 'ownership_invalid_binding')) {
183+
context.state.analysis.needs_mutation_validation = true;
184+
binding_initializers.push(
185+
b.stmt(
186+
b.call(
187+
b.id('$$ownership_validator.binding'),
188+
b.literal(attribute.name),
189+
b.id(component_name),
190+
expression.type === 'SequenceExpression'
191+
? expression.expressions[0]
192+
: b.thunk(expression)
193+
)
194+
)
195+
);
196+
}
197+
181198
if (expression.type === 'SequenceExpression') {
182199
if (attribute.name === 'this') {
183200
bind_this = attribute.expression;

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

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
/** @import { Expression, ExpressionStatement, Identifier, MemberExpression, SequenceExpression, Statement, Super } from 'estree' */
1+
/** @import { AssignmentExpression, Expression, ExpressionStatement, Identifier, MemberExpression, SequenceExpression, Literal, Super, UpdateExpression } from 'estree' */
22
/** @import { AST, ExpressionMetadata } from '#compiler' */
3-
/** @import { ComponentClientTransformState } from '../../types' */
3+
/** @import { ComponentClientTransformState, Context } from '../../types' */
44
import { walk } from 'zimmerframe';
55
import { object } from '../../../../../utils/ast.js';
66
import * as b from '../../../../../utils/builders.js';
77
import { sanitize_template_string } from '../../../../../utils/sanitize_template_string.js';
88
import { regex_is_valid_identifier } from '../../../../patterns.js';
99
import is_reference from 'is-reference';
10-
import { locator } from '../../../../../state.js';
10+
import { is_ignored, locator } from '../../../../../state.js';
1111
import { create_derived } from '../../utils.js';
1212

1313
/**
@@ -295,3 +295,55 @@ export function validate_binding(state, binding, expression) {
295295
)
296296
);
297297
}
298+
299+
/**
300+
* In dev mode validate mutations to props
301+
* @param {AssignmentExpression | UpdateExpression} node
302+
* @param {Context} context
303+
* @param {Expression} expression
304+
*/
305+
export function validate_mutation(node, context, expression) {
306+
const left = node.type === 'AssignmentExpression' ? node.left : node.argument;
307+
308+
if (
309+
!context.state.options.dev ||
310+
is_ignored(node, 'ownership_invalid_mutation') ||
311+
(left.type !== 'Identifier' && left.type !== 'MemberExpression')
312+
) {
313+
return expression;
314+
}
315+
316+
const name = object(left);
317+
if (!name) return expression;
318+
319+
const binding = context.state.scope.get(name.name);
320+
if (binding?.kind !== 'prop' && binding?.kind !== 'bindable_prop') return expression;
321+
322+
const state = /** @type {ComponentClientTransformState} */ (context.state);
323+
324+
state.analysis.needs_mutation_validation = true;
325+
326+
/** @type {Array<Identifier | Literal>} */
327+
const path = [];
328+
/** @type {Expression | Super} */
329+
let current = left;
330+
331+
while (current.type === 'MemberExpression') {
332+
if (current.property.type === 'Literal') {
333+
path.unshift(current.property);
334+
} else if (current.property.type === 'Identifier') {
335+
if (current.computed) {
336+
path.unshift(current.property);
337+
} else {
338+
path.unshift(b.literal(current.property.name));
339+
}
340+
} else {
341+
return expression;
342+
}
343+
current = current.object;
344+
}
345+
346+
path.unshift(b.literal(name.name));
347+
348+
return b.call('$$ownership_validator.mutation', b.array(path), expression);
349+
}

packages/svelte/src/compiler/phases/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export interface ComponentAnalysis extends Analysis {
5353
uses_component_bindings: boolean;
5454
uses_render_tags: boolean;
5555
needs_context: boolean;
56+
needs_mutation_validation: boolean;
5657
needs_props: boolean;
5758
/** Set to the first event directive (on:x) found on a DOM element in the code */
5859
event_directive_node: AST.OnDirective | null;

packages/svelte/src/internal/client/dev/ownership.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
/** @typedef {{ file: string, line: number, column: number }} Location */
22

3+
import { get_descriptor } from '../../shared/utils.js';
4+
import { LEGACY_PROPS, STATE_SYMBOL } from '../constants.js';
5+
import { FILENAME } from '../../../constants.js';
6+
import { component_context } from '../context.js';
7+
import * as w from '../warnings.js';
8+
39
/** @type {Record<string, Array<{ start: Location, end: Location, component: Function }>>} */
410
const boundaries = {};
511

@@ -96,3 +102,64 @@ export function mark_module_end(component) {
96102
boundary.component = component;
97103
}
98104
}
105+
106+
/**
107+
* Sets up a validator that
108+
* - traverses the path of a prop to find out if it is allowed to be mutated
109+
* - checks that the binding chain is not interrupted
110+
* @param {Record<string, any>} props
111+
*/
112+
export function create_ownership_validator(props) {
113+
const component = component_context?.function;
114+
const parent = component_context?.p?.function;
115+
116+
/** @param {string} prop_name */
117+
function is_bound(prop_name) {
118+
// Can be the case when someone does `mount(Component, props)` with `let props = $state({...})`
119+
// or `createClassComponent(Component, props)`
120+
const is_entry_props = STATE_SYMBOL in props || LEGACY_PROPS in props;
121+
const is_bound =
122+
!!get_descriptor(props, prop_name)?.set || (is_entry_props && prop_name in props);
123+
return is_bound;
124+
}
125+
126+
return {
127+
/**
128+
* @param {any[]} path
129+
* @param {any} result
130+
*/
131+
mutation: (path, result) => {
132+
const prop_name = path[0];
133+
if (is_bound(prop_name)) {
134+
return result;
135+
}
136+
137+
let prop = props[prop_name];
138+
139+
for (let i = 1; i < path.length - 1; i++) {
140+
if (!prop?.[STATE_SYMBOL]) {
141+
return result;
142+
}
143+
prop = prop[path[i]];
144+
}
145+
146+
w.ownership_invalid_mutation(component[FILENAME], parent[FILENAME]);
147+
148+
return result;
149+
},
150+
/**
151+
* @param {any} key
152+
* @param {any} child_component
153+
* @param {() => any} value
154+
*/
155+
binding: (key, child_component, value) => {
156+
if (!is_bound(key) && parent && value()?.[STATE_SYMBOL]) {
157+
w.ownership_invalid_binding(
158+
component[FILENAME],
159+
child_component[FILENAME],
160+
parent[FILENAME]
161+
);
162+
}
163+
}
164+
};
165+
}

packages/svelte/src/internal/client/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export { assign, assign_and, assign_or, assign_nullish } from './dev/assign.js';
44
export { cleanup_styles } from './dev/css.js';
55
export { add_locations } from './dev/elements.js';
66
export { hmr } from './dev/hmr.js';
7-
export { mark_module_start, mark_module_end } from './dev/ownership.js';
7+
export { mark_module_start, mark_module_end, create_ownership_validator } from './dev/ownership.js';
88
export { check_target, legacy_api } from './dev/legacy.js';
99
export { trace } from './dev/tracing.js';
1010
export { inspect } from './dev/inspect.js';

0 commit comments

Comments
 (0)