diff --git a/README.md b/README.md
index 5f07488..50acafa 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# Reactive Framework Test Suite
-Cross-library test suite for comparing reactive signal behavior across **15 frameworks** with **172 test cases**.
+Cross-library test suite for comparing reactive signal behavior across **15 frameworks** with **177 test cases**.
-> 2120 passed, 268 failed, 192 skipped out of 2580 total runs
+> 2169 passed, 274 failed, 212 skipped out of 2655 total runs
Test cases are collected and adapted from the test suites of all participating frameworks — thanks to every project for their thorough testing work. This suite focuses on **reactive semantics** (propagation, batching, disposal, edge cases), not API completeness. Tests that require an optional capability (e.g. `batch`) are skipped (⬜) for frameworks that don't expose it, rather than marked as failures.
@@ -36,21 +36,21 @@ The **Behavioral Differences** section is separate — those tests reflect desig
| Framework | Pass | Fail | Skip | Total |
| ---------------------- | ---- | ---- | ---- | ----- |
-| alien-signals | 172 | 0 | 0 | 172 |
-| @preact/signals-core | 169 | 3 | 0 | 172 |
-| @reatom/core | 168 | 4 | 0 | 172 |
-| @vue/reactivity | 165 | 7 | 0 | 172 |
-| anod | 158 | 14 | 0 | 172 |
-| tansu | 150 | 6 | 16 | 172 |
-| @solidjs/signals | 149 | 7 | 16 | 172 |
-| solid-js | 147 | 25 | 0 | 172 |
-| mobx | 138 | 18 | 16 | 172 |
-| signal-polyfill (TC39) | 135 | 8 | 29 | 172 |
-| @angular/core | 132 | 11 | 29 | 172 |
-| S.js | 124 | 48 | 0 | 172 |
-| svelte | 123 | 13 | 36 | 172 |
-| @reactively/core | 100 | 22 | 50 | 172 |
-| pota | 90 | 82 | 0 | 172 |
+| alien-signals | 177 | 0 | 0 | 177 |
+| @preact/signals-core | 174 | 3 | 0 | 177 |
+| @reatom/core | 173 | 4 | 0 | 177 |
+| @vue/reactivity | 170 | 7 | 0 | 177 |
+| anod | 159 | 18 | 0 | 177 |
+| solid-js | 152 | 25 | 0 | 177 |
+| tansu | 150 | 6 | 21 | 177 |
+| @solidjs/signals | 149 | 7 | 21 | 177 |
+| signal-polyfill (TC39) | 140 | 8 | 29 | 177 |
+| mobx | 138 | 18 | 21 | 177 |
+| @angular/core | 137 | 11 | 29 | 177 |
+| S.js | 129 | 48 | 0 | 177 |
+| svelte | 128 | 13 | 36 | 177 |
+| @reactively/core | 100 | 22 | 55 | 177 |
+| pota | 93 | 84 | 0 | 177 |
## Results
@@ -575,23 +575,23 @@ Legend:
dispose effect disposal call
```
-| Framework | #35,#143,... ×4 | #36,#108,#217 | #38 | #39,#110 | #40 | #42 | #111 | #141 | #178 | #201 | #202 | #216 | #222 | #229 | #230 | #231 | #233 | #235 | #236 |
-| ---------------------- | --------------- | ------------- | --- | -------- | --- | --- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
-| alien-signals | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| @preact/signals-core | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
-| @reactively/core | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ✅ | ⬜ | ❌ | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ |
-| tansu | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ✅ | ⬜ | ✅ | ⬜ | ❌ | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ❌ | ⬜ |
-| signal-polyfill (TC39) | ✅ | ✅ | ✅ | ✅ | ❌ | ⬜ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ⬜ | ✅ | ✅ | ⬜ | ✅ |
-| @vue/reactivity | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| mobx | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ✅ | ⬜ | ✅ | ⬜ | ✅ | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ❌ | ⬜ |
-| @reatom/core | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
-| svelte | ✅ | ✅ | ✅ | ✅ | ✅ | ⬜ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ⬜ | ⬜ | ✅ | ⬜ | ✅ |
-| solid-js | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
-| @solidjs/signals | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ✅ | ⬜ | ✅ | ⬜ | ✅ | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ✅ | ⬜ |
-| S.js | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
-| pota | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
-| @angular/core | ✅ | ✅ | ✅ | ✅ | ❌ | ⬜ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⬜ | ✅ | ✅ | ⬜ | ❌ |
-| anod | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
+| Framework | #35,#143,... ×4 | #36,#108,#217 | #38 | #39,#110,#242 | #40 | #42 | #111 | #141 | #178 | #201 | #202 | #216 | #222 | #229 | #230 | #231 | #233 | #235 | #236 | #237,#243 | #238,#241 |
+| ---------------------- | --------------- | ------------- | --- | ------------- | --- | --- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | --------- | --------- |
+| alien-signals | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| @preact/signals-core | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
+| @reactively/core | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ✅ | ⬜ | ❌ | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ |
+| tansu | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ✅ | ⬜ | ✅ | ⬜ | ❌ | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ❌ | ⬜ | ⬜ | ⬜ |
+| signal-polyfill (TC39) | ✅ | ✅ | ✅ | ✅ | ❌ | ⬜ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ⬜ | ✅ | ✅ | ⬜ | ✅ | ✅ | ✅ |
+| @vue/reactivity | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| mobx | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ✅ | ⬜ | ✅ | ⬜ | ✅ | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ❌ | ⬜ | ⬜ | ⬜ |
+| @reatom/core | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
+| svelte | ✅ | ✅ | ✅ | ✅ | ✅ | ⬜ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ⬜ | ⬜ | ✅ | ⬜ | ✅ | ✅ | ✅ |
+| solid-js | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
+| @solidjs/signals | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ✅ | ⬜ | ✅ | ⬜ | ✅ | ✅ | ✅ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ✅ | ⬜ | ⬜ | ⬜ |
+| S.js | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
+| pota | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
+| @angular/core | ✅ | ✅ | ✅ | ✅ | ❌ | ⬜ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⬜ | ✅ | ✅ | ⬜ | ❌ | ✅ | ✅ |
+| anod | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
Tests with failures or skips
@@ -803,6 +803,65 @@ trigger the same effect. User code bounds the recursion with a
counter; framework must execute this without unbounded looping
or stack overflow.
+#### #237 cleanup ordering on outer re-run: outer-cleanup before re-run, inner before outer if cascaded
+
+```
+ S(a) → E_outer{ E_inner }
+```
+
+Universal invariants on outer re-run:
+ - outer's old cleanup runs BEFORE outer's new body runs
+ - if inner cleanup fires at all, it runs BEFORE outer's cleanup
+ (deepest first when the framework cascades)
+
+Frameworks with a flat-effect model (no parent-child cascade)
+just won't fire inner cleanup; that's accepted.
+
+#### #238 cleanup ordering on dispose: inner before outer if cascaded
+
+ E_outer{ E_inner } → dispose
+
+Universal invariant on dispose:
+ - outer cleanup must run
+ - if inner cleanup runs (cascade), it runs BEFORE outer
+
+Flat frameworks (no cascade) only see outer cleanup; that's accepted.
+
+#### #241 three-level cleanup ordering: deepest first if cascaded
+
+ E_outer{ E_child{ E_grandchild } } → dispose
+
+Universal invariant for three-level nesting:
+ - if cleanups cascade, deepest goes first (grandchild < child < outer)
+ - outer cleanup must fire
+
+Flat frameworks (no cascade) will only see outer cleanup; accepted.
+
+#### #242 effect in computed: old inner cleanup (if any) before new eval
+
+ S(a) → C(c){ E_inner } → E_outer reads C(c)
+
+Universal invariant on computed re-evaluation:
+ - if computed cleans up effects from previous eval, the cleanup
+ fires BEFORE the new eval runs
+ - the new eval and new inner:run come after computed:eval
+
+Frameworks that don't cascade computed-owned effects just won't
+fire inner:cleanup; accepted.
+
+#### #243 cleanup ordering correct after prior inner-only re-run
+
+```
+ S(a) → E_outer{ E_inner ─→ S(b) }
+```
+
+Regression: when inner re-runs alone (via its own dep b), the
+outer is touched via the notify chain. The next real outer
+re-run (via a) must still produce a valid cleanup ordering.
+
+Universal invariant: same as #237 — outer cleanup before outer
+re-run; inner cleanup (if cascaded) before outer cleanup.
+
@@ -1938,23 +1997,23 @@ Legend:
─→ dependency edge
```
-| Framework | #17 | #15 | #146 | #29,#167 | #30 | #176 | #173 | #174 | #88 | #106 | #86,#107 | #49 | #62 | #175 |
-| ---------------------- | ----- | ------------------ | ---------------- | --------- | ---------- | ------------ | ---------- | ---------- | ---------------- | ----------- | ------------- | -------------------- | ------------------ | ------------------ |
-| alien-signals | lazy | no subscription | single recompute | === | skips | returns void | post-write | post-write | keeps subscribed | halts flush | returns stale | runs 1x per write | no throw | unbatched (2 runs) |
-| @preact/signals-core | lazy | no subscription | single recompute | === | skips | returns void | post-write | post-write | unsubscribes | continues | caches error | runs 2x per write | cycle detected | batched |
-| @reactively/core | lazy | no subscription | single recompute | === | skips | ⬜ | throws | throws | keeps subscribed | halts flush | re-evaluates | runs 2x per write | manual bail (200+) | error |
-| tansu | lazy | no subscription | single recompute | Object.is | propagates | returns void | post-write | post-write | keeps subscribed | continues | caches error | runs 2x per write | manual bail (200+) | batched |
-| signal-polyfill (TC39) | lazy | no subscription | single recompute | Object.is | skips | ⬜ | post-write | post-write | keeps subscribed | continues | caches error | runs 1x, then blocks | no throw | unbatched (2 runs) |
-| @vue/reactivity | lazy | no subscription | single recompute | Object.is | skips | returns void | post-write | post-write | keeps subscribed | continues | returns stale | runs 1x per write | no throw | unbatched (2 runs) |
-| mobx | lazy | no subscription | single recompute | === | propagates | returns void | post-write | post-write | keeps subscribed | continues | re-evaluates | runs 2x per write | no throw | batched |
-| @reatom/core | lazy | no subscription | single recompute | Object.is | skips | returns void | post-write | post-write | unsubscribes | continues | caches error | runs 2x per write | cycle detected | batched |
-| svelte | lazy | no subscription | single recompute | === | skips | ⬜ | post-write | throws | unsubscribes | halts flush | re-evaluates | runs 2x per write | cycle detected | batched |
-| solid-js | eager | subscribes eagerly | 2 recomputes | === | skips | returns void | post-write | post-write | unsubscribes | halts flush | error | runs 2x per write | manual bail (200+) | batched |
-| @solidjs/signals | lazy | no subscription | single recompute | === | skips | returns void | post-write | post-write | keeps subscribed | halts flush | caches error | runs 1x, then blocks | no throw | batched |
-| S.js | eager | subscribes eagerly | 2 recomputes | === | propagates | returns void | post-write | throws | keeps subscribed | halts flush | error | runs 2x per write | manual bail (200+) | batched |
-| pota | lazy | no subscription | 2 recomputes | === | skips | returns void | unknown | unknown | error | error | re-evaluates | no re-run | error | batched |
-| @angular/core | lazy | no subscription | single recompute | Object.is | skips | ⬜ | post-write | post-write | keeps subscribed | halts flush | caches error | runs 2x per write | manual bail (200+) | unbatched (2 runs) |
-| anod | eager | no subscription | single recompute | === | skips | returns void | post-write | post-write | unsubscribes | continues | caches error | runs 2x per write | manual bail (200+) | batched |
+| Framework | #17 | #15 | #146 | #29,#167 | #30 | #176 | #173 | #174 | #88 | #106 | #86,#107 | #49 | #62 | #175 | #244 |
+| ---------------------- | ----- | ------------------ | ---------------- | --------- | ---------- | ------------ | ---------- | ---------- | ---------------- | ----------- | ------------- | -------------------- | ------------------ | ------------------ | ---------- |
+| alien-signals | lazy | no subscription | single recompute | === | skips | returns void | post-write | post-write | keeps subscribed | halts flush | returns stale | runs 1x per write | no throw | unbatched (2 runs) | LIFO |
+| @preact/signals-core | lazy | no subscription | single recompute | === | skips | returns void | post-write | post-write | unsubscribes | continues | caches error | runs 2x per write | cycle detected | batched | no cascade |
+| @reactively/core | lazy | no subscription | single recompute | === | skips | ⬜ | throws | throws | keeps subscribed | halts flush | re-evaluates | runs 2x per write | manual bail (200+) | error | ⬜ |
+| tansu | lazy | no subscription | single recompute | Object.is | propagates | returns void | post-write | post-write | keeps subscribed | continues | caches error | runs 2x per write | manual bail (200+) | batched | ⬜ |
+| signal-polyfill (TC39) | lazy | no subscription | single recompute | Object.is | skips | ⬜ | post-write | post-write | keeps subscribed | continues | caches error | runs 1x, then blocks | no throw | unbatched (2 runs) | no cascade |
+| @vue/reactivity | lazy | no subscription | single recompute | Object.is | skips | returns void | post-write | post-write | keeps subscribed | continues | returns stale | runs 1x per write | no throw | unbatched (2 runs) | no cascade |
+| mobx | lazy | no subscription | single recompute | === | propagates | returns void | post-write | post-write | keeps subscribed | continues | re-evaluates | runs 2x per write | no throw | batched | ⬜ |
+| @reatom/core | lazy | no subscription | single recompute | Object.is | skips | returns void | post-write | post-write | unsubscribes | continues | caches error | runs 2x per write | cycle detected | batched | FIFO |
+| svelte | lazy | no subscription | single recompute | === | skips | ⬜ | post-write | throws | unsubscribes | halts flush | re-evaluates | runs 2x per write | cycle detected | batched | no cascade |
+| solid-js | eager | subscribes eagerly | 2 recomputes | === | skips | returns void | post-write | post-write | unsubscribes | halts flush | error | runs 2x per write | manual bail (200+) | batched | no cascade |
+| @solidjs/signals | lazy | no subscription | single recompute | === | skips | returns void | post-write | post-write | keeps subscribed | halts flush | caches error | runs 1x, then blocks | no throw | batched | ⬜ |
+| S.js | eager | subscribes eagerly | 2 recomputes | === | propagates | returns void | post-write | throws | keeps subscribed | halts flush | error | runs 2x per write | manual bail (200+) | batched | no cascade |
+| pota | lazy | no subscription | 2 recomputes | === | skips | returns void | unknown | unknown | error | error | re-evaluates | no re-run | error | batched | no cascade |
+| @angular/core | lazy | no subscription | single recompute | Object.is | skips | ⬜ | post-write | post-write | keeps subscribed | halts flush | caches error | runs 2x per write | manual bail (200+) | unbatched (2 runs) | no cascade |
+| anod | eager | no subscription | single recompute | === | skips | returns void | post-write | post-write | unsubscribes | continues | caches error | runs 2x per write | manual bail (200+) | batched | LIFO |
Test descriptions
@@ -2152,6 +2211,15 @@ downstream computed. Checks whether the framework batches
the two writes so that E(eff2) runs only once.
Returns "batched" or "unbatched (N runs)".
+#### #244 sibling cleanup order on dispose
+
+ E_outer{ E_inner1, E_inner2, E_inner3 } → dispose
+
+Probes the cleanup order of sibling effects when their owner
+disposes. Frameworks with parent-child cascade tend to use
+either LIFO (reverse creation) or FIFO (creation order). A
+flat-effect framework (no cascade) reports "no cascade".
+
diff --git a/docs/coverage-matrix.md b/docs/coverage-matrix.md
index b66bf22..a77a9a8 100644
--- a/docs/coverage-matrix.md
+++ b/docs/coverage-matrix.md
@@ -95,3 +95,44 @@ Of the 13 high+medium gaps, the ones I'd recommend adding as cross-framework tes
| #235 batch inside effect body | ✅ | preact/tansu/mobx/solid/S.js/anod fail |
| #236 cleanup write to own dep (cycle) | ✅ | only angular fails |
+## Round 2: cleanup ordering (translated from alien-signals PR #116)
+
+A second audit covered cleanup *ordering* (not just whether cleanup
+runs). Original PR draft added 7 strict tests asserting alien-signals's
+exact model; they were relaxed after discovering that many frameworks
+have a different but valid model (flat-effect, FIFO siblings, etc).
+
+### Gap
+Existing suite had no coverage of cleanup ordering contracts:
+inner-before-outer, sibling LIFO/FIFO, depth-first reverse on
+multi-level nesting, ordering on re-run vs dispose, cleanup ordering
+after a prior inner-only re-run.
+
+### Implementation Result
+
+| Test | Status | Notes |
+|---|---|---|
+| #237 cleanup ordering on outer re-run | ✅ relaxed to invariants | pota/angular/anod fail (real bugs) |
+| #238 cleanup ordering on dispose | ✅ relaxed | anod fails (real bug) |
+| #239 (original) sibling LIFO on dispose | ❌ moved to #244 probe | sibling order is model choice, not invariant |
+| #240 (original) sibling LIFO on re-run | ❌ moved to #244 probe | same as #239 |
+| #241 three-level cleanup depth-first | ✅ relaxed | most frameworks pass |
+| #242 effect in computed: old cleanup before new eval | ✅ relaxed | grouped with #39/#110 |
+| #243 cleanup ordering after prior inner-only re-run | ✅ relaxed | pota/angular/anod fail (real bugs) |
+| #244 sibling cleanup order probe (behavioral) | ✅ added | Returns "LIFO" / "FIFO" / "no cascade" |
+| computed unwatched LIFO (from PR #116) | ❌ dropped | auto-disposal of unobserved computeds not shared across frameworks |
+
+### Key insight
+Strict equality assertions over-constrained tests to one framework's
+model. Cleanup ordering has both:
+- **Universal invariants** (outer:cleanup before outer:run; inner before
+ outer if cascaded) → assert in main suite
+- **Model choices** (LIFO vs FIFO siblings; cascade vs flat) → report
+ as descriptive strings in `behaviorDifferences.ts`
+
+#244 probe summarizes each framework's choice:
+- **LIFO**: alien-signals, anod
+- **FIFO**: reatom
+- **no cascade** (flat-effect model): preact, vue, svelte, solid,
+ S.js, signal-polyfill, angular, pota
+
diff --git a/src/behaviorDifferences.ts b/src/behaviorDifferences.ts
index 3d814d8..dba5f63 100644
--- a/src/behaviorDifferences.ts
+++ b/src/behaviorDifferences.ts
@@ -1,5 +1,5 @@
import type { ReactiveFramework } from "./framework.js";
-import { SkipTest } from "./framework.js";
+import { SkipTest, hasEffectCleanup } from "./framework.js";
/**
* Behavioral Differences
@@ -457,4 +457,37 @@ export const cases: Record any> = {
a.write(10);
return runs <= 1 ? "batched" : `unbatched (${runs} runs)`;
},
+
+ /**
+ * E_outer{ E_inner1, E_inner2, E_inner3 } → dispose
+ *
+ * Probes the cleanup order of sibling effects when their owner
+ * disposes. Frameworks with parent-child cascade tend to use
+ * either LIFO (reverse creation) or FIFO (creation order). A
+ * flat-effect framework (no cascade) reports "no cascade".
+ */
+ "#244 sibling cleanup order on dispose"(fw: ReactiveFramework) {
+ if (!hasEffectCleanup(fw)) throw new SkipTest("no effectCleanup");
+ const order: number[] = [];
+
+ const dispose = fw.effect(() => {
+ fw.effect(() => {
+ return () => order.push(1);
+ });
+ fw.effect(() => {
+ return () => order.push(2);
+ });
+ fw.effect(() => {
+ return () => order.push(3);
+ });
+ });
+
+ dispose();
+ if (order.length === 0) return "no cascade";
+ if (order.length < 3) return `partial cascade (${order.join(",")})`;
+ const asStr = order.join(",");
+ if (asStr === "3,2,1") return "LIFO";
+ if (asStr === "1,2,3") return "FIFO";
+ return `other (${asStr})`;
+ },
};
diff --git a/src/effectLifecycle.ts b/src/effectLifecycle.ts
index d80ee07..93b8b0d 100644
--- a/src/effectLifecycle.ts
+++ b/src/effectLifecycle.ts
@@ -773,4 +773,212 @@ export const cases: Record any> = {
// infinite looping or stack overflow. Final run count is bounded.
expect(runs).toBeLessThanOrEqual(20);
},
+
+ /**
+ * S(a) → E_outer{ E_inner }
+ *
+ * Universal invariants on outer re-run:
+ * - outer's old cleanup runs BEFORE outer's new body runs
+ * - if inner cleanup fires at all, it runs BEFORE outer's cleanup
+ * (deepest first when the framework cascades)
+ *
+ * Frameworks with a flat-effect model (no parent-child cascade)
+ * just won't fire inner cleanup; that's accepted.
+ */
+ "#237 cleanup ordering on outer re-run: outer-cleanup before re-run, inner before outer if cascaded"(
+ fw: ReactiveFramework
+ ) {
+ if (!hasEffectCleanup(fw)) throw new SkipTest("no effectCleanup");
+ const a = fw.signal(0);
+ const log: string[] = [];
+
+ fw.effect(() => {
+ a.read();
+ log.push("outer:run");
+ fw.effect(() => {
+ log.push("inner:run");
+ return () => log.push("inner:cleanup");
+ });
+ return () => log.push("outer:cleanup");
+ });
+ expect(log).toEqual(["outer:run", "inner:run"]);
+
+ log.length = 0;
+ a.write(1);
+
+ const outerCleanupIdx = log.indexOf("outer:cleanup");
+ const outerRunIdx = log.lastIndexOf("outer:run");
+ const innerCleanupIdx = log.indexOf("inner:cleanup");
+
+ expect(outerCleanupIdx).toBeGreaterThanOrEqual(0);
+ expect(outerRunIdx).toBeGreaterThan(outerCleanupIdx);
+ if (innerCleanupIdx >= 0) {
+ expect(innerCleanupIdx).toBeLessThan(outerCleanupIdx);
+ }
+ },
+
+ /**
+ * E_outer{ E_inner } → dispose
+ *
+ * Universal invariant on dispose:
+ * - outer cleanup must run
+ * - if inner cleanup runs (cascade), it runs BEFORE outer
+ *
+ * Flat frameworks (no cascade) only see outer cleanup; that's accepted.
+ */
+ "#238 cleanup ordering on dispose: inner before outer if cascaded"(
+ fw: ReactiveFramework
+ ) {
+ if (!hasEffectCleanup(fw)) throw new SkipTest("no effectCleanup");
+ const log: string[] = [];
+
+ const dispose = fw.effect(() => {
+ log.push("outer:run");
+ fw.effect(() => {
+ log.push("inner:run");
+ return () => log.push("inner:cleanup");
+ });
+ return () => log.push("outer:cleanup");
+ });
+ log.length = 0;
+
+ dispose();
+ const outerCleanupIdx = log.indexOf("outer:cleanup");
+ const innerCleanupIdx = log.indexOf("inner:cleanup");
+
+ expect(outerCleanupIdx).toBeGreaterThanOrEqual(0);
+ if (innerCleanupIdx >= 0) {
+ expect(innerCleanupIdx).toBeLessThan(outerCleanupIdx);
+ }
+ },
+
+ /**
+ * E_outer{ E_child{ E_grandchild } } → dispose
+ *
+ * Universal invariant for three-level nesting:
+ * - if cleanups cascade, deepest goes first (grandchild < child < outer)
+ * - outer cleanup must fire
+ *
+ * Flat frameworks (no cascade) will only see outer cleanup; accepted.
+ */
+ "#241 three-level cleanup ordering: deepest first if cascaded"(
+ fw: ReactiveFramework
+ ) {
+ if (!hasEffectCleanup(fw)) throw new SkipTest("no effectCleanup");
+ const log: string[] = [];
+
+ const dispose = fw.effect(() => {
+ fw.effect(() => {
+ fw.effect(() => {
+ return () => log.push("grandchild:cleanup");
+ });
+ return () => log.push("child:cleanup");
+ });
+ return () => log.push("outer:cleanup");
+ });
+
+ dispose();
+ const outerIdx = log.indexOf("outer:cleanup");
+ const childIdx = log.indexOf("child:cleanup");
+ const grandIdx = log.indexOf("grandchild:cleanup");
+
+ expect(outerIdx).toBeGreaterThanOrEqual(0);
+ if (childIdx >= 0) {
+ expect(childIdx).toBeLessThan(outerIdx);
+ }
+ if (grandIdx >= 0) {
+ expect(grandIdx).toBeLessThan(outerIdx);
+ if (childIdx >= 0) {
+ expect(grandIdx).toBeLessThan(childIdx);
+ }
+ }
+ },
+
+ /**
+ * S(a) → C(c){ E_inner } → E_outer reads C(c)
+ *
+ * Universal invariant on computed re-evaluation:
+ * - if computed cleans up effects from previous eval, the cleanup
+ * fires BEFORE the new eval runs
+ * - the new eval and new inner:run come after computed:eval
+ *
+ * Frameworks that don't cascade computed-owned effects just won't
+ * fire inner:cleanup; accepted.
+ */
+ "#242 effect in computed: old inner cleanup (if any) before new eval"(
+ fw: ReactiveFramework
+ ) {
+ if (!hasEffectCleanup(fw)) throw new SkipTest("no effectCleanup");
+ const a = fw.signal(0);
+ const log: string[] = [];
+
+ const c = fw.computed(() => {
+ log.push("computed:eval");
+ fw.effect(() => {
+ log.push("inner:run");
+ return () => log.push("inner:cleanup");
+ });
+ return a.read();
+ });
+
+ fw.effect(() => {
+ c.read();
+ });
+ log.length = 0;
+
+ a.write(1);
+ const evalIdx = log.lastIndexOf("computed:eval");
+ const innerRunIdx = log.lastIndexOf("inner:run");
+ const innerCleanupIdx = log.indexOf("inner:cleanup");
+
+ expect(evalIdx).toBeGreaterThanOrEqual(0);
+ expect(innerRunIdx).toBeGreaterThan(evalIdx);
+ if (innerCleanupIdx >= 0) {
+ expect(innerCleanupIdx).toBeLessThan(evalIdx);
+ }
+ },
+
+ /**
+ * S(a) → E_outer{ E_inner ─→ S(b) }
+ *
+ * Regression: when inner re-runs alone (via its own dep b), the
+ * outer is touched via the notify chain. The next real outer
+ * re-run (via a) must still produce a valid cleanup ordering.
+ *
+ * Universal invariant: same as #237 — outer cleanup before outer
+ * re-run; inner cleanup (if cascaded) before outer cleanup.
+ */
+ "#243 cleanup ordering correct after prior inner-only re-run"(
+ fw: ReactiveFramework
+ ) {
+ if (!hasEffectCleanup(fw)) throw new SkipTest("no effectCleanup");
+ const a = fw.signal(0);
+ const b = fw.signal(0);
+ const log: string[] = [];
+
+ fw.effect(() => {
+ a.read();
+ log.push("outer:run");
+ fw.effect(() => {
+ b.read();
+ log.push("inner:run");
+ return () => log.push("inner:cleanup");
+ });
+ return () => log.push("outer:cleanup");
+ });
+
+ b.write(1); // inner re-runs alone
+ log.length = 0;
+
+ a.write(1);
+ const outerCleanupIdx = log.indexOf("outer:cleanup");
+ const outerRunIdx = log.lastIndexOf("outer:run");
+ const innerCleanupIdx = log.indexOf("inner:cleanup");
+
+ expect(outerCleanupIdx).toBeGreaterThanOrEqual(0);
+ expect(outerRunIdx).toBeGreaterThan(outerCleanupIdx);
+ if (innerCleanupIdx >= 0) {
+ expect(innerCleanupIdx).toBeLessThan(outerCleanupIdx);
+ }
+ },
};