Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: turn reactive_declaration_non_reactive_property into a runtime warning #14192

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/witty-turtles-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

chore: turn reactive_declaration_non_reactive_property into a runtime warning
34 changes: 34 additions & 0 deletions documentation/docs/98-reference/.generated/client-warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,40 @@ Mutating a value outside the component that created it is strongly discouraged.
%component% mutated a value owned by %owner%. This is strongly discouraged. Consider passing values to child components with `bind:`, or use a callback instead
```

### reactive_declaration_non_reactive_property

```
A `$:` statement (%location%) read reactive state that was not visible to the compiler. Updates to this state will not cause the statement to re-run. The behaviour of this code will change if you migrate it to runes mode
```

In legacy mode, a `$:` [reactive statement](https://svelte.dev/docs/svelte/legacy-reactive-assignments) re-runs when the state it _references_ changes. This is determined at compile time, by analysing the code.

In runes mode, effects and deriveds re-run when there are changes to the values that are read during the function's _execution_.

Often, the result is the same — for example these can be considered equivalent:

```js
$: sum = a + b;
```

```js
const sum = $derived(a + b);
```

In some cases — such as the one that triggered the above warning — they are _not_ the same:

```js
const add = () => a + b;

// the compiler can't 'see' that `sum` depends on `a` and `b`, but
// they _would_ be read while executing the `$derived` version
$: sum = add();
```

Similarly, reactive properties of [deep state](https://svelte.dev/docs/svelte/$state#Deep-state) are not visible to the compiler. As such, changes to these properties will cause effects and deriveds to re-run but will _not_ cause `$:` statements to re-run.

When you [migrate this component](https://svelte.dev/docs/svelte/v5-migration-guide) to runes mode, the behaviour will change accordingly.

### state_proxy_equality_mismatch

```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -726,12 +726,6 @@ Reactive declarations only exist at the top level of the instance script
Reassignments of module-level declarations will not cause reactive statements to update
```

### reactive_declaration_non_reactive_property

```
Properties of objects and arrays are not reactive unless in runes mode. Changes to this property will not cause the reactive statement to update
```

### script_context_deprecated

```
Expand Down
32 changes: 32 additions & 0 deletions packages/svelte/messages/client-warnings/warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,38 @@ The easiest way to log a value as it changes over time is to use the [`$inspect`

> %component% mutated a value owned by %owner%. This is strongly discouraged. Consider passing values to child components with `bind:`, or use a callback instead

## reactive_declaration_non_reactive_property

> A `$:` statement (%location%) read reactive state that was not visible to the compiler. Updates to this state will not cause the statement to re-run. The behaviour of this code will change if you migrate it to runes mode

In legacy mode, a `$:` [reactive statement](https://svelte.dev/docs/svelte/legacy-reactive-assignments) re-runs when the state it _references_ changes. This is determined at compile time, by analysing the code.

In runes mode, effects and deriveds re-run when there are changes to the values that are read during the function's _execution_.

Often, the result is the same — for example these can be considered equivalent:

```js
$: sum = a + b;
```

```js
const sum = $derived(a + b);
```

In some cases — such as the one that triggered the above warning — they are _not_ the same:

```js
const add = () => a + b;

// the compiler can't 'see' that `sum` depends on `a` and `b`, but
// they _would_ be read while executing the `$derived` version
$: sum = add();
```

Similarly, reactive properties of [deep state](https://svelte.dev/docs/svelte/$state#Deep-state) are not visible to the compiler. As such, changes to these properties will cause effects and deriveds to re-run but will _not_ cause `$:` statements to re-run.

When you [migrate this component](https://svelte.dev/docs/svelte/v5-migration-guide) to runes mode, the behaviour will change accordingly.

## 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
Expand Down
4 changes: 0 additions & 4 deletions packages/svelte/messages/compile-warnings/script.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@

> Reassignments of module-level declarations will not cause reactive statements to update

## reactive_declaration_non_reactive_property

> Properties of objects and arrays are not reactive unless in runes mode. Changes to this property will not cause the reactive statement to update

## state_referenced_locally

> State referenced in its own scope will never update. Did you mean to reference it inside a closure?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,30 +26,5 @@ export function MemberExpression(node, context) {
context.state.analysis.needs_context = true;
}

if (context.state.reactive_statement) {
const left = object(node);

if (left !== null) {
const binding = context.state.scope.get(left.name);

if (binding && binding.kind === 'normal') {
const parent = /** @type {Node} */ (context.path.at(-1));

if (
binding.scope === context.state.analysis.module.scope ||
binding.declaration_kind === 'import' ||
(binding.initial &&
binding.initial.type !== 'ArrayExpression' &&
binding.initial.type !== 'ObjectExpression' &&
binding.scope.function_depth <= 1)
) {
if (parent.type !== 'MemberExpression' && parent.type !== 'CallExpression') {
w.reactive_declaration_non_reactive_property(node);
}
}
}
}
}

context.next();
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/** @import { Location } from 'locate-character' */
/** @import { Expression, LabeledStatement, Statement } from 'estree' */
/** @import { ReactiveStatement } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import { dev, is_ignored, locator } from '../../../../state.js';
import * as b from '../../../../utils/builders.js';
import { build_getter } from '../utils.js';

Expand Down Expand Up @@ -48,14 +50,21 @@ export function LabeledStatement(node, context) {
sequence.push(serialized);
}

const location =
dev && !is_ignored(node, 'reactive_declaration_non_reactive_property')
? locator(/** @type {number} */ (node.start))
: undefined;

// these statements will be topologically ordered later
context.state.legacy_reactive_statements.set(
node,
b.stmt(
b.call(
'$.legacy_pre_effect',
sequence.length > 0 ? b.thunk(b.sequence(sequence)) : b.thunk(b.block([])),
b.thunk(b.block(body))
b.thunk(b.block(body)),
location && b.literal(location.line),
location && b.literal(location.column)
)
)
);
Expand Down
9 changes: 0 additions & 9 deletions packages/svelte/src/compiler/warnings.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ export const codes = [
"perf_avoid_nested_class",
"reactive_declaration_invalid_placement",
"reactive_declaration_module_script_dependency",
"reactive_declaration_non_reactive_property",
"state_referenced_locally",
"store_rune_conflict",
"css_unused_selector",
Expand Down Expand Up @@ -641,14 +640,6 @@ export function reactive_declaration_module_script_dependency(node) {
w(node, "reactive_declaration_module_script_dependency", "Reassignments of module-level declarations will not cause reactive statements to update");
}

/**
* Properties of objects and arrays are not reactive unless in runes mode. Changes to this property will not cause the reactive statement to update
* @param {null | NodeLike} node
*/
export function reactive_declaration_non_reactive_property(node) {
w(node, "reactive_declaration_non_reactive_property", "Properties of objects and arrays are not reactive unless in runes mode. Changes to this property will not cause the reactive statement to update");
}

/**
* State referenced in its own scope will never update. Did you mean to reference it inside a closure?
* @param {null | NodeLike} node
Expand Down
3 changes: 2 additions & 1 deletion packages/svelte/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ export const IGNORABLE_RUNTIME_WARNINGS = /** @type {const} */ ([
'hydration_attribute_changed',
'hydration_html_changed',
'ownership_invalid_binding',
'ownership_invalid_mutation'
'ownership_invalid_mutation',
'reactive_declaration_non_reactive_property'
]);

/**
Expand Down
25 changes: 25 additions & 0 deletions packages/svelte/src/internal/client/dev/location.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { DEV } from 'esm-env';
import { FILENAME } from '../../../constants.js';
import { dev_current_component_function } from '../runtime.js';

/**
*
* @param {number} [line]
* @param {number} [column]
*/
export function get_location(line, column) {
if (!DEV || line === undefined) return undefined;

var filename = dev_current_component_function?.[FILENAME];
var location = filename && `${filename}:${line}:${column}`;

return sanitize_location(location);
}

/**
* Prevent devtools trying to make `location` a clickable link by inserting a zero-width space
* @param {string | undefined} location
*/
export function sanitize_location(location) {
return location?.replace(/\//g, '/\u200b');
}
5 changes: 2 additions & 3 deletions packages/svelte/src/internal/client/dom/blocks/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { hash } from '../../../../utils.js';
import { DEV } from 'esm-env';
import { dev_current_component_function } from '../../runtime.js';
import { get_first_child, get_next_sibling } from '../operations.js';
import { sanitize_location } from '../../dev/location.js';

/**
* @param {Element} element
Expand All @@ -28,9 +29,7 @@ function check_hash(element, server_hash, value) {
location = `in ${dev_current_component_function[FILENAME]}`;
}

w.hydration_html_changed(
location?.replace(/\//g, '/\u200b') // prevent devtools trying to make it a clickable link by inserting a zero-width space
);
w.hydration_html_changed(sanitize_location(location));
}

/**
Expand Down
28 changes: 25 additions & 3 deletions packages/svelte/src/internal/client/reactivity/effects.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import {
set_is_flushing_effect,
set_signal_status,
untrack,
skip_reaction
skip_reaction,
capture_signals
} from '../runtime.js';
import {
DIRTY,
Expand All @@ -39,10 +40,13 @@ import {
} from '../constants.js';
import { set } from './sources.js';
import * as e from '../errors.js';
import * as w from '../warnings.js';
import { DEV } from 'esm-env';
import { define_property } from '../../shared/utils.js';
import { get_next_sibling } from '../dom/operations.js';
import { destroy_derived } from './deriveds.js';
import { FILENAME } from '../../../constants.js';
import { get_location } from '../dev/location.js';

/**
* @param {'$effect' | '$effect.pre' | '$inspect'} rune
Expand Down Expand Up @@ -261,14 +265,21 @@ export function effect(fn) {
* Internal representation of `$: ..`
* @param {() => any} deps
* @param {() => void | (() => void)} fn
* @param {number} [line]
* @param {number} [column]
*/
export function legacy_pre_effect(deps, fn) {
export function legacy_pre_effect(deps, fn, line, column) {
var context = /** @type {ComponentContextLegacy} */ (component_context);

/** @type {{ effect: null | Effect, ran: boolean }} */
var token = { effect: null, ran: false };
context.l.r1.push(token);

if (DEV && line !== undefined) {
var location = get_location(line, column);
var explicit_deps = capture_signals(deps);
}

token.effect = render_effect(() => {
deps();

Expand All @@ -278,7 +289,18 @@ export function legacy_pre_effect(deps, fn) {

token.ran = true;
set(context.l.r2, true);
untrack(fn);
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved

if (DEV && location) {
var implicit_deps = capture_signals(() => untrack(fn));

for (var signal of implicit_deps) {
if (!explicit_deps.has(signal)) {
w.reactive_declaration_non_reactive_property(/** @type {string} */ (location));
}
}
} else {
untrack(fn);
}
});
}

Expand Down
24 changes: 19 additions & 5 deletions packages/svelte/src/internal/client/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -795,15 +795,17 @@ export function safe_get(signal) {
}

/**
* Invokes a function and captures all signals that are read during the invocation,
* then invalidates them.
* @param {() => any} fn
* Capture an array of all the signals that are read when `fn` is called
* @template T
* @param {() => T} fn
*/
export function invalidate_inner_signals(fn) {
export function capture_signals(fn) {
var previous_captured_signals = captured_signals;
captured_signals = new Set();

var captured = captured_signals;
var signal;

try {
untrack(fn);
if (previous_captured_signals !== null) {
Expand All @@ -814,7 +816,19 @@ export function invalidate_inner_signals(fn) {
} finally {
captured_signals = previous_captured_signals;
}
for (signal of captured) {

return captured;
}

/**
* Invokes a function and captures all signals that are read during the invocation,
* then invalidates them.
* @param {() => any} fn
*/
export function invalidate_inner_signals(fn) {
var captured = capture_signals(() => untrack(fn));

for (var signal of captured) {
// Go one level up because derived signals created as part of props in legacy mode
if ((signal.f & LEGACY_DERIVED_PROP) !== 0) {
for (const dep of /** @type {Derived} */ (signal).deps || []) {
Expand Down
13 changes: 13 additions & 0 deletions packages/svelte/src/internal/client/warnings.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,19 @@ export function ownership_invalid_mutation(component, owner) {
}
}

/**
* A `$:` statement (%location%) read reactive state that was not visible to the compiler. Updates to this state will not cause the statement to re-run. The behaviour of this code will change if you migrate it to runes mode
* @param {string} location
*/
export function reactive_declaration_non_reactive_property(location) {
if (DEV) {
console.warn(`%c[svelte] reactive_declaration_non_reactive_property\n%cA \`$:\` statement (${location}) read reactive state that was not visible to the compiler. Updates to this state will not cause the statement to re-run. The behaviour of this code will change if you migrate it to runes mode`, bold, normal);
} else {
// TODO print a link to the documentation
console.warn("reactive_declaration_non_reactive_property");
}
}

/**
* Reactive `$state(...)` proxies and the values they proxy have different identities. Because of this, comparisons with `%operator%` will produce unexpected results
* @param {string} operator
Expand Down
Loading
Loading