Skip to content

Commit 6bf679b

Browse files
committed
fix: dispose nested effects in reverse-creation order, before own cleanup
Two bugs in cleanup of nested effects: 1. When an inner effect is auto-disposed (parent re-run / parent dispose / scope dispose), its cleanup was never called. The unwatched callback routed straight to effectScopeOper, which clears state but never reads .cleanup. Inner cleanup was silently dropped on every auto-dispose path. 2. Even after wiring cleanup into the auto-dispose path, the order was inverted: the parent's own cleanup ran first (in effectOper / run() / updateComputed), then children were disposed via purgeDeps at the end. Standard reverse-of-creation order needs the inner cleanup to run BEFORE the outer cleanup, so the inner can still rely on whatever state the outer set up; and BEFORE the body re-runs (in the re-run case), so the old inner doesn't end up cleaning up state that the new inner already set up. Fixes: - unwatched() dispatches by node shape: EffectNode → effectOper (full dispose including cleanup), ComputedNode → its existing stale-clear path, EffectScopeNode → effectScopeOper. - effectOper / effectScopeOper / run() / updateComputed all walk this.depsTail backward and unlink child EffectNode deps before the node's own cleanup runs. unlink triggers unwatched → recursive effectOper on each child, so grandchildren get cleaned up depth-first reverse before their parent. - A per-effect flag (HasChildEffect, defined locally in index.ts — outside ReactiveFlags' range so system.ts never touches it) gates the pre-walk in run() and updateComputed. Leaf effects with no child effects skip the walk entirely; the flag is set by effect() when it links to a parent and is naturally cleared at the start of a body re-run. - The not-dirty branch of run() (restore Watching after notify chain) explicitly preserves HasChildEffect: e.flags = Watching | (flags & HasChildEffect). Otherwise an inner-only re-run would clobber the outer's bit and the next real re-run would skip the pre-walk. Tests: - effect.spec.ts: - cleanup order on outer re-run - cleanup order on dispose - sibling cleanup on dispose / re-run (LIFO) - three-level nested cleanup - cleanup order after a prior inner-only re-run - effect created inside computed (parent is ComputedNode) - effectScope.spec.ts: - scope dispose runs child cleanup - sibling LIFO on scope dispose - depth-first reverse for nested in scope All 198 pass.
1 parent fbabdbd commit 6bf679b

3 files changed

Lines changed: 270 additions & 11 deletions

File tree

‎src/index.ts‎

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ interface SignalNode<T = any> extends ReactiveNode {
1818
pendingValue: T;
1919
}
2020

21+
// Marks a parent (effect or scope) whose deps include at least one child
22+
// effect. Used to gate the dispose-children-first slow path in run() so
23+
// leaf effects (no children, no own cleanup) avoid the extra deps walk.
24+
// The bit is outside ReactiveFlags' range and never touched by system.ts.
25+
const HasChildEffect = 64;
26+
2127
let cycle = 0;
2228
let runDepth = 0;
2329
let batchDepth = 0;
@@ -64,13 +70,22 @@ const {
6470
queued[insertIndex] = left;
6571
}
6672
},
67-
unwatched(node) {
68-
if (!(node.flags & ReactiveFlags.Mutable)) {
73+
unwatched(node: SignalNode | ComputedNode | EffectNode | EffectScopeNode) {
74+
if ('fn' in node) {
75+
effectOper.call(node);
76+
}
77+
else if ('getter' in node) {
78+
if (node.depsTail !== undefined) {
79+
node.depsTail = undefined;
80+
node.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty;
81+
purgeDeps(node);
82+
}
83+
}
84+
else if ('currentValue' in node) {
85+
// Nothing to do for signals, they are always mutable and never dirty until pendingValue changes
86+
}
87+
else {
6988
effectScopeOper.call(node);
70-
} else if (node.depsTail !== undefined) {
71-
node.depsTail = undefined;
72-
node.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty;
73-
purgeDeps(node);
7489
}
7590
},
7691
});
@@ -161,6 +176,7 @@ export function effect(fn: () => void | (() => void)): () => void {
161176
const prevSub = setActiveSub(e);
162177
if (prevSub !== undefined) {
163178
link(e, prevSub, 0);
179+
prevSub.flags |= HasChildEffect;
164180
}
165181
try {
166182
++runDepth;
@@ -222,6 +238,16 @@ export function trigger(fn: () => void) {
222238
}
223239

224240
function updateComputed(c: ComputedNode): boolean {
241+
if (c.flags & HasChildEffect) {
242+
let link = c.depsTail;
243+
while (link !== undefined) {
244+
const prev = link.prevDep;
245+
if ('fn' in link.dep) {
246+
unlink(link, c);
247+
}
248+
link = prev;
249+
}
250+
}
225251
c.depsTail = undefined;
226252
c.flags = ReactiveFlags.Mutable | ReactiveFlags.RecursedCheck;
227253
const prevSub = setActiveSub(c);
@@ -250,6 +276,16 @@ function run(e: EffectNode): void {
250276
&& checkDirty(e.deps!, e)
251277
)
252278
) {
279+
if (flags & HasChildEffect) {
280+
let link = e.depsTail;
281+
while (link !== undefined) {
282+
const prev = link.prevDep;
283+
if ('fn' in link.dep) {
284+
unlink(link, e);
285+
}
286+
link = prev;
287+
}
288+
}
253289
if (e.cleanup) {
254290
runCleanup(e);
255291
if (!e.flags) {
@@ -270,7 +306,7 @@ function run(e: EffectNode): void {
270306
purgeDeps(e);
271307
}
272308
} else if (e.deps !== undefined) {
273-
e.flags = ReactiveFlags.Watching;
309+
e.flags = ReactiveFlags.Watching | (flags & HasChildEffect);
274310
}
275311
}
276312

@@ -369,16 +405,20 @@ function runCleanup(e: EffectNode): void {
369405
}
370406

371407
function effectOper(this: EffectNode): void {
408+
effectScopeOper.call(this);
372409
if (this.cleanup) {
373410
runCleanup(this);
374411
}
375-
effectScopeOper.call(this);
376412
}
377413

378414
function effectScopeOper(this: EffectScopeNode): void {
379-
this.depsTail = undefined;
380415
this.flags = ReactiveFlags.None;
381-
purgeDeps(this);
416+
let link = this.depsTail;
417+
while (link !== undefined) {
418+
const prev = link.prevDep;
419+
unlink(link, this);
420+
link = prev;
421+
}
382422
const sub = this.subs;
383423
if (sub !== undefined) {
384424
unlink(sub);

‎tests/effect.spec.ts‎

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from 'vitest';
2-
import { effect, getActiveSub, signal } from '../src';
2+
import { computed, effect, getActiveSub, signal } from '../src';
33
import { ReactiveFlags } from '../src/system';
44

55
test('should support custom recurse effect', () => {
@@ -16,6 +16,181 @@ test('should support custom recurse effect', () => {
1616
expect(triggers).toBe(6);
1717
});
1818

19+
test('cleanup order on outer re-run: inner before outer, before new run', () => {
20+
const log: string[] = [];
21+
const a = signal(0);
22+
23+
effect(() => {
24+
a();
25+
log.push('outer:run');
26+
effect(() => {
27+
log.push('inner:run');
28+
return () => log.push('inner:cleanup');
29+
});
30+
return () => log.push('outer:cleanup');
31+
});
32+
expect(log).toEqual(['outer:run', 'inner:run']);
33+
34+
log.length = 0;
35+
a(1);
36+
expect(log).toEqual([
37+
'inner:cleanup',
38+
'outer:cleanup',
39+
'outer:run',
40+
'inner:run',
41+
]);
42+
});
43+
44+
test('cleanup order on dispose: inner before outer', () => {
45+
const log: string[] = [];
46+
47+
const dispose = effect(() => {
48+
log.push('outer:run');
49+
effect(() => {
50+
log.push('inner:run');
51+
return () => log.push('inner:cleanup');
52+
});
53+
return () => log.push('outer:cleanup');
54+
});
55+
log.length = 0;
56+
dispose();
57+
expect(log).toEqual(['inner:cleanup', 'outer:cleanup']);
58+
});
59+
60+
test('sibling cleanup order on dispose: reverse creation (LIFO)', () => {
61+
const log: string[] = [];
62+
63+
const dispose = effect(() => {
64+
effect(() => {
65+
return () => log.push('inner1:cleanup');
66+
});
67+
effect(() => {
68+
return () => log.push('inner2:cleanup');
69+
});
70+
effect(() => {
71+
return () => log.push('inner3:cleanup');
72+
});
73+
return () => log.push('outer:cleanup');
74+
});
75+
dispose();
76+
expect(log).toEqual([
77+
'inner3:cleanup',
78+
'inner2:cleanup',
79+
'inner1:cleanup',
80+
'outer:cleanup',
81+
]);
82+
});
83+
84+
test('sibling cleanup order on outer re-run: reverse creation (LIFO)', () => {
85+
const log: string[] = [];
86+
const a = signal(0);
87+
88+
effect(() => {
89+
a();
90+
effect(() => {
91+
return () => log.push('inner1:cleanup');
92+
});
93+
effect(() => {
94+
return () => log.push('inner2:cleanup');
95+
});
96+
effect(() => {
97+
return () => log.push('inner3:cleanup');
98+
});
99+
return () => log.push('outer:cleanup');
100+
});
101+
log.length = 0;
102+
103+
a(1);
104+
expect(log.slice(0, 4)).toEqual([
105+
'inner3:cleanup',
106+
'inner2:cleanup',
107+
'inner1:cleanup',
108+
'outer:cleanup',
109+
]);
110+
});
111+
112+
test('three-level nested cleanup on dispose: deepest first (depth-first reverse)', () => {
113+
const log: string[] = [];
114+
115+
const dispose = effect(() => {
116+
effect(() => {
117+
effect(() => {
118+
return () => log.push('grandchild:cleanup');
119+
});
120+
return () => log.push('child:cleanup');
121+
});
122+
return () => log.push('outer:cleanup');
123+
});
124+
dispose();
125+
expect(log).toEqual([
126+
'grandchild:cleanup',
127+
'child:cleanup',
128+
'outer:cleanup',
129+
]);
130+
});
131+
132+
test('effect created inside computed: old inner cleanup runs before new inner setup', () => {
133+
// When a computed re-evaluates, any effects its getter created on the
134+
// previous run must be disposed (with cleanup) before the getter runs
135+
// again. Otherwise the old inner's cleanup ends up running after the
136+
// new inner has already been set up.
137+
const a = signal(0);
138+
const log: string[] = [];
139+
140+
const c = computed(() => {
141+
log.push('computed:eval');
142+
effect(() => {
143+
log.push('inner:run');
144+
return () => log.push('inner:cleanup');
145+
});
146+
return a();
147+
});
148+
149+
effect(() => {
150+
c();
151+
});
152+
log.length = 0;
153+
154+
a(1);
155+
expect(log).toEqual([
156+
'inner:cleanup',
157+
'computed:eval',
158+
'inner:run',
159+
]);
160+
});
161+
162+
test('cleanup order is correct on outer re-run after a prior inner-only re-run', () => {
163+
// Regression: inner re-running alone routes outer through run()'s
164+
// not-dirty branch (restore Watching), which must preserve any
165+
// "has child effect" tracking so the next real outer re-run still
166+
// disposes children before its own cleanup.
167+
const a = signal(0);
168+
const b = signal(0);
169+
const log: string[] = [];
170+
171+
effect(() => {
172+
a();
173+
log.push('outer:run');
174+
effect(() => {
175+
b();
176+
log.push('inner:run');
177+
return () => log.push('inner:cleanup');
178+
});
179+
return () => log.push('outer:cleanup');
180+
});
181+
182+
b(1); // inner re-runs alone; outer is touched via notify chain
183+
log.length = 0;
184+
185+
a(1);
186+
expect(log).toEqual([
187+
'inner:cleanup',
188+
'outer:cleanup',
189+
'outer:run',
190+
'inner:run',
191+
]);
192+
});
193+
19194
// https://github.com/stackblitz/alien-signals/issues/115
20195
test('outer effect keeps responding to its own dep after inner re-runs', () => {
21196
const a = signal(0);

‎tests/effectScope.spec.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { expect, test } from 'vitest';
2+
import { effect, effectScope } from '../src';
3+
4+
test('scope dispose runs child effect cleanup', () => {
5+
const log: string[] = [];
6+
const dispose = effectScope(() => {
7+
effect(() => {
8+
return () => log.push('inner:cleanup');
9+
});
10+
});
11+
dispose();
12+
expect(log).toEqual(['inner:cleanup']);
13+
});
14+
15+
test('scope dispose: sibling effects clean up in reverse creation (LIFO)', () => {
16+
const log: string[] = [];
17+
const dispose = effectScope(() => {
18+
effect(() => {
19+
return () => log.push('e1:cleanup');
20+
});
21+
effect(() => {
22+
return () => log.push('e2:cleanup');
23+
});
24+
effect(() => {
25+
return () => log.push('e3:cleanup');
26+
});
27+
});
28+
dispose();
29+
expect(log).toEqual(['e3:cleanup', 'e2:cleanup', 'e1:cleanup']);
30+
});
31+
32+
test('scope dispose: nested effect cleanup runs depth-first reverse', () => {
33+
const log: string[] = [];
34+
const dispose = effectScope(() => {
35+
effect(() => {
36+
effect(() => {
37+
return () => log.push('grandchild:cleanup');
38+
});
39+
return () => log.push('child:cleanup');
40+
});
41+
});
42+
dispose();
43+
expect(log).toEqual(['grandchild:cleanup', 'child:cleanup']);
44+
});

0 commit comments

Comments
 (0)