Skip to content

Commit 975918c

Browse files
trueadmRich-Harris
andauthored
fix: ensure assignments to state field inside constructor trigger effect (#12985)
* fix: ensure assignments to state field inside constructor trigger effects * address feedback * address feedback * address feedback * add test * alternative approach * lint * error on reading local source in derived * build * add changeset for self-dependency error * revert unused changes * revert unused changes * Update packages/svelte/messages/client-errors/errors.md * regenerate * tweak --------- Co-authored-by: Rich Harris <[email protected]>
1 parent 72b066b commit 975918c

File tree

17 files changed

+192
-34
lines changed

17 files changed

+192
-34
lines changed

.changeset/clean-shirts-yawn.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+
breaking: throw error if derived creates state and then depends on it

.changeset/five-maps-reflect.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: ensure assignments to state field inside constructor trigger effects

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@
7272

7373
> Cannot set prototype of `$state` object
7474
75+
## state_unsafe_local_read
76+
77+
> Reading state that was created inside the same derived is forbidden. Consider using `untrack` to read locally created state
78+
7579
## state_unsafe_mutation
7680

7781
> Updating state inside a derived is forbidden. If the value should not be reactive, declare it without `$state`

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

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,6 @@ function build_assignment(operator, left, right, context) {
5757
return b.assignment(operator, /** @type {Pattern} */ (context.visit(left)), value);
5858
}
5959
}
60-
} else if (left.property.type === 'Identifier' && context.state.in_constructor) {
61-
const public_state = context.state.public_state.get(left.property.name);
62-
63-
if (public_state !== undefined && should_proxy(right, context.state.scope)) {
64-
const value = /** @type {Expression} */ (context.visit(right));
65-
66-
return b.assignment(
67-
operator,
68-
/** @type {Pattern} */ (context.visit(left)),
69-
public_state.kind === 'raw_state'
70-
? value
71-
: build_proxy_reassignment(value, public_state.id)
72-
);
73-
}
7460
}
7561
}
7662

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

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,6 @@ export function MemberExpression(node, context) {
1313
if (field) {
1414
return context.state.in_constructor ? b.member(node, 'v') : b.call('$.get', node);
1515
}
16-
} else if (node.object.type === 'ThisExpression') {
17-
// rewrite `this.foo` as `this.#foo.v` inside a constructor
18-
if (node.property.type === 'Identifier' && !node.computed) {
19-
const field = context.state.public_state.get(node.property.name);
20-
21-
if (field && context.state.in_constructor) {
22-
return b.member(b.member(b.this, field.id), 'v');
23-
}
24-
}
2516
}
2617

2718
context.next();

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,22 @@ export function state_prototype_fixed() {
310310
}
311311
}
312312

313+
/**
314+
* Reading state that was created inside the same derived is forbidden. Consider using `untrack` to read locally created state
315+
* @returns {never}
316+
*/
317+
export function state_unsafe_local_read() {
318+
if (DEV) {
319+
const error = new Error(`state_unsafe_local_read\nReading state that was created inside the same derived is forbidden. Consider using \`untrack\` to read locally created state`);
320+
321+
error.name = 'Svelte error';
322+
throw error;
323+
} else {
324+
// TODO print a link to the documentation
325+
throw new Error("state_unsafe_local_read");
326+
}
327+
}
328+
313329
/**
314330
* Updating state inside a derived is forbidden. If the value should not be reactive, declare it without `$state`
315331
* @returns {never}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export function proxy(value, parent = null, prev) {
118118

119119
// create a source, but only if it's an own property and not a prototype property
120120
if (s === undefined && (!exists || get_descriptor(target, prop)?.writable)) {
121-
s = source(proxy(exists ? target[prop] : UNINITIALIZED, metadata));
121+
s = source(proxy(exists ? target[prop] : UNINITIALIZED, metadata), null);
122122
sources.set(prop, s);
123123
}
124124

@@ -170,7 +170,7 @@ export function proxy(value, parent = null, prev) {
170170
(current_effect !== null && (!has || get_descriptor(target, prop)?.writable))
171171
) {
172172
if (s === undefined) {
173-
s = source(has ? proxy(target[prop], metadata) : UNINITIALIZED);
173+
s = source(has ? proxy(target[prop], metadata) : UNINITIALIZED, null);
174174
sources.set(prop, s);
175175
}
176176

packages/svelte/src/internal/client/reactivity/sources.js

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/** @import { Derived, Effect, Source, Value } from '#client' */
1+
/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */
22
import { DEV } from 'esm-env';
33
import {
44
current_component_context,
@@ -13,7 +13,9 @@ import {
1313
set_signal_status,
1414
untrack,
1515
increment_version,
16-
update_effect
16+
update_effect,
17+
derived_sources,
18+
set_derived_sources
1719
} from '../runtime.js';
1820
import { equals, safe_equals } from './equality.js';
1921
import {
@@ -32,27 +34,39 @@ let inspect_effects = new Set();
3234
/**
3335
* @template V
3436
* @param {V} v
37+
* @param {Reaction | null} [owner]
3538
* @returns {Source<V>}
3639
*/
3740
/*#__NO_SIDE_EFFECTS__*/
38-
export function source(v) {
39-
return {
41+
export function source(v, owner = current_reaction) {
42+
var source = {
4043
f: 0, // TODO ideally we could skip this altogether, but it causes type errors
4144
v,
4245
reactions: null,
4346
equals,
4447
version: 0
4548
};
49+
50+
if (owner !== null && (owner.f & DERIVED) !== 0) {
51+
if (derived_sources === null) {
52+
set_derived_sources([source]);
53+
} else {
54+
derived_sources.push(source);
55+
}
56+
}
57+
58+
return source;
4659
}
4760

4861
/**
4962
* @template V
5063
* @param {V} initial_value
64+
* @param {Reaction | null} [owner]
5165
* @returns {Source<V>}
5266
*/
5367
/*#__NO_SIDE_EFFECTS__*/
54-
export function mutable_source(initial_value) {
55-
const s = source(initial_value);
68+
export function mutable_source(initial_value, owner) {
69+
const s = source(initial_value, owner);
5670
s.equals = safe_equals;
5771

5872
// bind the signal to the component context, in case we need to
@@ -84,7 +98,14 @@ export function mutate(source, value) {
8498
* @returns {V}
8599
*/
86100
export function set(source, value) {
87-
if (current_reaction !== null && is_runes() && (current_reaction.f & DERIVED) !== 0) {
101+
if (
102+
current_reaction !== null &&
103+
is_runes() &&
104+
(current_reaction.f & DERIVED) !== 0 &&
105+
// If the source was created locally within the current derived, then
106+
// we allow the mutation.
107+
(derived_sources === null || !derived_sources.includes(source))
108+
) {
88109
e.state_unsafe_mutation();
89110
}
90111

packages/svelte/src/internal/client/reactivity/store.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { mutable_source, set } from './sources.js';
1919
export function store_get(store, store_name, stores) {
2020
const entry = (stores[store_name] ??= {
2121
store: null,
22-
source: mutable_source(undefined),
22+
source: mutable_source(undefined, null),
2323
unsubscribe: noop
2424
});
2525

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,20 @@ export function set_current_effect(effect) {
8080
current_effect = effect;
8181
}
8282

83+
/**
84+
* When sources are created within a derived, we record them so that we can safely allow
85+
* local mutations to these sources without the side-effect error being invoked unnecessarily.
86+
* @type {null | Source[]}
87+
*/
88+
export let derived_sources = null;
89+
90+
/**
91+
* @param {Source[] | null} sources
92+
*/
93+
export function set_derived_sources(sources) {
94+
derived_sources = sources;
95+
}
96+
8397
/**
8498
* The dependencies of the reaction that is currently being executed. In many cases,
8599
* the dependencies are unchanged between runs, and so this will be `null` unless
@@ -281,12 +295,14 @@ export function update_reaction(reaction) {
281295
var previous_untracked_writes = current_untracked_writes;
282296
var previous_reaction = current_reaction;
283297
var previous_skip_reaction = current_skip_reaction;
298+
var prev_derived_sources = derived_sources;
284299

285300
new_deps = /** @type {null | Value[]} */ (null);
286301
skipped_deps = 0;
287302
current_untracked_writes = null;
288303
current_reaction = (reaction.f & (BRANCH_EFFECT | ROOT_EFFECT)) === 0 ? reaction : null;
289304
current_skip_reaction = !is_flushing_effect && (reaction.f & UNOWNED) !== 0;
305+
derived_sources = null;
290306

291307
try {
292308
var result = /** @type {Function} */ (0, reaction.fn)();
@@ -323,6 +339,7 @@ export function update_reaction(reaction) {
323339
current_untracked_writes = previous_untracked_writes;
324340
current_reaction = previous_reaction;
325341
current_skip_reaction = previous_skip_reaction;
342+
derived_sources = prev_derived_sources;
326343
}
327344
}
328345

@@ -700,6 +717,9 @@ export function get(signal) {
700717

701718
// Register the dependency on the current reaction signal.
702719
if (current_reaction !== null) {
720+
if (derived_sources !== null && derived_sources.includes(signal)) {
721+
e.state_unsafe_local_read();
722+
}
703723
var deps = current_reaction.deps;
704724

705725
// If the signal is accessing the same dependencies in the same

0 commit comments

Comments
 (0)