Skip to content

Commit 74474fe

Browse files
authored
fix: prevent reactive statement reruns (#10736)
- run reactive statements only once per tick, even when they have indirect cyclic dependencies. Made possible by adding an array to the component context, which is filled with identifiers of the reactive statements, and which is cleared after all have run. Reactive statements rerunning before that will bail early if they detect they're still in the list - part of the solution is to run all reactive statements, and then all render effects, which also fixes #10597
1 parent f3bfb93 commit 74474fe

File tree

10 files changed

+100
-19
lines changed

10 files changed

+100
-19
lines changed

.changeset/moody-houses-argue.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: prevent reactive statement reruns when they have indirect cyclic dependencies

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,10 @@ export function client_component(source, analysis, options) {
214214
instance.body.push(statement[1]);
215215
}
216216

217+
if (analysis.reactive_statements.size > 0) {
218+
instance.body.push(b.stmt(b.call('$.legacy_pre_effect_reset')));
219+
}
220+
217221
/**
218222
* Used to store the group nodes
219223
* @type {import('estree').VariableDeclaration[]}

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

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,6 @@ export const javascript_visitors_legacy = {
157157
}
158158

159159
const body = serialized_body.body;
160-
const new_body = [];
161160

162161
/** @type {import('estree').Expression[]} */
163162
const sequence = [];
@@ -176,18 +175,16 @@ export const javascript_visitors_legacy = {
176175
sequence.push(serialized);
177176
}
178177

179-
if (sequence.length > 0) {
180-
new_body.push(b.stmt(b.sequence(sequence)));
181-
}
182-
183-
new_body.push(b.stmt(b.call('$.untrack', b.thunk(b.block(body)))));
184-
185-
serialized_body.body = new_body;
186-
187178
// these statements will be topologically ordered later
188179
state.legacy_reactive_statements.set(
189180
node,
190-
b.stmt(b.call('$.pre_effect', b.thunk(serialized_body)))
181+
b.stmt(
182+
b.call(
183+
'$.legacy_pre_effect',
184+
sequence.length > 0 ? b.thunk(b.sequence(sequence)) : b.thunk(b.block([])),
185+
b.thunk(b.block(body))
186+
)
187+
)
191188
);
192189

193190
return b.empty;

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ import {
55
current_effect,
66
destroy_signal,
77
flush_local_render_effects,
8-
schedule_effect
8+
get,
9+
is_runes,
10+
schedule_effect,
11+
untrack
912
} from '../runtime.js';
1013
import { DIRTY, MANAGED, RENDER_EFFECT, EFFECT, PRE_EFFECT } from '../constants.js';
14+
import { set } from './sources.js';
1115

1216
/**
1317
* @param {import('#client').Reaction} target_signal
@@ -156,6 +160,7 @@ export function pre_effect(fn) {
156160
);
157161
}
158162
const sync = current_effect !== null && (current_effect.f & RENDER_EFFECT) !== 0;
163+
const runes = is_runes(current_component_context);
159164
return create_effect(
160165
PRE_EFFECT,
161166
() => {
@@ -169,6 +174,47 @@ export function pre_effect(fn) {
169174
);
170175
}
171176

177+
/**
178+
* Internal representation of `$: ..`
179+
* @param {() => any} deps
180+
* @param {() => void | (() => void)} fn
181+
* @returns {import('#client').Effect}
182+
*/
183+
export function legacy_pre_effect(deps, fn) {
184+
const component_context = /** @type {import('#client').ComponentContext} */ (
185+
current_component_context
186+
);
187+
const token = {};
188+
return create_effect(
189+
PRE_EFFECT,
190+
() => {
191+
deps();
192+
if (component_context.l1.includes(token)) {
193+
return;
194+
}
195+
component_context.l1.push(token);
196+
set(component_context.l2, true);
197+
return untrack(fn);
198+
},
199+
true,
200+
current_block,
201+
true
202+
);
203+
}
204+
205+
export function legacy_pre_effect_reset() {
206+
const component_context = /** @type {import('#client').ComponentContext} */ (
207+
current_component_context
208+
);
209+
return render_effect(() => {
210+
const x = get(component_context.l2);
211+
if (x) {
212+
component_context.l1.length = 0;
213+
component_context.l2.v = false; // set directly to avoid rerunning this effect
214+
}
215+
});
216+
}
217+
172218
/**
173219
* This effect is used to ensure binding are kept in sync. We use a pre effect to ensure we run before the
174220
* bindings which are in later effects. However, we don't use a pre_effect directly as we don't want to flush anything.

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
} from './constants.js';
3131
import { flush_tasks } from './dom/task.js';
3232
import { add_owner } from './dev/ownership.js';
33-
import { mutate, set } from './reactivity/sources.js';
33+
import { mutate, set, source } from './reactivity/sources.js';
3434

3535
const IS_EFFECT = EFFECT | PRE_EFFECT | RENDER_EFFECT;
3636

@@ -430,11 +430,7 @@ export function execute_effect(signal) {
430430
current_effect = previous_effect;
431431
}
432432
const component_context = signal.x;
433-
if (
434-
is_runes(component_context) && // Don't rerun pre effects more than once to accomodate for "$: only runs once" behavior
435-
(signal.f & PRE_EFFECT) !== 0 &&
436-
current_queued_pre_and_render_effects.length > 0
437-
) {
433+
if ((signal.f & PRE_EFFECT) !== 0 && current_queued_pre_and_render_effects.length > 0) {
438434
flush_local_pre_effects(component_context);
439435
}
440436
}
@@ -1163,6 +1159,9 @@ export function push(props, runes = false, fn) {
11631159
s: props,
11641160
// runes
11651161
r: runes,
1162+
// legacy $:
1163+
l1: [],
1164+
l2: source(false),
11661165
// update_callbacks
11671166
u: null
11681167
};

packages/svelte/src/internal/client/types.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export type ComponentContext = {
4242
c: null | Map<unknown, unknown>;
4343
/** runes */
4444
r: boolean;
45+
/** legacy mode: if `$:` statements are allowed to run (ensures they only run once per render) */
46+
l1: any[];
47+
/** legacy mode: if `$:` statements are allowed to run (ensures they only run once per render) */
48+
l2: Source<boolean>;
4549
/** update_callbacks */
4650
u: null | {
4751
/** afterUpdate callbacks */
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { tick } from 'svelte';
12
import { test } from '../../test';
23

34
// destructure to store value
45
export default test({
56
html: '<h1>2 2 xxx 5 6 9 10 2</h1>',
67
async test({ assert, target, component }) {
7-
await component.update();
8+
component.update();
9+
await tick();
810
assert.htmlEqual(target.innerHTML, '<h1>11 11 yyy 12 13 14 15 11</h1>');
911
}
1012
});
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { tick } from 'svelte';
12
import { test } from '../../test';
23

34
// destructure to store
45
export default test({
56
html: '<h1>2 2 xxx 5 6 9 10 2</h1>',
67
async test({ assert, target, component }) {
7-
await component.update();
8+
component.update();
9+
await tick();
810
assert.htmlEqual(target.innerHTML, '<h1>11 11 yyy 12 13 14 15 11</h1>');
911
}
1012
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { tick } from 'svelte';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
html: '<button>1 / 1</button>',
6+
async test({ assert, target }) {
7+
const button = target.querySelector('button');
8+
button?.click();
9+
await tick();
10+
11+
assert.htmlEqual(target.innerHTML, '<button>3 / 2</button>');
12+
}
13+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script>
2+
let count1 = 0;
3+
let count2 = 0;
4+
function increaseCount1() { count1++ }
5+
$: if(count2 < 10) { increaseCount1() }
6+
$: if(count1 < 10) { count2++ }
7+
</script>
8+
9+
<button on:click={() => count1++}>{count1} / {count2}</button>

0 commit comments

Comments
 (0)