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); + } + }, };