Skip to content

Commit fe0bd29

Browse files
authored
fix: proxify values when assigning using ||=, &&= and ??= operators (#14273)
* add failing test for #14268 * simplify * proxify values when using ||=, &&= and ??= assignment operators * proxify values assigned to private state fields * changeset * fix * fix * add warning * update test
1 parent bbee1fc commit fe0bd29

File tree

11 files changed

+287
-10
lines changed

11 files changed

+287
-10
lines changed

.changeset/shaggy-spies-happen.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: proxify values when assigning using `||=`, `&&=` and `??=` operators

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,37 @@
11
<!-- This file is generated by scripts/process-messages/index.js. Do not edit! -->
22

3+
### assignment_value_stale
4+
5+
```
6+
Assignment to `%property%` property (%location%) will evaluate to the right-hand side, not the value of `%property%` following the assignment. This may result in unexpected behaviour.
7+
```
8+
9+
Given a case like this...
10+
11+
```svelte
12+
<script>
13+
let object = $state({ array: null });
14+
15+
function add() {
16+
(object.array ??= []).push(object.array.length);
17+
}
18+
</script>
19+
20+
<button onclick={add}>add</button>
21+
<p>items: {JSON.stringify(object.items)}</p>
22+
```
23+
24+
...the array being pushed to when the button is first clicked is the `[]` on the right-hand side of the assignment, but the resulting value of `object.array` is an empty state proxy. As a result, the pushed value will be discarded.
25+
26+
You can fix this by separating it into two statements:
27+
28+
```js
29+
function add() {
30+
object.array ??= [];
31+
object.array.push(object.array.length);
32+
}
33+
```
34+
335
### binding_property_non_reactive
436
537
```

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,33 @@
1+
## assignment_value_stale
2+
3+
> Assignment to `%property%` property (%location%) will evaluate to the right-hand side, not the value of `%property%` following the assignment. This may result in unexpected behaviour.
4+
5+
Given a case like this...
6+
7+
```svelte
8+
<script>
9+
let object = $state({ array: null });
10+
11+
function add() {
12+
(object.array ??= []).push(object.array.length);
13+
}
14+
</script>
15+
16+
<button onclick={add}>add</button>
17+
<p>items: {JSON.stringify(object.items)}</p>
18+
```
19+
20+
...the array being pushed to when the button is first clicked is the `[]` on the right-hand side of the assignment, but the resulting value of `object.array` is an empty state proxy. As a result, the pushed value will be discarded.
21+
22+
You can fix this by separating it into two statements:
23+
24+
```js
25+
function add() {
26+
object.array ??= [];
27+
object.array.push(object.array.length);
28+
}
29+
```
30+
131
## binding_property_non_reactive
232
333
> `%binding%` is binding to a non-reactive property

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

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
/** @import { AssignmentExpression, AssignmentOperator, Expression, Pattern } from 'estree' */
1+
/** @import { Location } from 'locate-character' */
2+
/** @import { AssignmentExpression, AssignmentOperator, Expression, Identifier, Literal, MemberExpression, Pattern } from 'estree' */
3+
/** @import { AST } from '#compiler' */
24
/** @import { Context } from '../types.js' */
35
import * as b from '../../../../utils/builders.js';
4-
import { build_assignment_value } from '../../../../utils/ast.js';
5-
import { is_ignored } from '../../../../state.js';
6+
import {
7+
build_assignment_value,
8+
get_attribute_expression,
9+
is_event_attribute
10+
} from '../../../../utils/ast.js';
11+
import { dev, filename, is_ignored, locator } from '../../../../state.js';
612
import { build_proxy_reassignment, should_proxy } from '../utils.js';
713
import { visit_assignment_expression } from '../../shared/assignments.js';
814

@@ -20,6 +26,24 @@ export function AssignmentExpression(node, context) {
2026
: expression;
2127
}
2228

29+
/**
30+
* Determines whether the value will be coerced on assignment (as with e.g. `+=`).
31+
* If not, we may need to proxify the value, or warn that the value will not be
32+
* proxified in time
33+
* @param {AssignmentOperator} operator
34+
*/
35+
function is_non_coercive_operator(operator) {
36+
return ['=', '||=', '&&=', '??='].includes(operator);
37+
}
38+
39+
/** @type {Record<string, string>} */
40+
const callees = {
41+
'=': '$.assign',
42+
'&&=': '$.assign_and',
43+
'||=': '$.assign_or',
44+
'??=': '$.assign_nullish'
45+
};
46+
2347
/**
2448
* @param {AssignmentOperator} operator
2549
* @param {Pattern} left
@@ -41,7 +65,11 @@ function build_assignment(operator, left, right, context) {
4165
context.visit(build_assignment_value(operator, left, right))
4266
);
4367

44-
if (private_state.kind !== 'raw_state' && should_proxy(value, context.state.scope)) {
68+
if (
69+
private_state.kind === 'state' &&
70+
is_non_coercive_operator(operator) &&
71+
should_proxy(value, context.state.scope)
72+
) {
4573
value = build_proxy_reassignment(value, b.member(b.this, private_state.id));
4674
}
4775

@@ -73,24 +101,28 @@ function build_assignment(operator, left, right, context) {
73101
? context.state.transform[object.name]
74102
: null;
75103

104+
const path = context.path.map((node) => node.type);
105+
76106
// reassignment
77107
if (object === left && transform?.assign) {
108+
// special case — if an element binding, we know it's a primitive
109+
110+
const is_primitive = path.at(-1) === 'BindDirective' && path.at(-2) === 'RegularElement';
111+
78112
let value = /** @type {Expression} */ (
79113
context.visit(build_assignment_value(operator, left, right))
80114
);
81115

82-
// special case — if an element binding, we know it's a primitive
83-
const path = context.path.map((node) => node.type);
84-
const is_primitive = path.at(-1) === 'BindDirective' && path.at(-2) === 'RegularElement';
85-
86116
if (
87117
!is_primitive &&
88118
binding.kind !== 'prop' &&
89119
binding.kind !== 'bindable_prop' &&
120+
binding.kind !== 'raw_state' &&
90121
context.state.analysis.runes &&
91-
should_proxy(value, context.state.scope)
122+
should_proxy(right, context.state.scope) &&
123+
is_non_coercive_operator(operator)
92124
) {
93-
value = binding.kind === 'raw_state' ? value : build_proxy_reassignment(value, object);
125+
value = build_proxy_reassignment(value, object);
94126
}
95127

96128
return transform.assign(object, value);
@@ -108,5 +140,57 @@ function build_assignment(operator, left, right, context) {
108140
);
109141
}
110142

143+
// in cases like `(object.items ??= []).push(value)`, we may need to warn
144+
// if the value gets proxified, since the proxy _isn't_ the thing that
145+
// will be pushed to. we do this by transforming it to something like
146+
// `$.assign_nullish(object, 'items', [])`
147+
let should_transform =
148+
dev && path.at(-1) !== 'ExpressionStatement' && is_non_coercive_operator(operator);
149+
150+
// special case — ignore `onclick={() => (...)}`
151+
if (
152+
path.at(-1) === 'ArrowFunctionExpression' &&
153+
(path.at(-2) === 'RegularElement' || path.at(-2) === 'SvelteElement')
154+
) {
155+
const element = /** @type {AST.RegularElement} */ (context.path.at(-2));
156+
157+
const attribute = element.attributes.find((attribute) => {
158+
if (attribute.type !== 'Attribute' || !is_event_attribute(attribute)) {
159+
return false;
160+
}
161+
162+
const expression = get_attribute_expression(attribute);
163+
164+
return expression === context.path.at(-1);
165+
});
166+
167+
if (attribute) {
168+
should_transform = false;
169+
}
170+
}
171+
172+
if (left.type === 'MemberExpression' && should_transform) {
173+
const callee = callees[operator];
174+
175+
const loc = /** @type {Location} */ (locator(/** @type {number} */ (left.start)));
176+
const location = `${filename}:${loc.line}:${loc.column}`;
177+
178+
return /** @type {Expression} */ (
179+
context.visit(
180+
b.call(
181+
callee,
182+
/** @type {Expression} */ (left.object),
183+
/** @type {Expression} */ (
184+
left.computed
185+
? left.property
186+
: b.literal(/** @type {Identifier} */ (left.property).name)
187+
),
188+
right,
189+
b.literal(location)
190+
)
191+
)
192+
);
193+
}
194+
111195
return null;
112196
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import * as w from '../warnings.js';
2+
import { sanitize_location } from './location.js';
3+
4+
/**
5+
*
6+
* @param {any} a
7+
* @param {any} b
8+
* @param {string} property
9+
* @param {string} location
10+
*/
11+
function compare(a, b, property, location) {
12+
if (a !== b) {
13+
w.assignment_value_stale(property, /** @type {string} */ (sanitize_location(location)));
14+
}
15+
16+
return a;
17+
}
18+
19+
/**
20+
* @param {any} object
21+
* @param {string} property
22+
* @param {any} value
23+
* @param {string} location
24+
*/
25+
export function assign(object, property, value, location) {
26+
return compare((object[property] = value), object[property], property, location);
27+
}
28+
29+
/**
30+
* @param {any} object
31+
* @param {string} property
32+
* @param {any} value
33+
* @param {string} location
34+
*/
35+
export function assign_and(object, property, value, location) {
36+
return compare((object[property] &&= value), object[property], property, location);
37+
}
38+
39+
/**
40+
* @param {any} object
41+
* @param {string} property
42+
* @param {any} value
43+
* @param {string} location
44+
*/
45+
export function assign_or(object, property, value, location) {
46+
return compare((object[property] ||= value), object[property], property, location);
47+
}
48+
49+
/**
50+
* @param {any} object
51+
* @param {string} property
52+
* @param {any} value
53+
* @param {string} location
54+
*/
55+
export function assign_nullish(object, property, value, location) {
56+
return compare((object[property] ??= value), object[property], property, location);
57+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { FILENAME, HMR, NAMESPACE_SVG } from '../../constants.js';
2+
export { assign, assign_and, assign_or, assign_nullish } from './dev/assign.js';
23
export { cleanup_styles } from './dev/css.js';
34
export { add_locations } from './dev/elements.js';
45
export { hmr } from './dev/hmr.js';

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+
* Assignment to `%property%` property (%location%) will evaluate to the right-hand side, not the value of `%property%` following the assignment. This may result in unexpected behaviour.
10+
* @param {string} property
11+
* @param {string} location
12+
*/
13+
export function assignment_value_stale(property, location) {
14+
if (DEV) {
15+
console.warn(`%c[svelte] assignment_value_stale\n%cAssignment to \`${property}\` property (${location}) will evaluate to the right-hand side, not the value of \`${property}\` following the assignment. This may result in unexpected behaviour.`, bold, normal);
16+
} else {
17+
// TODO print a link to the documentation
18+
console.warn("assignment_value_stale");
19+
}
20+
}
21+
822
/**
923
* `%binding%` (%location%) is binding to a non-reactive property
1024
* @param {string} binding
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { flushSync } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
compileOptions: {
6+
dev: true
7+
},
8+
9+
html: `<button>items: null</button>`,
10+
11+
test({ assert, target, warnings }) {
12+
const btn = target.querySelector('button');
13+
14+
flushSync(() => btn?.click());
15+
assert.htmlEqual(target.innerHTML, `<button>items: []</button>`);
16+
17+
flushSync(() => btn?.click());
18+
assert.htmlEqual(target.innerHTML, `<button>items: [0]</button>`);
19+
20+
assert.deepEqual(warnings, [
21+
'Assignment to `items` property (main.svelte:5:24) will evaluate to the right-hand side, not the value of `items` following the assignment. This may result in unexpected behaviour.'
22+
]);
23+
}
24+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<script>
2+
let object = $state({ items: null });
3+
</script>
4+
5+
<button onclick={() => (object.items ??= []).push(object.items.length)}>
6+
items: {JSON.stringify(object.items)}
7+
</button>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { flushSync } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
html: `<button>items: null</button>`,
6+
7+
test({ assert, target }) {
8+
const [btn1, btn2] = target.querySelectorAll('button');
9+
10+
flushSync(() => btn1.click());
11+
assert.htmlEqual(target.innerHTML, `<button>items: [0]</button>`);
12+
13+
flushSync(() => btn1.click());
14+
assert.htmlEqual(target.innerHTML, `<button>items: [0,1]</button>`);
15+
}
16+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<script>
2+
let items = $state(null);
3+
</script>
4+
5+
<button onclick={() => (items ??= []).push(items.length)}>
6+
items: {JSON.stringify(items)}
7+
</button>

0 commit comments

Comments
 (0)