Skip to content

Fix nested-effect cleanup order and add LIFO disposal#116

Merged
johnsoncodehk merged 5 commits into
masterfrom
fix-cleanup-order
May 14, 2026
Merged

Fix nested-effect cleanup order and add LIFO disposal#116
johnsoncodehk merged 5 commits into
masterfrom
fix-cleanup-order

Conversation

@johnsoncodehk
Copy link
Copy Markdown
Collaborator

@johnsoncodehk johnsoncodehk commented May 14, 2026

Summary

Two bugs in cleanup of nested effects:

  1. Inner cleanup never ran on auto-dispose. When an inner effect was disposed by the parent re-running or being disposed (rather than by the user calling its own dispose function), unwatched routed it straight to effectScopeOper, which only clears state — .cleanup was never read. Inner cleanup was silently dropped on every auto-dispose path.

  2. Order was inverted. Even with cleanup hooked into auto-dispose, the parent's own cleanup ran first; children were disposed afterwards via purgeDeps. Standard reverse-of-creation order requires:

    • Inner cleanup before outer cleanup (so the inner can still rely on whatever state the outer set up).
    • Old inner cleanup before the body re-runs (so it doesn't clean up state the new inner already set up).

Changes

Routing — unwatched dispatches by node shape

unwatched(node) {
  if ('fn' in node) effectOper.call(node);          // EffectNode → full dispose
  else if ('getter' in node) { /* stale clear */ }  // ComputedNode
  else if ('currentValue' in node) { /* noop */ }   // SignalNode
  else effectScopeOper.call(node);                  // EffectScopeNode / trigger sub
}

Pre-walk children before own cleanup

effectOper, effectScopeOper, run(), and 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 clean up depth-first reverse before their parent.

Fast path — HasChildEffect flag

A new bit defined locally in index.ts (outside ReactiveFlags' range so system.ts never touches it). effect() sets it on the parent when linking; run() and updateComputed check it before doing the pre-walk. Leaf effects with no child effects skip the walk entirely.

The flag is dynamic — it's cleared at the start of every body re-run and re-set if the body creates new children. So an effect that stops creating children moves back to the fast path automatically.

The else if branch of run() (the parent-chain-notify case where outer is queued but not dirty) explicitly preserves the flag: e.flags = Watching | (flags & HasChildEffect). Otherwise an inner-only re-run clobbers the outer's bit and the next real re-run skips the pre-walk.

Tests

tests/effect.spec.ts:

  • cleanup order on outer re-run
  • cleanup order on dispose
  • sibling cleanup order (LIFO) on dispose / re-run
  • three-level nested cleanup
  • cleanup order after a prior inner-only re-run (regression for the bit-loss path)
  • effect created inside computed (updateComputed regression)

tests/effectScope.spec.ts (new):

  • scope dispose runs child cleanup
  • sibling LIFO on scope dispose
  • depth-first reverse for nested in scope

198 passing.

…anup

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.
@bolt-new-by-stackblitz
Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

Bug 002 — effectScope as intermediate parent:
effectScope() linked itself to its parent without setting HasChildEffect
on the parent, so when the chain was effect → effectScope → effect, the
outer's HasChildEffect bit was never set and run()'s pre-walk was
skipped. The old inner's cleanup then fired after the new inner had
already been set up. Mirror effect()'s flag-set inside effectScope().

Additionally, run()'s and updateComputed's pre-walks only unlinked deps
matching `'fn' in dep`, so even with the flag set the loop skipped scope
deps. Broaden the predicate to `!('getter' in dep) && !('currentValue'
in dep)` to also match EffectScopeNode — unlinking a scope cascades
through unwatched → effectScopeOper, which already disposes the scope's
own deps in reverse.

Bug 001 — computed unwatched FIFO:
When a computed lost its last subscriber, the 'getter' in node branch
of unwatched routed through purgeDeps, which walks deps from head to
tail (FIFO). If the computed's getter had created multiple child
effects, their cleanups ran in creation order instead of LIFO.
Replace purgeDeps with a depsTail-backward walk in that branch.

Added regression tests:
- tests/effect.spec.ts: computed unwatched LIFO ordering.
- tests/effectScope.spec.ts: scope-as-intermediate cleanup order.

All 200 pass.
The new reverse-walk inside the 'getter' in node branch confused
tsslint's type inference because `node.depsTail` is narrowed to
`Link` after the `!== undefined` check, but the loop variable
needs to accept `Link | undefined` for the `link = prev` step.
Import `Link` from system.js and annotate both `link` and `prev`
explicitly.
@johnsoncodehk johnsoncodehk merged commit 713a1c6 into master May 14, 2026
2 checks passed
@johnsoncodehk johnsoncodehk deleted the fix-cleanup-order branch May 14, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant