From bffe842ad99e2cc6b49b0b8aa3632928ca73ad0a Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 14 May 2026 23:12:36 +0800 Subject: [PATCH 1/3] Add cleanup ordering tests (#237-#243) Translates the cleanup-ordering tests added in alien-signals PR #116 into cross-framework form. Existing suite had no coverage of cleanup ordering contracts (inner-before-outer, sibling LIFO, depth-first reverse on multi-level nesting). - #237 cleanup order on outer re-run: inner before outer, before new run - #238 cleanup order on dispose: inner before outer - #239 sibling cleanup on dispose: reverse creation (LIFO) - #240 sibling cleanup on outer re-run: reverse creation (LIFO) - #241 three-level nested cleanup on dispose: deepest first - #242 effect created in computed: old inner cleanup before new inner setup - #243 cleanup order correct on outer re-run after prior inner-only re-run The computed-unwatched LIFO test from PR #116 is intentionally skipped because auto-disposal of unobserved computeds is not a contract shared across frameworks. --- README.md | 126 ++++++++++++++++------ src/effectLifecycle.ts | 238 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 5f07488..4b1e1ce 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 **179 test cases**. -> 2120 passed, 268 failed, 192 skipped out of 2580 total runs +> 2131 passed, 334 failed, 220 skipped out of 2685 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 | 179 | 0 | 0 | 179 | +| @reatom/core | 172 | 7 | 0 | 179 | +| @preact/signals-core | 169 | 10 | 0 | 179 | +| @vue/reactivity | 165 | 14 | 0 | 179 | +| anod | 158 | 21 | 0 | 179 | +| tansu | 150 | 6 | 23 | 179 | +| @solidjs/signals | 149 | 7 | 23 | 179 | +| solid-js | 147 | 32 | 0 | 179 | +| mobx | 138 | 18 | 23 | 179 | +| signal-polyfill (TC39) | 135 | 15 | 29 | 179 | +| @angular/core | 132 | 18 | 29 | 179 | +| S.js | 124 | 55 | 0 | 179 | +| svelte | 123 | 20 | 36 | 179 | +| @reactively/core | 100 | 22 | 57 | 179 | +| pota | 90 | 89 | 0 | 179 | ## 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 | #40 | #42 | #111 | #141 | #178 | #201 | #202 | #216 | #222 | #229 | #230 | #231 | #233 | #235 | #236 | #237..#238,... ×4 | #239..#240,#242 | +| ---------------------- | --------------- | ------------- | --- | -------- | --- | --- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ----------------- | --------------- | +| 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,64 @@ trigger the same effect. User code bounds the recursion with a counter; framework must execute this without unbounded looping or stack overflow. +#### #237 cleanup order on outer re-run: inner before outer, before new run + +``` + S(a) → E_outer{ E_inner } +``` + +On a re-run triggered by outer's dep, the cleanup order is: + 1. inner's cleanup (deepest first) + 2. outer's cleanup + 3. outer's body re-runs (creating new inner) + 4. new inner runs + +#### #238 cleanup order on dispose: inner before outer + + E_outer{ E_inner } → dispose + +Disposal of the outer effect cascades. Inner cleanup runs before +outer cleanup (deepest first). + +#### #239 sibling cleanup on dispose: reverse creation (LIFO) + + E_outer{ E_inner1, E_inner2, E_inner3 } → dispose + +Siblings clean up in reverse creation order (LIFO). + +#### #240 sibling cleanup on outer re-run: reverse creation (LIFO) + + S(a) → E_outer{ E_inner1, E_inner2, E_inner3 } + +Same LIFO contract as #239, but triggered by outer's re-run +(not disposal). Same observed cleanup order. + +#### #241 three-level nested cleanup on dispose: deepest first + + E_outer{ E_child{ E_grandchild } } → dispose + +Three-level nesting. On disposal, cleanups fire depth-first in +reverse: grandchild, then child, then outer. + +#### #242 effect created in computed: old inner cleanup before new inner setup + + S(a) → C(c){ E_inner } → E_outer reads C(c) + +On computed re-evaluation, any effect created by the previous +eval must be cleaned up before the new eval runs. + +#### #243 cleanup order correct on outer re-run 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 dispose children before its own +cleanup — i.e., the inner-only path must not corrupt the +outer's "has child effect" tracking. +
diff --git a/src/effectLifecycle.ts b/src/effectLifecycle.ts index d80ee07..710647c 100644 --- a/src/effectLifecycle.ts +++ b/src/effectLifecycle.ts @@ -773,4 +773,242 @@ 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 } + * + * On a re-run triggered by outer's dep, the cleanup order is: + * 1. inner's cleanup (deepest first) + * 2. outer's cleanup + * 3. outer's body re-runs (creating new inner) + * 4. new inner runs + */ + "#237 cleanup order on outer re-run: inner before outer, before new run"( + 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); + expect(log).toEqual([ + "inner:cleanup", + "outer:cleanup", + "outer:run", + "inner:run", + ]); + }, + + /** + * E_outer{ E_inner } → dispose + * + * Disposal of the outer effect cascades. Inner cleanup runs before + * outer cleanup (deepest first). + */ + "#238 cleanup order on dispose: inner before outer"(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(); + expect(log).toEqual(["inner:cleanup", "outer:cleanup"]); + }, + + /** + * E_outer{ E_inner1, E_inner2, E_inner3 } → dispose + * + * Siblings clean up in reverse creation order (LIFO). + */ + "#239 sibling cleanup on dispose: reverse creation (LIFO)"( + fw: ReactiveFramework + ) { + if (!hasEffectCleanup(fw)) throw new SkipTest("no effectCleanup"); + const log: string[] = []; + + const dispose = fw.effect(() => { + fw.effect(() => { + return () => log.push("inner1:cleanup"); + }); + fw.effect(() => { + return () => log.push("inner2:cleanup"); + }); + fw.effect(() => { + return () => log.push("inner3:cleanup"); + }); + return () => log.push("outer:cleanup"); + }); + + dispose(); + expect(log).toEqual([ + "inner3:cleanup", + "inner2:cleanup", + "inner1:cleanup", + "outer:cleanup", + ]); + }, + + /** + * S(a) → E_outer{ E_inner1, E_inner2, E_inner3 } + * + * Same LIFO contract as #239, but triggered by outer's re-run + * (not disposal). Same observed cleanup order. + */ + "#240 sibling cleanup on outer re-run: reverse creation (LIFO)"( + fw: ReactiveFramework + ) { + if (!hasEffectCleanup(fw)) throw new SkipTest("no effectCleanup"); + const a = fw.signal(0); + const log: string[] = []; + + fw.effect(() => { + a.read(); + fw.effect(() => { + return () => log.push("inner1:cleanup"); + }); + fw.effect(() => { + return () => log.push("inner2:cleanup"); + }); + fw.effect(() => { + return () => log.push("inner3:cleanup"); + }); + return () => log.push("outer:cleanup"); + }); + log.length = 0; + + a.write(1); + // The first 4 entries must be the cleanup chain. (Anything after + // is the re-run of outer / new inner setup.) + expect(log.slice(0, 4)).toEqual([ + "inner3:cleanup", + "inner2:cleanup", + "inner1:cleanup", + "outer:cleanup", + ]); + }, + + /** + * E_outer{ E_child{ E_grandchild } } → dispose + * + * Three-level nesting. On disposal, cleanups fire depth-first in + * reverse: grandchild, then child, then outer. + */ + "#241 three-level nested cleanup on dispose: deepest first"( + 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(); + expect(log).toEqual([ + "grandchild:cleanup", + "child:cleanup", + "outer:cleanup", + ]); + }, + + /** + * S(a) → C(c){ E_inner } → E_outer reads C(c) + * + * On computed re-evaluation, any effect created by the previous + * eval must be cleaned up before the new eval runs. + */ + "#242 effect created in computed: old inner cleanup before new inner setup"( + 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); + expect(log).toEqual([ + "inner:cleanup", + "computed:eval", + "inner: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 dispose children before its own + * cleanup — i.e., the inner-only path must not corrupt the + * outer's "has child effect" tracking. + */ + "#243 cleanup order correct on outer re-run 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); + expect(log).toEqual([ + "inner:cleanup", + "outer:cleanup", + "outer:run", + "inner:run", + ]); + }, }; From 6d38f12a8b00fbd66cd4d5158c69bf8fd473043a Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 14 May 2026 23:23:08 +0800 Subject: [PATCH 2/3] Relax cleanup ordering tests to universal invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strict equality assertions over-constrained the tests to alien-signals's specific model (nested ownership + LIFO siblings). Frameworks with flat-effect models (no parent-child cascade) failed not because they have bugs, but because they have a different valid design. Changes: - #237/#238/#241/#242/#243: relax to universal invariants - outer cleanup must fire and must precede outer re-run - inner cleanup (if framework cascades) must precede outer cleanup - frameworks without cascade simply don't fire inner cleanup; that's accepted - #239/#240: removed (sibling order is a model choice, not contract) - Added #244 probe in behaviorDifferences: returns "LIFO" / "FIFO" / "no cascade" / etc. — characterizing each framework's choice Remaining failures now reflect real cleanup bugs (e.g. pota not firing outer cleanup at all) rather than design differences. --- README.md | 174 ++++++++++++++++---------------- src/behaviorDifferences.ts | 35 ++++++- src/effectLifecycle.ts | 196 ++++++++++++++++--------------------- 3 files changed, 209 insertions(+), 196 deletions(-) diff --git a/README.md b/README.md index 4b1e1ce..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 **179 test cases**. +Cross-library test suite for comparing reactive signal behavior across **15 frameworks** with **177 test cases**. -> 2131 passed, 334 failed, 220 skipped out of 2685 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 | 179 | 0 | 0 | 179 | -| @reatom/core | 172 | 7 | 0 | 179 | -| @preact/signals-core | 169 | 10 | 0 | 179 | -| @vue/reactivity | 165 | 14 | 0 | 179 | -| anod | 158 | 21 | 0 | 179 | -| tansu | 150 | 6 | 23 | 179 | -| @solidjs/signals | 149 | 7 | 23 | 179 | -| solid-js | 147 | 32 | 0 | 179 | -| mobx | 138 | 18 | 23 | 179 | -| signal-polyfill (TC39) | 135 | 15 | 29 | 179 | -| @angular/core | 132 | 18 | 29 | 179 | -| S.js | 124 | 55 | 0 | 179 | -| svelte | 123 | 20 | 36 | 179 | -| @reactively/core | 100 | 22 | 57 | 179 | -| pota | 90 | 89 | 0 | 179 | +| 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 | #237..#238,... ×4 | #239..#240,#242 | -| ---------------------- | --------------- | ------------- | --- | -------- | --- | --- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ----------------- | --------------- | -| 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,53 +803,53 @@ trigger the same effect. User code bounds the recursion with a counter; framework must execute this without unbounded looping or stack overflow. -#### #237 cleanup order on outer re-run: inner before outer, before new run +#### #237 cleanup ordering on outer re-run: outer-cleanup before re-run, inner before outer if cascaded ``` S(a) → E_outer{ E_inner } ``` -On a re-run triggered by outer's dep, the cleanup order is: - 1. inner's cleanup (deepest first) - 2. outer's cleanup - 3. outer's body re-runs (creating new inner) - 4. new inner runs +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) -#### #238 cleanup order on dispose: inner before outer +Frameworks with a flat-effect model (no parent-child cascade) +just won't fire inner cleanup; that's accepted. - E_outer{ E_inner } → dispose - -Disposal of the outer effect cascades. Inner cleanup runs before -outer cleanup (deepest first). +#### #238 cleanup ordering on dispose: inner before outer if cascaded -#### #239 sibling cleanup on dispose: reverse creation (LIFO) - - E_outer{ E_inner1, E_inner2, E_inner3 } → dispose - -Siblings clean up in reverse creation order (LIFO). - -#### #240 sibling cleanup on outer re-run: reverse creation (LIFO) + E_outer{ E_inner } → dispose - S(a) → E_outer{ E_inner1, E_inner2, E_inner3 } +Universal invariant on dispose: + - outer cleanup must run + - if inner cleanup runs (cascade), it runs BEFORE outer -Same LIFO contract as #239, but triggered by outer's re-run -(not disposal). Same observed cleanup order. +Flat frameworks (no cascade) only see outer cleanup; that's accepted. -#### #241 three-level nested cleanup on dispose: deepest first +#### #241 three-level cleanup ordering: deepest first if cascaded E_outer{ E_child{ E_grandchild } } → dispose -Three-level nesting. On disposal, cleanups fire depth-first in -reverse: grandchild, then child, then outer. +Universal invariant for three-level nesting: + - if cleanups cascade, deepest goes first (grandchild < child < outer) + - outer cleanup must fire -#### #242 effect created in computed: old inner cleanup before new inner setup +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) -On computed re-evaluation, any effect created by the previous -eval must be cleaned up before the new eval runs. +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 order correct on outer re-run after prior inner-only re-run +#### #243 cleanup ordering correct after prior inner-only re-run ``` S(a) → E_outer{ E_inner ─→ S(b) } @@ -857,9 +857,10 @@ eval must be cleaned up before the new eval runs. 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 dispose children before its own -cleanup — i.e., the inner-only path must not corrupt the -outer's "has child effect" tracking. +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.
@@ -1996,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 @@ -2210,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/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 710647c..93b8b0d 100644 --- a/src/effectLifecycle.ts +++ b/src/effectLifecycle.ts @@ -777,13 +777,15 @@ export const cases: Record any> = { /** * S(a) → E_outer{ E_inner } * - * On a re-run triggered by outer's dep, the cleanup order is: - * 1. inner's cleanup (deepest first) - * 2. outer's cleanup - * 3. outer's body re-runs (creating new inner) - * 4. new inner runs + * 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 order on outer re-run: inner before outer, before new run"( + "#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"); @@ -803,21 +805,30 @@ export const cases: Record any> = { log.length = 0; a.write(1); - expect(log).toEqual([ - "inner:cleanup", - "outer:cleanup", - "outer:run", - "inner:run", - ]); + + 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 * - * Disposal of the outer effect cascades. Inner cleanup runs before - * outer cleanup (deepest first). + * 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 order on dispose: inner before outer"(fw: ReactiveFramework) { + "#238 cleanup ordering on dispose: inner before outer if cascaded"( + fw: ReactiveFramework + ) { if (!hasEffectCleanup(fw)) throw new SkipTest("no effectCleanup"); const log: string[] = []; @@ -832,88 +843,25 @@ export const cases: Record any> = { log.length = 0; dispose(); - expect(log).toEqual(["inner:cleanup", "outer:cleanup"]); - }, - - /** - * E_outer{ E_inner1, E_inner2, E_inner3 } → dispose - * - * Siblings clean up in reverse creation order (LIFO). - */ - "#239 sibling cleanup on dispose: reverse creation (LIFO)"( - fw: ReactiveFramework - ) { - if (!hasEffectCleanup(fw)) throw new SkipTest("no effectCleanup"); - const log: string[] = []; - - const dispose = fw.effect(() => { - fw.effect(() => { - return () => log.push("inner1:cleanup"); - }); - fw.effect(() => { - return () => log.push("inner2:cleanup"); - }); - fw.effect(() => { - return () => log.push("inner3:cleanup"); - }); - return () => log.push("outer:cleanup"); - }); + const outerCleanupIdx = log.indexOf("outer:cleanup"); + const innerCleanupIdx = log.indexOf("inner:cleanup"); - dispose(); - expect(log).toEqual([ - "inner3:cleanup", - "inner2:cleanup", - "inner1:cleanup", - "outer:cleanup", - ]); - }, - - /** - * S(a) → E_outer{ E_inner1, E_inner2, E_inner3 } - * - * Same LIFO contract as #239, but triggered by outer's re-run - * (not disposal). Same observed cleanup order. - */ - "#240 sibling cleanup on outer re-run: reverse creation (LIFO)"( - fw: ReactiveFramework - ) { - if (!hasEffectCleanup(fw)) throw new SkipTest("no effectCleanup"); - const a = fw.signal(0); - const log: string[] = []; - - fw.effect(() => { - a.read(); - fw.effect(() => { - return () => log.push("inner1:cleanup"); - }); - fw.effect(() => { - return () => log.push("inner2:cleanup"); - }); - fw.effect(() => { - return () => log.push("inner3:cleanup"); - }); - return () => log.push("outer:cleanup"); - }); - log.length = 0; - - a.write(1); - // The first 4 entries must be the cleanup chain. (Anything after - // is the re-run of outer / new inner setup.) - expect(log.slice(0, 4)).toEqual([ - "inner3:cleanup", - "inner2:cleanup", - "inner1:cleanup", - "outer:cleanup", - ]); + expect(outerCleanupIdx).toBeGreaterThanOrEqual(0); + if (innerCleanupIdx >= 0) { + expect(innerCleanupIdx).toBeLessThan(outerCleanupIdx); + } }, /** * E_outer{ E_child{ E_grandchild } } → dispose * - * Three-level nesting. On disposal, cleanups fire depth-first in - * reverse: grandchild, then child, then outer. + * 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 nested cleanup on dispose: deepest first"( + "#241 three-level cleanup ordering: deepest first if cascaded"( fw: ReactiveFramework ) { if (!hasEffectCleanup(fw)) throw new SkipTest("no effectCleanup"); @@ -930,20 +878,34 @@ export const cases: Record any> = { }); dispose(); - expect(log).toEqual([ - "grandchild:cleanup", - "child:cleanup", - "outer:cleanup", - ]); + 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) * - * On computed re-evaluation, any effect created by the previous - * eval must be cleaned up before the new eval runs. + * 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 created in computed: old inner cleanup before new inner setup"( + "#242 effect in computed: old inner cleanup (if any) before new eval"( fw: ReactiveFramework ) { if (!hasEffectCleanup(fw)) throw new SkipTest("no effectCleanup"); @@ -965,11 +927,15 @@ export const cases: Record any> = { log.length = 0; a.write(1); - expect(log).toEqual([ - "inner:cleanup", - "computed:eval", - "inner:run", - ]); + 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); + } }, /** @@ -977,11 +943,12 @@ export const cases: Record any> = { * * 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 dispose children before its own - * cleanup — i.e., the inner-only path must not corrupt the - * outer's "has child effect" tracking. + * 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 order correct on outer re-run after prior inner-only re-run"( + "#243 cleanup ordering correct after prior inner-only re-run"( fw: ReactiveFramework ) { if (!hasEffectCleanup(fw)) throw new SkipTest("no effectCleanup"); @@ -1004,11 +971,14 @@ export const cases: Record any> = { log.length = 0; a.write(1); - expect(log).toEqual([ - "inner:cleanup", - "outer:cleanup", - "outer:run", - "inner:run", - ]); + 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); + } }, }; From f9db1d79326012d7ca00c756a113c4d786726dbf Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 14 May 2026 23:27:53 +0800 Subject: [PATCH 3/3] Update coverage-matrix.md with round 2 (cleanup ordering) --- docs/coverage-matrix.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) 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 +