Skip to content

Commit 98d14ec

Browse files
fix: rework binding ownership validation (#15678)
* remove old validation * 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 * remove some now obsolete tests * fix * better warnings now that we have more info * fix * hoist * we only care about mutation, not reassignment * tidy * handle prop aliases * mutation validation is only tangentially linked to context requirement * no need for two vars, one will do * update warning, include mutation location * tweak --------- Co-authored-by: Rich Harris <[email protected]>
1 parent caf62ee commit 98d14ec

File tree

57 files changed

+271
-806
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+271
-806
lines changed

.changeset/sweet-ants-care.md

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

documentation/docs/98-reference/.generated/client-warnings.md

+2-6
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ Tried to unmount a component that was not mounted
161161
### ownership_invalid_binding
162162

163163
```
164-
%parent% passed a value to %child% with `bind:`, but the value is owned by %owner%. Consider creating a binding between %owner% and %parent%
164+
%parent% passed property `%prop%` to %child% with `bind:`, but its parent component %owner% did not declare `%prop%` as a binding. Consider creating a binding between %owner% and %parent% (e.g. `bind:%prop%={...}` instead of `%prop%={...}`)
165165
```
166166

167167
Consider three components `GrandParent`, `Parent` and `Child`. If you do `<GrandParent bind:value>`, inside `GrandParent` pass on the variable via `<Parent {value} />` (note the missing `bind:`) and then do `<Child bind:value>` inside `Parent`, this warning is thrown.
@@ -171,11 +171,7 @@ To fix it, `bind:` to the value instead of just passing a property (i.e. in this
171171
### ownership_invalid_mutation
172172
173173
```
174-
Mutating a value outside the component that created it is strongly discouraged. Consider passing values to child components with `bind:`, or use a callback instead
175-
```
176-
177-
```
178-
%component% mutated a value owned by %owner%. This is strongly discouraged. Consider passing values to child components with `bind:`, or use a callback instead
174+
Mutating unbound props (`%name%`, at %location%) is strongly discouraged. Consider using `bind:%prop%={...}` in %parent% (or using a callback) instead
179175
```
180176
181177
Consider the following code:

packages/svelte/messages/client-warnings/warnings.md

+2-4
Original file line numberDiff line numberDiff line change
@@ -132,17 +132,15 @@ During development, this error is often preceeded by a `console.error` detailing
132132
133133
## ownership_invalid_binding
134134
135-
> %parent% passed a value to %child% with `bind:`, but the value is owned by %owner%. Consider creating a binding between %owner% and %parent%
135+
> %parent% passed property `%prop%` to %child% with `bind:`, but its parent component %owner% did not declare `%prop%` as a binding. Consider creating a binding between %owner% and %parent% (e.g. `bind:%prop%={...}` instead of `%prop%={...}`)
136136
137137
Consider three components `GrandParent`, `Parent` and `Child`. If you do `<GrandParent bind:value>`, inside `GrandParent` pass on the variable via `<Parent {value} />` (note the missing `bind:`) and then do `<Child bind:value>` inside `Parent`, this warning is thrown.
138138
139139
To fix it, `bind:` to the value instead of just passing a property (i.e. in this example do `<Parent bind:value />`).
140140
141141
## ownership_invalid_mutation
142142
143-
> Mutating a value outside the component that created it is strongly discouraged. Consider passing values to child components with `bind:`, or use a callback instead
144-
145-
> %component% mutated a value owned by %owner%. This is strongly discouraged. Consider passing values to child components with `bind:`, or use a callback instead
143+
> Mutating unbound props (`%name%`, at %location%) is strongly discouraged. Consider using `bind:%prop%={...}` in %parent% (or using a callback) instead
146144
147145
Consider the following code:
148146

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

+1
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

+6
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,12 @@ export function client_component(analysis, options) {
393393
);
394394
}
395395

396+
if (analysis.needs_mutation_validation) {
397+
component_block.body.unshift(
398+
b.var('$$ownership_validator', b.call('$.create_ownership_validator', b.id('$$props')))
399+
);
400+
}
401+
396402
const should_inject_context =
397403
dev ||
398404
analysis.needs_context ||

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

+29-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
/** @import { ArrowFunctionExpression, Expression, FunctionDeclaration, FunctionExpression, Identifier, Pattern, PrivateIdentifier, Statement } from 'estree' */
2-
/** @import { AST, Binding } from '#compiler' */
1+
/** @import { ArrowFunctionExpression, AssignmentExpression, Expression, FunctionDeclaration, FunctionExpression, Identifier, Node, Pattern, UpdateExpression } from 'estree' */
2+
/** @import { Binding } from '#compiler' */
33
/** @import { ClientTransformState, ComponentClientTransformState, ComponentContext } from './types.js' */
44
/** @import { Analysis } from '../../types.js' */
55
/** @import { Scope } from '../../scope.js' */
66
import * as b from '../../../utils/builders.js';
7-
import { extract_identifiers, is_simple_expression } from '../../../utils/ast.js';
7+
import { is_simple_expression } from '../../../utils/ast.js';
88
import {
99
PROPS_IS_LAZY_INITIAL,
1010
PROPS_IS_IMMUTABLE,
@@ -13,7 +13,8 @@ import {
1313
PROPS_IS_BINDABLE
1414
} from '../../../../constants.js';
1515
import { dev } from '../../../state.js';
16-
import { get_value } from './visitors/shared/declarations.js';
16+
import { walk } from 'zimmerframe';
17+
import { validate_mutation } from './visitors/shared/utils.js';
1718

1819
/**
1920
* @param {Binding} binding
@@ -110,6 +111,30 @@ function get_hoisted_params(node, context) {
110111
}
111112
}
112113
}
114+
115+
if (dev) {
116+
// this is a little hacky, but necessary for ownership validation
117+
// to work inside hoisted event handlers
118+
119+
/**
120+
* @param {AssignmentExpression | UpdateExpression} node
121+
* @param {{ next: () => void, stop: () => void }} context
122+
*/
123+
function visit(node, { next, stop }) {
124+
if (validate_mutation(node, /** @type {any} */ (context), node) !== node) {
125+
params.push(b.id('$$ownership_validator'));
126+
stop();
127+
} else {
128+
next();
129+
}
130+
}
131+
132+
walk(/** @type {Node} */ (node), null, {
133+
AssignmentExpression: visit,
134+
UpdateExpression: visit
135+
});
136+
}
137+
113138
return params;
114139
}
115140

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

+3-4
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import {
77
get_attribute_expression,
88
is_event_attribute
99
} from '../../../../utils/ast.js';
10-
import { dev, is_ignored, locate_node } from '../../../../state.js';
10+
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
@@ -20,9 +21,7 @@ export function AssignmentExpression(node, context) {
2021
visit_assignment_expression(node, context, build_assignment) ?? context.next()
2122
);
2223

23-
return is_ignored(node, 'ownership_invalid_mutation')
24-
? b.call('$.skip_ownership_validation', b.thunk(expression))
25-
: expression;
24+
return validate_mutation(node, context, expression);
2625
}
2726

2827
/**

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

-27
Original file line numberDiff line numberDiff line change
@@ -161,33 +161,6 @@ export function ClassBody(node, context) {
161161
body.push(/** @type {MethodDefinition} **/ (context.visit(definition, child_state)));
162162
}
163163

164-
if (dev && public_state.size > 0) {
165-
// add an `[$.ADD_OWNER]` method so that a class with state fields can widen ownership
166-
body.push(
167-
b.method(
168-
'method',
169-
b.id('$.ADD_OWNER'),
170-
[b.id('owner')],
171-
[
172-
b.stmt(
173-
b.call(
174-
'$.add_owner_to_class',
175-
b.this,
176-
b.id('owner'),
177-
b.array(
178-
Array.from(public_state).map(([name]) =>
179-
b.thunk(b.call('$.get', b.member(b.this, b.private_id(name))))
180-
)
181-
),
182-
is_ignored(node, 'ownership_invalid_binding') && b.true
183-
)
184-
)
185-
],
186-
true
187-
)
188-
);
189-
}
190-
191164
return { ...node, body };
192165
}
193166

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

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/** @import { AssignmentExpression, Expression, UpdateExpression } from 'estree' */
22
/** @import { Context } from '../types' */
3-
import { is_ignored } from '../../../../state.js';
43
import { object } from '../../../../utils/ast.js';
54
import * as b from '../../../../utils/builders.js';
5+
import { validate_mutation } from './shared/utils.js';
66

77
/**
88
* @param {UpdateExpression} node
@@ -51,7 +51,5 @@ export function UpdateExpression(node, context) {
5151
);
5252
}
5353

54-
return is_ignored(node, 'ownership_invalid_mutation')
55-
? b.call('$.skip_ownership_validation', b.thunk(update))
56-
: update;
54+
return validate_mutation(node, context, update);
5755
}

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

+22-12
Original file line numberDiff line numberDiff line change
@@ -179,19 +179,29 @@ export function build_component(node, component_name, context, anchor = context.
179179
} else if (attribute.type === 'BindDirective') {
180180
const expression = /** @type {Expression} */ (context.visit(attribute.expression));
181181

182-
if (dev && attribute.name !== 'this') {
183-
binding_initializers.push(
184-
b.stmt(
185-
b.call(
186-
b.id('$.add_owner_effect'),
187-
expression.type === 'SequenceExpression'
188-
? expression.expressions[0]
189-
: b.thunk(expression),
190-
b.id(component_name),
191-
is_ignored(node, 'ownership_invalid_binding') && b.true
182+
if (
183+
dev &&
184+
attribute.name !== 'this' &&
185+
!is_ignored(node, 'ownership_invalid_binding') &&
186+
// bind:x={() => x.y, y => x.y = y} will be handled by the assignment expression binding validation
187+
attribute.expression.type !== 'SequenceExpression'
188+
) {
189+
const left = object(attribute.expression);
190+
const binding = left && context.state.scope.get(left.name);
191+
192+
if (binding?.kind === 'bindable_prop' || binding?.kind === 'prop') {
193+
context.state.analysis.needs_mutation_validation = true;
194+
binding_initializers.push(
195+
b.stmt(
196+
b.call(
197+
'$$ownership_validator.binding',
198+
b.literal(binding.node.name),
199+
b.id(component_name),
200+
b.thunk(expression)
201+
)
192202
)
193-
)
194-
);
203+
);
204+
}
195205
}
196206

197207
if (expression.type === 'SequenceExpression') {

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

+60-3
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 { dev, is_ignored, locator } from '../../../../../state.js';
1111
import { create_derived } from '../../utils.js';
1212

1313
/**
@@ -295,3 +295,60 @@ 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+
let left = /** @type {Expression | Super} */ (
307+
node.type === 'AssignmentExpression' ? node.left : node.argument
308+
);
309+
310+
if (!dev || left.type !== 'MemberExpression' || is_ignored(node, 'ownership_invalid_mutation')) {
311+
return expression;
312+
}
313+
314+
const name = object(left);
315+
if (!name) return expression;
316+
317+
const binding = context.state.scope.get(name.name);
318+
if (binding?.kind !== 'prop' && binding?.kind !== 'bindable_prop') return expression;
319+
320+
const state = /** @type {ComponentClientTransformState} */ (context.state);
321+
state.analysis.needs_mutation_validation = true;
322+
323+
/** @type {Array<Identifier | Literal>} */
324+
const path = [];
325+
326+
while (left.type === 'MemberExpression') {
327+
if (left.property.type === 'Literal') {
328+
path.unshift(left.property);
329+
} else if (left.property.type === 'Identifier') {
330+
if (left.computed) {
331+
path.unshift(left.property);
332+
} else {
333+
path.unshift(b.literal(left.property.name));
334+
}
335+
} else {
336+
return expression;
337+
}
338+
339+
left = left.object;
340+
}
341+
342+
path.unshift(b.literal(name.name));
343+
344+
const loc = locator(/** @type {number} */ (left.start));
345+
346+
return b.call(
347+
'$$ownership_validator.mutation',
348+
b.literal(binding.prop_alias),
349+
b.array(path),
350+
expression,
351+
loc && b.literal(loc.line),
352+
loc && b.literal(loc.column)
353+
);
354+
}

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

+1
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/constants.js

-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,5 @@ export const EFFECT_HAS_DERIVED = 1 << 20;
2323
export const EFFECT_IS_UPDATING = 1 << 21;
2424

2525
export const STATE_SYMBOL = Symbol('$state');
26-
export const STATE_SYMBOL_METADATA = Symbol('$state metadata');
2726
export const LEGACY_PROPS = Symbol('legacy props');
2827
export const LOADING_ATTR_SYMBOL = Symbol('');

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

-10
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/** @import { ComponentContext } from '#client' */
22

33
import { DEV } from 'esm-env';
4-
import { add_owner } from './dev/ownership.js';
54
import { lifecycle_outside_component } from '../shared/errors.js';
65
import { source } from './reactivity/sources.js';
76
import {
@@ -67,15 +66,6 @@ export function getContext(key) {
6766
*/
6867
export function setContext(key, context) {
6968
const context_map = get_or_init_context_map('setContext');
70-
71-
if (DEV) {
72-
// When state is put into context, we treat as if it's global from now on.
73-
// We do for performance reasons (it's for example very expensive to call
74-
// getContext on a big object many times when part of a list component)
75-
// and danger of false positives.
76-
untrack(() => add_owner(context, null, true));
77-
}
78-
7969
context_map.set(key, context);
8070
return context;
8171
}

0 commit comments

Comments
 (0)