Skip to content

Commit 2ce2b7d

Browse files
authored
feat: warn if binding to a non-reactive property (#12500)
* feat: warn if binding to a non-reactive property * tweak
1 parent dd9ade7 commit 2ce2b7d

File tree

9 files changed

+202
-3
lines changed

9 files changed

+202
-3
lines changed

.changeset/nasty-mayflies-smoke.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+
feat: warn if binding to a non-reactive property

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## binding_property_non_reactive
2+
3+
> `%binding%` is binding to a non-reactive property
4+
5+
> `%binding%` (%location%) is binding to a non-reactive property
6+
17
## hydration_attribute_changed
28

39
> The `%attribute%` attribute on `%html%` changed its value between server and client renders. The client value, `%value%`, will be ignored in favour of the server value

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

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
/** @import { BlockStatement, CallExpression, Expression, ExpressionStatement, Identifier, Literal, MemberExpression, ObjectExpression, Pattern, Property, Statement, Super, TemplateElement, TemplateLiteral } from 'estree' */
2+
/** @import { BindDirective } from '#compiler' */
3+
/** @import { ComponentClientTransformState } from '../types' */
24
import {
35
extract_identifiers,
46
extract_paths,
@@ -776,11 +778,15 @@ function serialize_inline_component(node, component_name, context, anchor = cont
776778
push_prop(b.init(attribute.name, value));
777779
}
778780
} else if (attribute.type === 'BindDirective') {
781+
const expression = /** @type {Expression} */ (context.visit(attribute.expression));
782+
783+
if (expression.type === 'MemberExpression' && context.state.options.dev) {
784+
context.state.init.push(serialize_validate_binding(context.state, attribute, expression));
785+
}
786+
779787
if (attribute.name === 'this') {
780788
bind_this = attribute.expression;
781789
} else {
782-
const expression = /** @type {Expression} */ (context.visit(attribute.expression));
783-
784790
if (context.state.options.dev) {
785791
binding_initializers.push(
786792
b.stmt(b.call(b.id('$.add_owner_effect'), b.thunk(expression), b.id(component_name)))
@@ -2824,6 +2830,17 @@ export const template_visitors = {
28242830
BindDirective(node, context) {
28252831
const { state, path, visit } = context;
28262832
const expression = node.expression;
2833+
2834+
if (expression.type === 'MemberExpression' && context.state.options.dev) {
2835+
context.state.init.push(
2836+
serialize_validate_binding(
2837+
context.state,
2838+
node,
2839+
/**@type {MemberExpression} */ (visit(expression))
2840+
)
2841+
);
2842+
}
2843+
28272844
const getter = b.thunk(/** @type {Expression} */ (visit(expression)));
28282845
const assignment = b.assignment('=', expression, b.id('$$value'));
28292846
const setter = b.arrow(
@@ -3230,3 +3247,34 @@ export const template_visitors = {
32303247
CallExpression: javascript_visitors_runes.CallExpression,
32313248
VariableDeclaration: javascript_visitors_runes.VariableDeclaration
32323249
};
3250+
3251+
/**
3252+
* @param {import('../types.js').ComponentClientTransformState} state
3253+
* @param {BindDirective} binding
3254+
* @param {MemberExpression} expression
3255+
*/
3256+
function serialize_validate_binding(state, binding, expression) {
3257+
const string = state.analysis.source.slice(binding.start, binding.end);
3258+
3259+
const get_object = b.thunk(/** @type {Expression} */ (expression.object));
3260+
const get_property = b.thunk(
3261+
/** @type {Expression} */ (
3262+
expression.computed
3263+
? expression.property
3264+
: b.literal(/** @type {Identifier} */ (expression.property).name)
3265+
)
3266+
);
3267+
3268+
const loc = locator(binding.start);
3269+
3270+
return b.stmt(
3271+
b.call(
3272+
'$.validate_binding',
3273+
b.literal(string),
3274+
get_object,
3275+
get_property,
3276+
loc && b.literal(loc.line),
3277+
loc && b.literal(loc.column)
3278+
)
3279+
);
3280+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export {
146146
hasContext
147147
} from './runtime.js';
148148
export {
149+
validate_binding,
149150
validate_dynamic_component,
150151
validate_each_keys,
151152
validate_prop_bindings

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

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { untrack } from './runtime.js';
1+
import { dev_current_component_function, untrack } from './runtime.js';
22
import { get_descriptor, is_array } from '../shared/utils.js';
33
import * as e from './errors.js';
44
import { FILENAME } from '../../constants.js';
5+
import { render_effect } from './reactivity/effects.js';
6+
import * as w from './warnings.js';
57

68
/** regex of all html void element names */
79
const void_element_names =
@@ -89,3 +91,44 @@ export function validate_prop_bindings($$props, bindable, exports, component) {
8991
}
9092
}
9193
}
94+
95+
/**
96+
* @param {string} binding
97+
* @param {() => Record<string, any>} get_object
98+
* @param {() => string} get_property
99+
* @param {number} line
100+
* @param {number} column
101+
*/
102+
export function validate_binding(binding, get_object, get_property, line, column) {
103+
var warned = false;
104+
105+
var filename = dev_current_component_function?.[FILENAME];
106+
107+
render_effect(() => {
108+
if (warned) return;
109+
110+
var object = get_object();
111+
var property = get_property();
112+
113+
var ran = false;
114+
115+
// by making the (possibly false, but it would be an extreme edge case) assumption
116+
// that a getter has a corresponding setter, we can determine if a property is
117+
// reactive by seeing if this effect has dependencies
118+
var effect = render_effect(() => {
119+
if (ran) return;
120+
121+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
122+
object[property];
123+
});
124+
125+
ran = true;
126+
127+
if (effect.deps === null) {
128+
var location = filename && `${filename}:${line}:${column}`;
129+
w.binding_property_non_reactive(binding, location);
130+
131+
warned = true;
132+
}
133+
});
134+
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ import { DEV } from 'esm-env';
55
var bold = 'font-weight: bold';
66
var normal = 'font-weight: normal';
77

8+
/**
9+
* `%binding%` (%location%) is binding to a non-reactive property
10+
* @param {string} binding
11+
* @param {string | undefined | null} [location]
12+
*/
13+
export function binding_property_non_reactive(binding, location) {
14+
if (DEV) {
15+
console.warn(`%c[svelte] binding_property_non_reactive\n%c${location ? `\`${binding}\` (${location}) is binding to a non-reactive property` : `\`${binding}\` is binding to a non-reactive property`}`, bold, normal);
16+
} else {
17+
// TODO print a link to the documentation
18+
console.warn("binding_property_non_reactive");
19+
}
20+
}
21+
822
/**
923
* The `%attribute%` attribute on `%html%` changed its value between server and client renders. The client value, `%value%`, will be ignored in favour of the server value
1024
* @param {string} attribute
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script lang="ts">
2+
let { value = $bindable() }: { value: number } = $props();
3+
</script>
4+
5+
<p>{value}</p>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
compileOptions: {
5+
dev: true
6+
},
7+
8+
async test({ assert, warnings }) {
9+
assert.deepEqual(warnings, [
10+
`\`bind:value={pojo.value}\` (main.svelte:50:7) is binding to a non-reactive property`,
11+
`\`bind:value={frozen.value}\` (main.svelte:51:7) is binding to a non-reactive property`,
12+
`\`bind:value={pojo.value}\` (main.svelte:52:7) is binding to a non-reactive property`,
13+
`\`bind:value={frozen.value}\` (main.svelte:53:7) is binding to a non-reactive property`
14+
]);
15+
}
16+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<script>
2+
import Child from './Child.svelte';
3+
4+
let pojo = {
5+
value: 1
6+
};
7+
8+
let frozen = $state.frozen({
9+
value: 2
10+
});
11+
12+
let reactive = $state({
13+
value: 3
14+
});
15+
16+
let value = $state(4);
17+
let accessors = {
18+
get value() {
19+
return value;
20+
},
21+
set value(v) {
22+
value = v;
23+
}
24+
};
25+
26+
let proxied = $state(5);
27+
let proxy = new Proxy(
28+
{},
29+
{
30+
get(target, prop, receiver) {
31+
if (prop === 'value') {
32+
return proxied;
33+
}
34+
35+
return Reflect.get(target, prop, receiver);
36+
},
37+
set(target, prop, value, receiver) {
38+
if (prop === 'value') {
39+
proxied = value;
40+
return true;
41+
}
42+
43+
return Reflect.set(target, prop, value, receiver);
44+
}
45+
}
46+
);
47+
</script>
48+
49+
<!-- should warn -->
50+
<input bind:value={pojo.value} />
51+
<input bind:value={frozen.value} />
52+
<Child bind:value={pojo.value} />
53+
<Child bind:value={frozen.value} />
54+
55+
<!-- should not warn -->
56+
<input bind:value={reactive.value} />
57+
<input bind:value={accessors.value} />
58+
<input bind:value={proxy.value} />
59+
<Child bind:value={reactive.value} />
60+
<Child bind:value={accessors.value} />
61+
<Child bind:value={proxy.value} />

0 commit comments

Comments
 (0)