feat: provide $state warnings for accidental equality (#11610)

* feat: provide $state warnings for accidental equality

* tune

* tune

* tune

* adjust test

* fix treeshaking

* fix bugs

* fix bugs

* refactor

* revert test changes

* tune

* tune

* tune

* tune

* fix up

* fix

* remove if(DEV) stuff

* use console.trace, like we do for ownership warnings

* tweak

* tweak message, simplify logic

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/11621/head
Dominic Gannaway 7 months ago committed by GitHub
parent f488a6e84a
commit 7ef686f8ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
feat: provide $state warnings for accidental equality

@ -19,3 +19,7 @@
> 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
## state_proxy_equality_mismatch
> Reactive `$state(...)` proxies and the values they proxy have different identities. Because of this, comparisons with `%operator%` will produce unexpected results. Consider using `$state.is(a, b)` instead

@ -451,6 +451,31 @@ export const javascript_visitors_runes = {
}
context.next();
},
BinaryExpression(node, { state, visit, next }) {
const operator = node.operator;
if (state.options.dev) {
if (operator === '===' || operator === '!==') {
return b.call(
'$.strict_equals',
/** @type {import('estree').Expression} */ (visit(node.left)),
/** @type {import('estree').Expression} */ (visit(node.right)),
operator === '!==' && b.literal(false)
);
}
if (operator === '==' || operator === '!=') {
return b.call(
'$.equals',
/** @type {import('estree').Expression} */ (visit(node.left)),
/** @type {import('estree').Expression} */ (visit(node.right)),
operator === '!=' && b.literal(false)
);
}
}
next();
}
};

@ -0,0 +1,92 @@
import * as w from '../warnings.js';
import { get_proxied_value } from '../proxy.js';
export function init_array_prototype_warnings() {
const array_prototype = Array.prototype;
const { indexOf, lastIndexOf, includes } = array_prototype;
array_prototype.indexOf = function (item, from_index) {
const index = indexOf.call(this, item, from_index);
if (index === -1) {
const test = indexOf.call(get_proxied_value(this), get_proxied_value(item), from_index);
if (test !== -1) {
w.state_proxy_equality_mismatch('array.indexOf(...)');
// eslint-disable-next-line no-console
console.trace();
}
}
return index;
};
array_prototype.lastIndexOf = function (item, from_index) {
const index = lastIndexOf.call(this, item, from_index);
if (index === -1) {
const test = lastIndexOf.call(get_proxied_value(this), get_proxied_value(item), from_index);
if (test !== -1) {
w.state_proxy_equality_mismatch('array.lastIndexOf(...)');
// eslint-disable-next-line no-console
console.trace();
}
}
return index;
};
array_prototype.includes = function (item, from_index) {
const has = includes.call(this, item, from_index);
if (!has) {
const test = includes.call(get_proxied_value(this), get_proxied_value(item), from_index);
if (test) {
w.state_proxy_equality_mismatch('array.includes(...)');
// eslint-disable-next-line no-console
console.trace();
}
}
return has;
};
}
/**
* @param {any} a
* @param {any} b
* @param {boolean} equal
* @returns {boolean}
*/
export function strict_equals(a, b, equal = true) {
if ((a === b) !== (get_proxied_value(a) === get_proxied_value(b))) {
w.state_proxy_equality_mismatch(equal ? '===' : '!==');
// eslint-disable-next-line no-console
console.trace();
}
return (a === b) === equal;
}
/**
* @param {any} a
* @param {any} b
* @param {boolean} equal
* @returns {boolean}
*/
export function equals(a, b, equal = true) {
if ((a == b) !== (get_proxied_value(a) == get_proxied_value(b))) {
w.state_proxy_equality_mismatch(equal ? '==' : '!=');
// eslint-disable-next-line no-console
console.trace();
}
return (a == b) === equal;
}

@ -1,6 +1,7 @@
import { hydrate_anchor, hydrate_nodes, hydrating } from './hydration.js';
import { get_descriptor } from '../utils.js';
import { DEV } from 'esm-env';
import { init_array_prototype_warnings } from '../dev/equality.js';
// We cache the Node and Element prototype methods, so that we can avoid doing
// expensive prototype chain lookups.
@ -74,6 +75,8 @@ export function init_operations() {
if (DEV) {
// @ts-expect-error
element_prototype.__svelte_meta = null;
init_array_prototype_warnings();
}
first_child_get = /** @type {(this: Node) => ChildNode | null} */ (

@ -161,3 +161,4 @@ export {
validate_store,
validate_void_dynamic_element
} from '../shared/validate.js';
export { strict_equals, equals } from './dev/equality.js';

@ -72,3 +72,16 @@ export function ownership_invalid_mutation(component, owner) {
console.warn("ownership_invalid_mutation");
}
}
/**
* Reactive `$state(...)` proxies and the values they proxy have different identities. Because of this, comparisons with `%operator%` will produce unexpected results. Consider using `$state.is(a, b)` instead
* @param {string} operator
*/
export function state_proxy_equality_mismatch(operator) {
if (DEV) {
console.warn(`%c[svelte] ${"state_proxy_equality_mismatch"}\n%c${`Reactive \`$state(...)\` proxies and the values they proxy have different identities. Because of this, comparisons with \`${operator}\` will produce unexpected results. Consider using \`$state.is(a, b)\` instead`}`, bold, normal);
} else {
// TODO print a link to the documentation
console.warn("state_proxy_equality_mismatch");
}
}
Loading…
Cancel
Save