Skip to content

Commit a67b586

Browse files
authored
fix: more informative error when effects run in an infinite loop (#16405)
* update effect_update_depth_exceeded docs * log update locations * remove dev_effect_stack stuff, it's not very helpful * tidy up * test * fix test * changeset * fix
1 parent 09c9a3c commit a67b586

File tree

12 files changed

+202
-93
lines changed

12 files changed

+202
-93
lines changed

.changeset/slimy-doors-fetch.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: more informative error when effects run in an infinite loop

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,47 @@ Effect cannot be created inside a `$derived` value that was not itself created i
8989
### effect_update_depth_exceeded
9090

9191
```
92-
Maximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops
92+
Maximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state
9393
```
9494

95+
If an effect updates some state that it also depends on, it will re-run, potentially in a loop:
96+
97+
```js
98+
let count = $state(0);
99+
100+
$effect(() => {
101+
// this both reads and writes `count`,
102+
// so will run in an infinite loop
103+
count += 1;
104+
});
105+
```
106+
107+
(Svelte intervenes before this can crash your browser tab.)
108+
109+
The same applies to array mutations, since these both read and write to the array:
110+
111+
```js
112+
let array = $state([]);
113+
114+
$effect(() => {
115+
array.push('hello');
116+
});
117+
```
118+
119+
Note that it's fine for an effect to re-run itself as long as it 'settles':
120+
121+
```js
122+
let array = ['a', 'b', 'c'];
123+
// ---cut---
124+
$effect(() => {
125+
// this is okay, because sorting an already-sorted array
126+
// won't result in a mutation
127+
array.sort();
128+
});
129+
```
130+
131+
Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency.
132+
95133
### flush_sync_in_effect
96134

97135
```

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,45 @@ See the [migration guide](/docs/svelte/v5-migration-guide#Components-are-no-long
6060
6161
## effect_update_depth_exceeded
6262

63-
> Maximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops
63+
> Maximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state
64+
65+
If an effect updates some state that it also depends on, it will re-run, potentially in a loop:
66+
67+
```js
68+
let count = $state(0);
69+
70+
$effect(() => {
71+
// this both reads and writes `count`,
72+
// so will run in an infinite loop
73+
count += 1;
74+
});
75+
```
76+
77+
(Svelte intervenes before this can crash your browser tab.)
78+
79+
The same applies to array mutations, since these both read and write to the array:
80+
81+
```js
82+
let array = $state([]);
83+
84+
$effect(() => {
85+
array.push('hello');
86+
});
87+
```
88+
89+
Note that it's fine for an effect to re-run itself as long as it 'settles':
90+
91+
```js
92+
let array = ['a', 'b', 'c'];
93+
// ---cut---
94+
$effect(() => {
95+
// this is okay, because sorting an already-sorted array
96+
// won't result in a mutation
97+
array.sort();
98+
});
99+
```
100+
101+
Often when encountering this issue, the value in question shouldn't be state (for example, if you are pushing to a `logs` array in an effect, make `logs` a normal array rather than `$state([])`). In the rare cases where you really _do_ need to write to state in an effect — [which you should avoid]($effect#When-not-to-use-$effect) — you can read the state with [untrack](svelte#untrack) to avoid adding it as a dependency.
64102

65103
## flush_sync_in_effect
66104

packages/svelte/src/internal/client/dev/tracing.js

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,10 @@ function log_entry(signal, entry) {
5656
}
5757

5858
if (dirty && signal.updated) {
59-
// eslint-disable-next-line no-console
60-
console.log(signal.updated);
59+
for (const updated of signal.updated.values()) {
60+
// eslint-disable-next-line no-console
61+
console.log(updated.error);
62+
}
6163
}
6264

6365
if (entry) {
@@ -120,44 +122,46 @@ export function trace(label, fn) {
120122

121123
/**
122124
* @param {string} label
125+
* @returns {Error & { stack: string } | null}
123126
*/
124127
export function get_stack(label) {
125128
let error = Error();
126129
const stack = error.stack;
127130

128-
if (stack) {
129-
const lines = stack.split('\n');
130-
const new_lines = ['\n'];
131-
132-
for (let i = 0; i < lines.length; i++) {
133-
const line = lines[i];
134-
135-
if (line === 'Error') {
136-
continue;
137-
}
138-
if (line.includes('validate_each_keys')) {
139-
return null;
140-
}
141-
if (line.includes('svelte/src/internal')) {
142-
continue;
143-
}
144-
new_lines.push(line);
145-
}
131+
if (!stack) return null;
146132

147-
if (new_lines.length === 1) {
133+
const lines = stack.split('\n');
134+
const new_lines = ['\n'];
135+
136+
for (let i = 0; i < lines.length; i++) {
137+
const line = lines[i];
138+
139+
if (line === 'Error') {
140+
continue;
141+
}
142+
if (line.includes('validate_each_keys')) {
148143
return null;
149144
}
145+
if (line.includes('svelte/src/internal')) {
146+
continue;
147+
}
148+
new_lines.push(line);
149+
}
150150

151-
define_property(error, 'stack', {
152-
value: new_lines.join('\n')
153-
});
154-
155-
define_property(error, 'name', {
156-
// 'Error' suffix is required for stack traces to be rendered properly
157-
value: `${label}Error`
158-
});
151+
if (new_lines.length === 1) {
152+
return null;
159153
}
160-
return error;
154+
155+
define_property(error, 'stack', {
156+
value: new_lines.join('\n')
157+
});
158+
159+
define_property(error, 'name', {
160+
// 'Error' suffix is required for stack traces to be rendered properly
161+
value: `${label}Error`
162+
});
163+
164+
return /** @type {Error & { stack: string }} */ (error);
161165
}
162166

163167
/**

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,12 +214,12 @@ export function effect_pending_outside_reaction() {
214214
}
215215

216216
/**
217-
* Maximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops
217+
* Maximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state
218218
* @returns {never}
219219
*/
220220
export function effect_update_depth_exceeded() {
221221
if (DEV) {
222-
const error = new Error(`effect_update_depth_exceeded\nMaximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops\nhttps://svelte.dev/e/effect_update_depth_exceeded`);
222+
const error = new Error(`effect_update_depth_exceeded\nMaximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state\nhttps://svelte.dev/e/effect_update_depth_exceeded`);
223223

224224
error.name = 'Svelte error';
225225

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

Lines changed: 28 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,6 @@ export let current_batch = null;
4646
*/
4747
export let batch_deriveds = null;
4848

49-
/** @type {Effect[]} Stack of effects, dev only */
50-
export let dev_effect_stack = [];
51-
5249
/** @type {Set<() => void>} */
5350
export let effect_pending_updates = new Set();
5451

@@ -345,6 +342,28 @@ export class Batch {
345342

346343
while (queued_root_effects.length > 0) {
347344
if (flush_count++ > 1000) {
345+
if (DEV) {
346+
var updates = new Map();
347+
348+
for (const source of this.#current.keys()) {
349+
for (const [stack, update] of source.updated ?? []) {
350+
var entry = updates.get(stack);
351+
352+
if (!entry) {
353+
entry = { error: update.error, count: 0 };
354+
updates.set(stack, entry);
355+
}
356+
357+
entry.count += update.count;
358+
}
359+
}
360+
361+
for (const update of updates.values()) {
362+
// eslint-disable-next-line no-console
363+
console.error(update.error);
364+
}
365+
}
366+
348367
infinite_loop_guard();
349368
}
350369

@@ -356,9 +375,6 @@ export class Batch {
356375
set_is_updating_effect(was_updating_effect);
357376

358377
last_scheduled_effect = null;
359-
if (DEV) {
360-
dev_effect_stack = [];
361-
}
362378
}
363379
}
364380

@@ -471,56 +487,25 @@ export function flushSync(fn) {
471487
// we need to reset it here as well in case the first time there's 0 queued root effects
472488
last_scheduled_effect = null;
473489

474-
if (DEV) {
475-
dev_effect_stack = [];
476-
}
477-
478490
return /** @type {T} */ (result);
479491
}
480492

481493
batch.flush_effects();
482494
}
483495
}
484496

485-
function log_effect_stack() {
486-
// eslint-disable-next-line no-console
487-
console.error(
488-
'Last ten effects were: ',
489-
dev_effect_stack.slice(-10).map((d) => d.fn)
490-
);
491-
dev_effect_stack = [];
492-
}
493-
494497
function infinite_loop_guard() {
495498
try {
496499
e.effect_update_depth_exceeded();
497500
} catch (error) {
498501
if (DEV) {
499-
// stack is garbage, ignore. Instead add a console.error message.
500-
define_property(error, 'stack', {
501-
value: ''
502-
});
503-
}
504-
// Try and handle the error so it can be caught at a boundary, that's
505-
// if there's an effect available from when it was last scheduled
506-
if (last_scheduled_effect !== null) {
507-
if (DEV) {
508-
try {
509-
invoke_error_boundary(error, last_scheduled_effect);
510-
} catch (e) {
511-
// Only log the effect stack if the error is re-thrown
512-
log_effect_stack();
513-
throw e;
514-
}
515-
} else {
516-
invoke_error_boundary(error, last_scheduled_effect);
517-
}
518-
} else {
519-
if (DEV) {
520-
log_effect_stack();
521-
}
522-
throw error;
502+
// stack contains no useful information, replace it
503+
define_property(error, 'stack', { value: '' });
523504
}
505+
506+
// Best effort: invoke the boundary nearest the most recent
507+
// effect and hope that it's relevant to the infinite loop
508+
invoke_error_boundary(error, last_scheduled_effect);
524509
}
525510
}
526511

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,22 @@ export function internal_set(source, value) {
182182
const batch = Batch.ensure();
183183
batch.capture(source, old_value);
184184

185-
if (DEV && tracing_mode_flag) {
186-
source.updated = get_stack('UpdatedAt');
185+
if (DEV) {
186+
if (tracing_mode_flag || active_effect !== null) {
187+
const error = get_stack('UpdatedAt');
188+
189+
if (error !== null) {
190+
source.updated ??= new Map();
191+
let entry = source.updated.get(error.stack);
192+
193+
if (!entry) {
194+
entry = { error, count: 0 };
195+
source.updated.set(error.stack, entry);
196+
}
197+
198+
entry.count++;
199+
}
200+
}
187201

188202
if (active_effect !== null) {
189203
source.set_during_effect = true;

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ export interface Value<V = unknown> extends Signal {
2929
label?: string;
3030
/** An error with a stack trace showing when the source was created */
3131
created?: Error | null;
32-
/** An error with a stack trace showing when the source was last updated */
33-
updated?: Error | null;
32+
/** An map of errors with stack traces showing when the source was updated, keyed by the stack trace */
33+
updated?: Map<string, { error: Error; count: number }> | null;
3434
/**
3535
* Whether or not the source was set while running an effect — if so, we need to
3636
* increment the write version so that it shows up as dirty when the effect re-runs

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

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,7 @@ import {
4242
set_dev_stack
4343
} from './context.js';
4444
import * as w from './warnings.js';
45-
import {
46-
Batch,
47-
batch_deriveds,
48-
dev_effect_stack,
49-
flushSync,
50-
schedule_effect
51-
} from './reactivity/batch.js';
45+
import { Batch, batch_deriveds, flushSync, schedule_effect } from './reactivity/batch.js';
5246
import { handle_error } from './error-handling.js';
5347
import { UNINITIALIZED } from '../../constants.js';
5448

@@ -491,10 +485,6 @@ export function update_effect(effect) {
491485
}
492486
}
493487
}
494-
495-
if (DEV) {
496-
dev_effect_stack.push(effect);
497-
}
498488
} finally {
499489
is_updating_effect = was_updating_effect;
500490
active_effect = previous_effect;

0 commit comments

Comments
 (0)