From 8d20e2c605fd070f2bcac3795db4c60531762dbb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 06:36:42 +0000 Subject: [PATCH 1/5] Catch throwing cleanups in reatom adapter instead of leaking uncaught exceptions Co-authored-by: Johnson Chu --- docs/coverage-matrix.md | 138 ------------------ test/frameworks/reatom.ts | 11 +- .../{xReactivity.ts => solidSignals.ts} | 0 3 files changed, 10 insertions(+), 139 deletions(-) delete mode 100644 docs/coverage-matrix.md rename test/frameworks/{xReactivity.ts => solidSignals.ts} (100%) diff --git a/docs/coverage-matrix.md b/docs/coverage-matrix.md deleted file mode 100644 index a77a9a8..0000000 --- a/docs/coverage-matrix.md +++ /dev/null @@ -1,138 +0,0 @@ -# Coverage Matrix - -Systematic audit of reactive primitive interactions in the test suite, -identifying combinations that aren't covered. - -## Method - -Cross every "inner action" (read/write/create/dispose) with every -"outer context" (effect body, effect cleanup, computed getter, scope -body, batch, untracked). Each cell lists representative existing -tests; empty cells are gaps. - -## Matrix - -| Inner ↓ / Outer → | effect.body | effect.cleanup | computed.getter | scope.body | batch | untracked | -|---|---|---|---|---|---|---| -| **read signal** | #36 | #40 (untracked) | #19 | (implicit) | #67 | #75 | -| **write signal** | #50 | #51, #120 | #52 | — | #65-#74 family | #156 | -| **read computed** | (implicit) | — | #22 chain | (implicit) | #68, #128 | #117, #118 | -| **create signal** | — | — | #115 | — | — | — | -| **create computed** | — | — | (computed in computed) | — | — | — | -| **create effect** | #43-#48, #163-#170, #209-#210, #226-#228 | #222 | — (just added in alien-signals PR #116, not in matrix yet) | new effectScope.spec.ts in alien-signals | #70, #126 | #45 | -| **create scope** | — | — | — | — | — | — | -| **dispose effect** | #108, #141 self-dispose | #111 | — | (cascading) | #42, #124, #127 | — | -| **enter batch** | #130 implicit | #120 implicit | — | — | #66 nested | — (#218, #219 nearby) | -| **enter untracked** | #75, #118 | #40 | #76, #117 | — | (#218, #219) | (nested, trivial) | -| **throw** | #84, #87, #89 | #90 | #84, #85 | — | #69, #121, #154 | — | - -## Identified Gaps - -### High-value gaps (likely to expose real differences) - -1. **Cleanup writes signal in batch** — `effect(() => { batch(() => { sig.write(...) }); return cleanup that triggers another effect })`. Tests whether cleanup interacts correctly with batch flush boundary. - -2. **Cleanup reads computed** — `effect(() => { c.read(); return () => { c.read() } })`. Verifies cleanup gets fresh computed value, not stale. - -3. **Untracked inside cleanup** — `effect(() => { sig; return () => untracked(() => otherSig.read()) })`. Untracked in cleanup is already non-tracking (#40), but explicit untracked inside might behave differently. - -4. **Effect created inside cleanup tracks deps** — already added as #222 but only verified inner runs once. Doesn't check whether new effect's own cleanup runs on subsequent disposal. - -5. **Computed used during effect cleanup re-evaluates correctly** — if computed's source changes after effect re-runs but before cleanup of previous run, cleanup sees stale or fresh value? - -6. **Dispose effect inside computed getter** — `computed(() => { dispose_other_effect(); ... })`. Side effect during pull-based eval. - -### Medium-value gaps - -7. **EffectScope created inside effect.body, disposed via parent re-run** — does scope's own children cleanup correctly when parent re-runs and "garbage-collects" the old scope via purgeDeps? - -8. **EffectScope dispose inside batch** — `batch(() => { scopeDispose() })`. Does the batched flush still happen correctly afterwards? - -9. **Throw in scope body during setup** — `effectScope(() => { effect(() => {}); throw new Error() })`. Graph state after. - -10. **Nested batch + nested effect** — `effect(() => { batch(() => { sig.write(...) }) })`. Multiple writes in a single body, batched, into an effect that triggers more effects. - -11. **Untracked dispose** — `untracked(() => disposeEffect())`. Trivial but never tested. - -12. **Cycle through effect cleanup** — effect's cleanup writes a signal that re-triggers itself. - -13. **Computed reading itself transitively** — `c1 = computed(() => c2.read())`, `c2 = computed(() => c1.read())`. Already partially in #150/#151/#152/#153 but those are tautological — recently flagged. - -### Low-value (edge / unlikely to find real bugs) - -14. **Untracked inside untracked** — semantically same as one level -15. **Batch inside batch inside batch** — nested past 2 levels -16. **Signal write inside untracked inside computed** — covered by composition -17. **Computed read inside untracked inside effect body** — covered by #75/#117 - -## Test Proposal Summary - -Of the 13 high+medium gaps, the ones I'd recommend adding as cross-framework tests: - -| Priority | Test name (proposed) | Likely to differ across frameworks? | -|---|---|---| -| H | #229 cleanup reads computed returns fresh value | Yes — staleness handling varies | -| H | #230 cleanup writes signal inside batch | Yes — batch + cleanup ordering varies | -| H | #231 untracked inside cleanup | Maybe — implementations differ | -| H | ~~#232 effect created inside cleanup is disposed when outer disposes~~ | (Dropped — result vector identical to #222) | -| H | #233 computed re-eval during cleanup of effect | Maybe — reframed as cleanup write→read of dependent computed | -| H | ~~#234 dispose effect from inside computed getter~~ | (Dropped — covered by #201; reframed sibling-dispose version had identical result vector to #39/#110) | -| M | #235 batch inside effect body coalesces writes | Yes — only 4 frameworks pass | -| M | #236 cleanup write to own dep (bounded recursion) | Maybe — only angular fails | -| M | scope-related tests (#235/#236/#237 in original list) | — Out of scope (suite has no `effectScope` in framework interface) | -| L | untracked dispose | No — trivial, skipped | - -## Implementation Result - -| Test | Added | Discriminates | -|---|---|---| -| #229 cleanup reads computed | ✅ | svelte/pota fail | -| #230 cleanup writes signal in batch | ✅ | S.js/pota fail | -| #231 untracked inside cleanup | ✅ | only pota fails | -| #232 (original) cleanup-created lifecycle | ❌ dropped | (would group with #222) | -| #233 cleanup write→read dependent computed | ✅ | anod/S.js/pota fail | -| #234 (original) cleanup disposes sibling | ❌ dropped | (would group with #39/#110) | -| #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/test/frameworks/reatom.ts b/test/frameworks/reatom.ts index c49ba9d..712de1f 100644 --- a/test/frameworks/reatom.ts +++ b/test/frameworks/reatom.ts @@ -30,7 +30,16 @@ export const reatomFramework: ReactiveFramework = { const instance = effect(() => { const maybeCleanup = batch(fn); if (typeof maybeCleanup === "function") { - abortVar.subscribe(maybeCleanup); + // Reatom runs cleanups as AbortSignal listeners, so a throwing cleanup + // never reaches the caller; Node would instead report it as an + // uncaught exception after the test has finished. + abortVar.subscribe(() => { + try { + maybeCleanup(); + } catch (e) { + console.error(e); + } + }); } }); diff --git a/test/frameworks/xReactivity.ts b/test/frameworks/solidSignals.ts similarity index 100% rename from test/frameworks/xReactivity.ts rename to test/frameworks/solidSignals.ts From 5379e0b3566775a8027ded3a459be5d1c76769a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 06:36:42 +0000 Subject: [PATCH 2/5] Publish compiled JS with type declarations; stop exporting internal capability helpers Co-authored-by: Johnson Chu --- package-lock.json | 58 +++++++++++++++------------------------------ package.json | 42 +++++++++++++++++--------------- src/index.ts | 2 +- tsconfig.build.json | 9 +++++++ tsconfig.json | 3 ++- 5 files changed, 54 insertions(+), 60 deletions(-) create mode 100644 tsconfig.build.json diff --git a/package-lock.json b/package-lock.json index 84e886c..939f5f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "reactive-framework-test-suite", "version": "0.0.2", + "license": "MIT", "devDependencies": { "@amadeus-it-group/tansu": "^2.0.0", "@angular/compiler": "^20.0.0", @@ -15,6 +16,7 @@ "@reactively/core": "^0.0.8", "@reatom/core": "^1001.0.0", "@solidjs/signals": "^0.3.0", + "@types/node": "^22.20.4", "@vue/reactivity": "^3.5.0", "alien-signals": "^3.2.1", "anod": "^0.9.1", @@ -994,9 +996,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1011,9 +1010,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1028,9 +1024,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1045,9 +1038,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1062,9 +1052,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1079,9 +1066,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1096,9 +1080,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1113,9 +1094,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1130,9 +1108,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1147,9 +1122,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1164,9 +1136,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1181,9 +1150,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1198,9 +1164,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1340,6 +1303,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -2387,6 +2360,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/package.json b/package.json index 85feac5..ebef7b8 100644 --- a/package.json +++ b/package.json @@ -1,32 +1,35 @@ { "name": "reactive-framework-test-suite", "version": "0.0.2", + "description": "Cross-framework test suite for reactive signal libraries: propagation, batching, disposal, error handling and other edge cases.", + "keywords": [ + "signals", + "reactivity", + "reactive", + "test-suite", + "computed", + "effect" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/johnsoncodehk/reactive-framework-test-suite.git" + }, "license": "MIT", "type": "module", "exports": { - ".": "./src/index.ts" + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } }, "files": [ - "src/index.ts", - "src/assert.ts", - "src/framework.ts", - "src/graphPropagation.ts", - "src/dynamicDeps.ts", - "src/computedEval.ts", - "src/equality.ts", - "src/effectLifecycle.ts", - "src/nestedEffects.ts", - "src/innerWrite.ts", - "src/cycleDetection.ts", - "src/batching.ts", - "src/untracked.ts", - "src/errorHandling.ts", - "src/staleEvaluation.ts", - "src/memoryGc.ts", - "src/behaviorDifferences.ts" + "dist" ], "scripts": { - "test": "vitest run; node scripts/update-readme.mjs" + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test": "vitest run && node scripts/update-readme.mjs", + "prepack": "npm run build" }, "devDependencies": { "@amadeus-it-group/tansu": "^2.0.0", @@ -36,6 +39,7 @@ "@reactively/core": "^0.0.8", "@reatom/core": "^1001.0.0", "@solidjs/signals": "^0.3.0", + "@types/node": "^22.20.4", "@vue/reactivity": "^3.5.0", "alien-signals": "^3.2.1", "anod": "^0.9.1", diff --git a/src/index.ts b/src/index.ts index e4e7943..1a23cb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export type { ReactiveFramework, Signal, Computed } from "./framework.js"; -export { SkipTest, detectCapabilities, hasEffectCleanup, hasComputedThrows } from "./framework.js"; +export { SkipTest } from "./framework.js"; export { expect, setExpect } from "./assert.js"; import type { ReactiveFramework } from "./framework.js"; diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..f12f1ba --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json index 63241dc..754e94e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,5 +6,6 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true - } + }, + "exclude": ["dist", "node_modules"] } From 7c620e51ba5bb31161f1995a44fa8d2250a44efb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 06:36:42 +0000 Subject: [PATCH 3/5] README: skip behavioral cases in usage example Co-authored-by: Johnson Chu --- README.md | 7 +++++-- scripts/update-readme.mjs | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ecc4049..c356818 100644 --- a/README.md +++ b/README.md @@ -2280,13 +2280,16 @@ Wire it up with your test runner (vitest, jest, mocha, etc.): ```ts import { testSuite, SkipTest, setExpect } from "reactive-framework-test-suite"; -import { expect } from "vitest"; +import { describe, test, expect } from "vitest"; // Optional: swap the built-in expect for your runner's // for richer error messages and tighter integration. setExpect(expect); -for (const { section, cases } of testSuite) { +for (const { section, cases, type } of testSuite) { + // Behavioral cases never fail; they return a string describing + // the design choice your framework made (see Behavioral Differences). + if (type === "behavioral") continue; describe(section, () => { for (const [name, fn] of Object.entries(cases)) { test(name, () => { diff --git a/scripts/update-readme.mjs b/scripts/update-readme.mjs index e3ef247..dd6110f 100644 --- a/scripts/update-readme.mjs +++ b/scripts/update-readme.mjs @@ -516,13 +516,16 @@ Wire it up with your test runner (vitest, jest, mocha, etc.): \`\`\`ts import { testSuite, SkipTest, setExpect } from "reactive-framework-test-suite"; -import { expect } from "vitest"; +import { describe, test, expect } from "vitest"; // Optional: swap the built-in expect for your runner's // for richer error messages and tighter integration. setExpect(expect); -for (const { section, cases } of testSuite) { +for (const { section, cases, type } of testSuite) { + // Behavioral cases never fail; they return a string describing + // the design choice your framework made (see Behavioral Differences). + if (type === "behavioral") continue; describe(section, () => { for (const [name, fn] of Object.entries(cases)) { test(name, () => { From e6e8ad4711bb604fb79b0233aee6249ed8c08cde Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 06:36:42 +0000 Subject: [PATCH 4/5] CI: run typecheck, tests and build on PRs; use Node 22 and npm ci Co-authored-by: Johnson Chu --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ .github/workflows/update-readme.yml | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bfe6f35 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - run: npm run typecheck + + - run: npx vitest run + + - run: npm run build diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml index f24345d..c566866 100644 --- a/.github/workflows/update-readme.yml +++ b/.github/workflows/update-readme.yml @@ -17,10 +17,10 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm - - run: npm install + - run: npm ci - run: npm test From cc508a5c37b2686ad48a5e7a3f829186a7c053b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 06:36:48 +0000 Subject: [PATCH 5/5] Rename xReactivity adapter to solidSignals; use @types/node; drop coverage-matrix notes Co-authored-by: Johnson Chu --- test/env.d.ts | 15 --------------- test/frameworks/index.ts | 4 ++-- test/frameworks/solidSignals.ts | 2 +- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/test/env.d.ts b/test/env.d.ts index d4131e2..2c762b1 100644 --- a/test/env.d.ts +++ b/test/env.d.ts @@ -1,18 +1,3 @@ -/// - -interface ImportMeta { - dirname: string; -} - -declare module "fs" { - export function mkdirSync(path: string, options?: { recursive?: boolean }): void; - export function writeFileSync(path: string, data: string): void; -} - -declare module "path" { - export function join(...paths: string[]): string; -} - declare module "pota" { export const createSignal: any; export const memo: any; diff --git a/test/frameworks/index.ts b/test/frameworks/index.ts index 7cc9215..f836650 100644 --- a/test/frameworks/index.ts +++ b/test/frameworks/index.ts @@ -14,7 +14,7 @@ import { svelteFramework } from "./svelte.js"; import { tansuFramework } from "./tansu.js"; import { tc39SignalsFramework } from "./tc39Signals.js"; import { vueReactivityFramework } from "./vueReactivity.js"; -import { xReactivityFramework } from "./xReactivity.js"; +import { solidSignalsFramework } from "./solidSignals.js"; export const frameworks: ReactiveFramework[] = [ alienSignalsFramework, @@ -27,7 +27,7 @@ export const frameworks: ReactiveFramework[] = [ reatomFramework, svelteFramework, solidFramework, - xReactivityFramework, + solidSignalsFramework, sjsFramework, potaFramework, angularSignalsFramework, diff --git a/test/frameworks/solidSignals.ts b/test/frameworks/solidSignals.ts index bc30614..987e447 100644 --- a/test/frameworks/solidSignals.ts +++ b/test/frameworks/solidSignals.ts @@ -17,7 +17,7 @@ function safeFlush() { } catch {} } -export const xReactivityFramework: ReactiveFramework = { +export const solidSignalsFramework: ReactiveFramework = { name: "@solidjs/signals", signal(initialValue) { const [read, write] = createSignal(initialValue as any);