Skip to content

Commit 56f41e1

Browse files
authored
fix: ensure signal graph is consistent before triggering $inspect signals (#13153)
Fixes #13146. Sync effects are not possible with Svelte 5's push-pull system because they can make can break the reactive graph – which is also why we don't permit writes within a derived too. The same problem is occurring here, as inspect effects are run sync – they are actually happening as part of an existing derived – which means they're a bit like writes in a derived and can cause tearing to the reactive signal graph. To avoid this we can call flushSync just before invoking these effects, as that should ensure the graph is made consistent again. Also another issue I found was that we don't "reset" the inspect_effects Set when we enter a derived – which we should as a derived can create it's own local state that should have no bearing on the parent inspect effect.
1 parent 941f83b commit 56f41e1

File tree

5 files changed

+81
-5
lines changed

5 files changed

+81
-5
lines changed

.changeset/chatty-snails-train.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 signal graph is consistent before triggering $inspect signals

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { equals, safe_equals } from './equality.js';
1414
import * as e from '../errors.js';
1515
import { destroy_effect } from './effects.js';
16+
import { inspect_effects, set_inspect_effects } from './sources.js';
1617

1718
/**
1819
* @template V
@@ -92,6 +93,8 @@ export function update_derived(derived) {
9293
var value;
9394

9495
if (DEV) {
96+
let prev_inspect_effects = inspect_effects;
97+
set_inspect_effects(new Set());
9598
try {
9699
if (stack.includes(derived)) {
97100
e.derived_references_self();
@@ -102,6 +105,7 @@ export function update_derived(derived) {
102105
destroy_derived_children(derived);
103106
value = update_reaction(derived);
104107
} finally {
108+
set_inspect_effects(prev_inspect_effects);
105109
stack.pop();
106110
}
107111
} else {

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

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import {
1515
increment_version,
1616
update_effect,
1717
derived_sources,
18-
set_derived_sources
18+
set_derived_sources,
19+
flush_sync
1920
} from '../runtime.js';
2021
import { equals, safe_equals } from './equality.js';
2122
import {
@@ -29,7 +30,14 @@ import {
2930
} from '../constants.js';
3031
import * as e from '../errors.js';
3132

32-
let inspect_effects = new Set();
33+
export let inspect_effects = new Set();
34+
35+
/**
36+
* @param {Set<any>} v
37+
*/
38+
export function set_inspect_effects(v) {
39+
inspect_effects = v;
40+
}
3341

3442
/**
3543
* @template V
@@ -159,11 +167,19 @@ export function set(source, value) {
159167
}
160168
}
161169

162-
if (DEV) {
163-
for (const effect of inspect_effects) {
170+
if (DEV && inspect_effects.size > 0) {
171+
const inspects = Array.from(inspect_effects);
172+
if (current_effect === null) {
173+
// Triggering an effect sync can tear the signal graph, so to avoid this we need
174+
// to ensure the graph has been flushed before triggering any inspect effects.
175+
// Only needed when there's currently no effect, and flushing with one present
176+
// could have other unintended consequences, like effects running out of order.
177+
// This is expensive, but given this is a DEV mode only feature, it should be fine
178+
flush_sync();
179+
}
180+
for (const effect of inspects) {
164181
update_effect(effect);
165182
}
166-
167183
inspect_effects.clear();
168184
}
169185
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { test } from '../../test';
2+
3+
export default test({
4+
compileOptions: {
5+
dev: true
6+
},
7+
8+
async test({ assert, logs, target }) {
9+
const [btn] = target.querySelectorAll('button');
10+
btn.click();
11+
btn.click();
12+
await Promise.resolve();
13+
14+
assert.deepEqual(logs, ['init', [], 'update', [{}], 'update', [{}, {}]]);
15+
}
16+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<script>
2+
class Rect{
3+
x = $state();
4+
y = $state();
5+
6+
constructor(x, y){
7+
this.x = x;
8+
this.y = y;
9+
}
10+
}
11+
12+
class Node{
13+
pos = $state({ x: 0, y: 0 });
14+
rect = $derived(new Rect(this.pos.x, this.pos.y));
15+
16+
constructor(pos){
17+
this.pos = pos;
18+
}
19+
}
20+
21+
const nodes = $state([]);
22+
23+
const rects = $derived(nodes.map(n => n.rect));
24+
25+
$inspect(rects);
26+
</script>
27+
28+
<button onclick={()=>{
29+
nodes.push(new Node({x: Math.floor(Math.random()*100), y: Math.floor(Math.random()*100)}));
30+
}}>add</button>
31+
<ul>
32+
{#each rects as rect}
33+
<li>{rect.x} - {rect.y}</li>
34+
{/each}
35+
</ul>

0 commit comments

Comments
 (0)