Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/sweet-ants-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: rework binding ownership validation
8 changes: 2 additions & 6 deletions documentation/docs/98-reference/.generated/client-warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ Tried to unmount a component that was not mounted
### ownership_invalid_binding

```
%parent% passed a value to %child% with `bind:`, but the value is owned by %owner%. Consider creating a binding between %owner% and %parent%
%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%={...}`)
```

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.
Expand All @@ -171,11 +171,7 @@ To fix it, `bind:` to the value instead of just passing a property (i.e. in this
### ownership_invalid_mutation

```
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
```

```
%component% mutated a value owned by %owner%. This is strongly discouraged. Consider passing values to child components with `bind:`, or use a callback instead
Mutating unbound props (`%name%`, at %location%) is strongly discouraged. Consider using `bind:%prop%={...}` in %parent% (or using a callback) instead
```

Consider the following code:
Expand Down
6 changes: 2 additions & 4 deletions packages/svelte/messages/client-warnings/warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,17 +132,15 @@ During development, this error is often preceeded by a `console.error` detailing

## ownership_invalid_binding

> %parent% passed a value to %child% with `bind:`, but the value is owned by %owner%. Consider creating a binding between %owner% and %parent%
> %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%={...}`)

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.

To fix it, `bind:` to the value instead of just passing a property (i.e. in this example do `<Parent bind:value />`).

## ownership_invalid_mutation

> 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

> %component% mutated a value owned by %owner%. This is strongly discouraged. Consider passing values to child components with `bind:`, or use a callback instead
> Mutating unbound props (`%name%`, at %location%) is strongly discouraged. Consider using `bind:%prop%={...}` in %parent% (or using a callback) instead

Consider the following code:

Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/compiler/phases/2-analyze/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ export function analyze_component(root, source, options) {
uses_component_bindings: false,
uses_render_tags: false,
needs_context: false,
needs_mutation_validation: false,
needs_props: false,
event_directive_node: null,
uses_event_attributes: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,12 @@ export function client_component(analysis, options) {
);
}

if (analysis.needs_mutation_validation) {
component_block.body.unshift(
b.var('$$ownership_validator', b.call('$.create_ownership_validator', b.id('$$props')))
);
}

const should_inject_context =
dev ||
analysis.needs_context ||
Expand Down
33 changes: 29 additions & 4 deletions packages/svelte/src/compiler/phases/3-transform/client/utils.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/** @import { ArrowFunctionExpression, Expression, FunctionDeclaration, FunctionExpression, Identifier, Pattern, PrivateIdentifier, Statement } from 'estree' */
/** @import { AST, Binding } from '#compiler' */
/** @import { ArrowFunctionExpression, AssignmentExpression, Expression, FunctionDeclaration, FunctionExpression, Identifier, Node, Pattern, UpdateExpression } from 'estree' */
/** @import { Binding } from '#compiler' */
/** @import { ClientTransformState, ComponentClientTransformState, ComponentContext } from './types.js' */
/** @import { Analysis } from '../../types.js' */
/** @import { Scope } from '../../scope.js' */
import * as b from '../../../utils/builders.js';
import { extract_identifiers, is_simple_expression } from '../../../utils/ast.js';
import { is_simple_expression } from '../../../utils/ast.js';
import {
PROPS_IS_LAZY_INITIAL,
PROPS_IS_IMMUTABLE,
Expand All @@ -13,7 +13,8 @@ import {
PROPS_IS_BINDABLE
} from '../../../../constants.js';
import { dev } from '../../../state.js';
import { get_value } from './visitors/shared/declarations.js';
import { walk } from 'zimmerframe';
import { validate_mutation } from './visitors/shared/utils.js';

/**
* @param {Binding} binding
Expand Down Expand Up @@ -110,6 +111,30 @@ function get_hoisted_params(node, context) {
}
}
}

if (dev) {
// this is a little hacky, but necessary for ownership validation
// to work inside hoisted event handlers

/**
* @param {AssignmentExpression | UpdateExpression} node
* @param {{ next: () => void, stop: () => void }} context
*/
function visit(node, { next, stop }) {
if (validate_mutation(node, /** @type {any} */ (context), node) !== node) {
params.push(b.id('$$ownership_validator'));
stop();
} else {
next();
}
}

walk(/** @type {Node} */ (node), null, {
AssignmentExpression: visit,
UpdateExpression: visit
});
}

return params;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import {
get_attribute_expression,
is_event_attribute
} from '../../../../utils/ast.js';
import { dev, is_ignored, locate_node } from '../../../../state.js';
import { dev, locate_node } from '../../../../state.js';
import { should_proxy } from '../utils.js';
import { visit_assignment_expression } from '../../shared/assignments.js';
import { validate_mutation } from './shared/utils.js';

/**
* @param {AssignmentExpression} node
Expand All @@ -20,9 +21,7 @@ export function AssignmentExpression(node, context) {
visit_assignment_expression(node, context, build_assignment) ?? context.next()
);

return is_ignored(node, 'ownership_invalid_mutation')
? b.call('$.skip_ownership_validation', b.thunk(expression))
: expression;
return validate_mutation(node, context, expression);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,33 +161,6 @@ export function ClassBody(node, context) {
body.push(/** @type {MethodDefinition} **/ (context.visit(definition, child_state)));
}

if (dev && public_state.size > 0) {
// add an `[$.ADD_OWNER]` method so that a class with state fields can widen ownership
body.push(
b.method(
'method',
b.id('$.ADD_OWNER'),
[b.id('owner')],
[
b.stmt(
b.call(
'$.add_owner_to_class',
b.this,
b.id('owner'),
b.array(
Array.from(public_state).map(([name]) =>
b.thunk(b.call('$.get', b.member(b.this, b.private_id(name))))
)
),
is_ignored(node, 'ownership_invalid_binding') && b.true
)
)
],
true
)
);
}

return { ...node, body };
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/** @import { AssignmentExpression, Expression, UpdateExpression } from 'estree' */
/** @import { Context } from '../types' */
import { is_ignored } from '../../../../state.js';
import { object } from '../../../../utils/ast.js';
import * as b from '../../../../utils/builders.js';
import { validate_mutation } from './shared/utils.js';

/**
* @param {UpdateExpression} node
Expand Down Expand Up @@ -51,7 +51,5 @@ export function UpdateExpression(node, context) {
);
}

return is_ignored(node, 'ownership_invalid_mutation')
? b.call('$.skip_ownership_validation', b.thunk(update))
: update;
return validate_mutation(node, context, update);
}
Original file line number Diff line number Diff line change
Expand Up @@ -179,19 +179,29 @@ export function build_component(node, component_name, context, anchor = context.
} else if (attribute.type === 'BindDirective') {
const expression = /** @type {Expression} */ (context.visit(attribute.expression));

if (dev && attribute.name !== 'this') {
binding_initializers.push(
b.stmt(
b.call(
b.id('$.add_owner_effect'),
expression.type === 'SequenceExpression'
? expression.expressions[0]
: b.thunk(expression),
b.id(component_name),
is_ignored(node, 'ownership_invalid_binding') && b.true
if (
dev &&
attribute.name !== 'this' &&
!is_ignored(node, 'ownership_invalid_binding') &&
// bind:x={() => x.y, y => x.y = y} will be handled by the assignment expression binding validation
attribute.expression.type !== 'SequenceExpression'
) {
const left = object(attribute.expression);
const binding = left && context.state.scope.get(left.name);

if (binding?.kind === 'bindable_prop' || binding?.kind === 'prop') {
context.state.analysis.needs_mutation_validation = true;
binding_initializers.push(
b.stmt(
b.call(
'$$ownership_validator.binding',
b.literal(binding.node.name),
b.id(component_name),
b.thunk(expression)
)
)
)
);
);
}
}

if (expression.type === 'SequenceExpression') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/** @import { Expression, ExpressionStatement, Identifier, MemberExpression, SequenceExpression, Statement, Super } from 'estree' */
/** @import { AssignmentExpression, Expression, ExpressionStatement, Identifier, MemberExpression, SequenceExpression, Literal, Super, UpdateExpression } from 'estree' */
/** @import { AST, ExpressionMetadata } from '#compiler' */
/** @import { ComponentClientTransformState } from '../../types' */
/** @import { ComponentClientTransformState, Context } from '../../types' */
import { walk } from 'zimmerframe';
import { object } from '../../../../../utils/ast.js';
import * as b from '../../../../../utils/builders.js';
import { sanitize_template_string } from '../../../../../utils/sanitize_template_string.js';
import { regex_is_valid_identifier } from '../../../../patterns.js';
import is_reference from 'is-reference';
import { locator } from '../../../../../state.js';
import { dev, is_ignored, locator } from '../../../../../state.js';
import { create_derived } from '../../utils.js';

/**
Expand Down Expand Up @@ -295,3 +295,60 @@ export function validate_binding(state, binding, expression) {
)
);
}

/**
* In dev mode validate mutations to props
* @param {AssignmentExpression | UpdateExpression} node
* @param {Context} context
* @param {Expression} expression
*/
export function validate_mutation(node, context, expression) {
let left = /** @type {Expression | Super} */ (
node.type === 'AssignmentExpression' ? node.left : node.argument
);

if (!dev || left.type !== 'MemberExpression' || is_ignored(node, 'ownership_invalid_mutation')) {
return expression;
}

const name = object(left);
if (!name) return expression;

const binding = context.state.scope.get(name.name);
if (binding?.kind !== 'prop' && binding?.kind !== 'bindable_prop') return expression;

const state = /** @type {ComponentClientTransformState} */ (context.state);
state.analysis.needs_mutation_validation = true;

/** @type {Array<Identifier | Literal>} */
const path = [];

while (left.type === 'MemberExpression') {
if (left.property.type === 'Literal') {
path.unshift(left.property);
} else if (left.property.type === 'Identifier') {
if (left.computed) {
path.unshift(left.property);
} else {
path.unshift(b.literal(left.property.name));
}
} else {
return expression;
}

left = left.object;
}

path.unshift(b.literal(name.name));

const loc = locator(/** @type {number} */ (left.start));

return b.call(
'$$ownership_validator.mutation',
b.literal(binding.prop_alias),
b.array(path),
expression,
loc && b.literal(loc.line),
loc && b.literal(loc.column)
);
}
1 change: 1 addition & 0 deletions packages/svelte/src/compiler/phases/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface ComponentAnalysis extends Analysis {
uses_component_bindings: boolean;
uses_render_tags: boolean;
needs_context: boolean;
needs_mutation_validation: boolean;
needs_props: boolean;
/** Set to the first event directive (on:x) found on a DOM element in the code */
event_directive_node: AST.OnDirective | null;
Expand Down
1 change: 0 additions & 1 deletion packages/svelte/src/internal/client/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,5 @@ export const EFFECT_HAS_DERIVED = 1 << 20;
export const EFFECT_IS_UPDATING = 1 << 21;

export const STATE_SYMBOL = Symbol('$state');
export const STATE_SYMBOL_METADATA = Symbol('$state metadata');
export const LEGACY_PROPS = Symbol('legacy props');
export const LOADING_ATTR_SYMBOL = Symbol('');
10 changes: 0 additions & 10 deletions packages/svelte/src/internal/client/context.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/** @import { ComponentContext } from '#client' */

import { DEV } from 'esm-env';
import { add_owner } from './dev/ownership.js';
import { lifecycle_outside_component } from '../shared/errors.js';
import { source } from './reactivity/sources.js';
import {
Expand Down Expand Up @@ -67,15 +66,6 @@ export function getContext(key) {
*/
export function setContext(key, context) {
const context_map = get_or_init_context_map('setContext');

if (DEV) {
// When state is put into context, we treat as if it's global from now on.
// We do for performance reasons (it's for example very expensive to call
// getContext on a big object many times when part of a list component)
// and danger of false positives.
untrack(() => add_owner(context, null, true));
}

context_map.set(key, context);
return context;
}
Expand Down
Loading