UNPKG

@zeix/cause-effect

Version:

Cause & Effect - reactive state management primitives library for TypeScript.

966 lines (825 loc) 21.4 kB
import { describe, expect, mock, test } from 'bun:test' import { batch, createEffect, createMemo, createScope, createState, createTask, match, RequiredOwnerError, } from '../index.ts' /* === Utility Functions === */ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) /* === Tests === */ describe('createEffect', () => { test('should run immediately on creation', () => { let ran = false createEffect(() => { ran = true }) expect(ran).toBe(true) }) test('should re-run when a tracked dependency changes', () => { const source = createState('foo') let count = 0 createEffect(() => { source.get() count++ }) expect(count).toBe(1) source.set('bar') expect(count).toBe(2) }) test('should re-run on each state change', () => { const source = createState(0) let result = 0 createEffect(() => { result = source.get() }) for (let i = 1; i <= 5; i++) { source.set(i) expect(result).toBe(i) } }) test('should handle state updates inside effects', () => { const count = createState(0) let effectCount = 0 createEffect(() => { effectCount++ if (count.get() === 0) count.set(1) }) expect(count.get()).toBe(1) expect(effectCount).toBe(2) }) describe('Cleanup', () => { test('should call cleanup before next run', () => { const source = createState(0) let cleanupCount = 0 let effectCount = 0 createEffect(() => { source.get() effectCount++ return () => { cleanupCount++ } }) expect(effectCount).toBe(1) expect(cleanupCount).toBe(0) source.set(1) expect(effectCount).toBe(2) expect(cleanupCount).toBe(1) source.set(2) expect(effectCount).toBe(3) expect(cleanupCount).toBe(2) }) test('should call cleanup on disposal', () => { const source = createState(0) let cleanupCalled = false const dispose = createEffect(() => { source.get() return () => { cleanupCalled = true } }) expect(cleanupCalled).toBe(false) dispose() expect(cleanupCalled).toBe(true) }) test('should stop reacting after disposal', () => { const source = createState(42) let received = 0 const dispose = createEffect(() => { received = source.get() }) source.set(43) expect(received).toBe(43) dispose() source.set(44) expect(received).toBe(43) }) }) describe('Owner Registration', () => { test('should dispose nested effects when parent scope is disposed', () => { const source = createState(0) let innerRuns = 0 const dispose = createScope(() => { createEffect(() => { source.get() innerRuns++ }) }) expect(innerRuns).toBe(1) source.set(1) expect(innerRuns).toBe(2) dispose() source.set(2) expect(innerRuns).toBe(2) // no longer reacting }) }) describe('Watched memo equality', () => { test('should skip effect re-run when watched memo recomputes to same value', () => { let invalidate!: () => void let effectCount = 0 // Memo whose computed value does not change on invalidation const memo = createMemo(() => 42, { value: 42, watched: inv => { invalidate = inv return () => {} }, }) const dispose = createScope(() => { createEffect(() => { void memo.get() effectCount++ }) }) expect(effectCount).toBe(1) // Invalidate — memo recomputes but returns same value (42) invalidate() // Because equals(42, 42) is true, the effect should NOT re-run expect(effectCount).toBe(1) dispose() }) test('should re-run effect when watched memo recomputes to different value', () => { let invalidate!: () => void let effectCount = 0 let externalValue = 1 const memo = createMemo(() => externalValue, { value: 0, watched: inv => { invalidate = inv return () => {} }, }) let observed = 0 const dispose = createScope(() => { createEffect(() => { observed = memo.get() effectCount++ }) }) expect(effectCount).toBe(1) expect(observed).toBe(1) // Change external value and invalidate — memo returns a new value externalValue = 99 invalidate() expect(effectCount).toBe(2) expect(observed).toBe(99) dispose() }) test('should respect custom equals to skip effect re-run', () => { let invalidate!: () => void let effectCount = 0 let externalValue = 3 // Custom equals: treat values as equal when they round to the same integer const memo = createMemo(() => externalValue, { value: 0, equals: (a, b) => Math.floor(a) === Math.floor(b), watched: inv => { invalidate = inv return () => {} }, }) const dispose = createScope(() => { createEffect(() => { void memo.get() effectCount++ }) }) expect(effectCount).toBe(1) // External value changes slightly but rounds to same integer externalValue = 3.7 invalidate() expect(effectCount).toBe(1) // equals says same → effect skipped // External value changes to a different integer externalValue = 4.1 invalidate() expect(effectCount).toBe(2) // equals says different → effect runs dispose() }) test('should skip effect re-run through memo chain when watched memo value unchanged', () => { let invalidate!: () => void let effectCount = 0 const watchedMemo = createMemo(() => 42, { value: 42, watched: inv => { invalidate = inv return () => {} }, }) // Downstream memo that doubles the watched memo value const doubled = createMemo(() => watchedMemo.get() * 2) const dispose = createScope(() => { createEffect(() => { void doubled.get() effectCount++ }) }) expect(effectCount).toBe(1) // Invalidate — watchedMemo recomputes to same value, so doubled // should also remain unchanged, and the effect should not re-run invalidate() expect(effectCount).toBe(1) dispose() }) test('should skip effect when invalidate is called inside batch and value unchanged', () => { let invalidate!: () => void let effectCount = 0 const memo = createMemo(() => 42, { value: 42, watched: inv => { invalidate = inv return () => {} }, }) const dispose = createScope(() => { createEffect(() => { void memo.get() effectCount++ }) }) expect(effectCount).toBe(1) batch(() => { invalidate() }) // Value didn't change so effect should still be at 1 expect(effectCount).toBe(1) dispose() }) test('should still run effect for dirty state even when watched memo unchanged', () => { let invalidate!: () => void let effectCount = 0 const state = createState(1) const memo = createMemo(() => 42, { value: 42, watched: inv => { invalidate = inv return () => {} }, }) let observedState = 0 const dispose = createScope(() => { createEffect(() => { observedState = state.get() void memo.get() effectCount++ }) }) expect(effectCount).toBe(1) // Change the state AND invalidate — effect must run because state changed batch(() => { state.set(2) invalidate() }) expect(effectCount).toBe(2) expect(observedState).toBe(2) dispose() }) }) describe('Input Validation', () => { test('should throw InvalidCallbackError for non-function', () => { // @ts-expect-error - Testing invalid input expect(() => createEffect(null)).toThrow( '[Effect] Callback null is invalid', ) // @ts-expect-error - Testing invalid input expect(() => createEffect(42)).toThrow( '[Effect] Callback 42 is invalid', ) }) }) }) describe('match', () => { test('should call ok handler when all signals have values', () => { const a = createState(1) const b = createState(2) let result = 0 createEffect(() => match([a, b], { ok: ([aVal, bVal]) => { result = aVal + bVal }, }), ) expect(result).toBe(3) }) test('should call nil handler when signals are unset', async () => { const task = createTask(async () => { await wait(50) return 42 }) let okCount = 0 let nilCount = 0 createEffect(() => match([task], { ok: ([value]) => { okCount++ expect(value).toBe(42) }, nil: () => { nilCount++ }, }), ) expect(okCount).toBe(0) expect(nilCount).toBe(1) await wait(60) expect(okCount).toBeGreaterThan(0) expect(nilCount).toBe(1) }) test('should call err handler when signals throw', () => { const a = createState(1) const b = createMemo(() => { if (a.get() > 5) throw new Error('Too high') return a.get() * 2 }) let okCount = 0 let errCount = 0 createEffect(() => match([b], { ok: () => { okCount++ }, err: errors => { errCount++ // biome-ignore lint/style/noNonNullAssertion: test expect(errors[0]!.message).toBe('Too high') }, }), ) expect(okCount).toBe(1) a.set(6) expect(errCount).toBe(1) a.set(3) expect(okCount).toBe(2) expect(errCount).toBe(1) }) test('should fall back to console.error when err handler is not provided', () => { const originalConsoleError = console.error const mockConsoleError = mock(() => {}) console.error = mockConsoleError try { const a = createState(1) const b = createMemo(() => { if (a.get() > 5) throw new Error('Too high') return a.get() * 2 }) createEffect(() => match([b], { ok: () => {}, }), ) a.set(6) expect(mockConsoleError).toHaveBeenCalled() } finally { console.error = originalConsoleError } }) test('should preserve tuple types in ok handler', () => { const a = createState(1) const b = createState('hello') createEffect(() => match([a, b], { ok: ([aVal, bVal]) => { // If tuple types are preserved, aVal is number and bVal is string // If widened, both would be string | number const num: number = aVal const str: string = bVal expect(num).toBe(1) expect(str).toBe('hello') }, }), ) }) test('should throw RequiredOwnerError when called outside an owner', () => { expect(() => match([], { ok: () => {} })).toThrow(RequiredOwnerError) }) describe('Single-signal overload', () => { test('should call ok with unwrapped value', () => { const s = createState(42) let result = 0 createEffect(() => match(s, { ok: value => { result = value }, }), ) expect(result).toBe(42) s.set(99) expect(result).toBe(99) }) test('should call nil handler when signal is unset', async () => { const task = createTask(async () => { await wait(50) return 42 }) let okCount = 0 let nilCount = 0 createEffect(() => match(task, { ok: value => { okCount++ expect(value).toBe(42) }, nil: () => { nilCount++ }, }), ) expect(okCount).toBe(0) expect(nilCount).toBe(1) await wait(60) expect(okCount).toBeGreaterThan(0) expect(nilCount).toBe(1) }) test('should call err with unwrapped Error', () => { const a = createState(1) const b = createMemo(() => { if (a.get() > 5) throw new Error('Too high') return a.get() * 2 }) let okCount = 0 let errCount = 0 createEffect(() => match(b, { ok: () => { okCount++ }, err: error => { errCount++ expect(error.message).toBe('Too high') }, }), ) expect(okCount).toBe(1) a.set(6) expect(errCount).toBe(1) a.set(3) expect(okCount).toBe(2) expect(errCount).toBe(1) }) test('should fall back to console.error for single signal without err handler', () => { const originalConsoleError = console.error const mockConsoleError = mock(() => {}) console.error = mockConsoleError try { const a = createState(1) const b = createMemo(() => { if (a.get() > 5) throw new Error('Too high') return a.get() * 2 }) createEffect(() => match(b, { ok: () => {} })) a.set(6) expect(mockConsoleError).toHaveBeenCalled() } finally { console.error = originalConsoleError } }) }) test('should resolve multiple async tasks without waterfalls', async () => { const a = createTask(async () => { await wait(20) return 10 }) const b = createTask(async () => { await wait(20) return 20 }) let result = 0 let nilCount = 0 createEffect(() => match([a, b], { ok: ([aVal, bVal]) => { result = aVal + bVal }, nil: () => { nilCount++ }, }), ) expect(result).toBe(0) expect(nilCount).toBe(1) await wait(30) expect(result).toBe(30) }) describe('stale handler', () => { // stale fires when: task.get() succeeds (retained value) AND task.isPending() is true. // recomputeTask() sets node.controller synchronously, so isPending() = true immediately // after the first get() call that triggers recomputation. test('should call stale on initial run when task has a seeded value and is computing', async () => { const task = createTask(async () => { await wait(50) return 99 }, { value: 42 }) let okCount = 0 let staleCount = 0 createEffect(() => match(task, { ok: () => { okCount++ }, stale: () => { staleCount++ }, }), ) // First run: task has 42 (seeded) but is computing → stale expect(staleCount).toBe(1) expect(okCount).toBe(0) await wait(60) // Resolved to 99 (changed): ok expect(okCount).toBe(1) expect(staleCount).toBe(1) }) test('should call stale when another dependency changes while task is still pending', async () => { const other = createState(0) const task = createTask(async () => { await wait(100) return 42 }, { value: 0 }) const log: string[] = [] createEffect(() => { const o = other.get() match(task, { ok: v => { log.push(`ok:${v}:${o}`) }, stale: () => { log.push(`stale:${o}`) }, }) }) // Initial run: task has seeded value 0, computing → stale expect(log).toEqual(['stale:0']) // While task is still in flight, another dependency changes → effect re-runs FLAG_DIRTY other.set(1) expect(log).toEqual(['stale:0', 'stale:1']) await wait(110) // Task resolved: effect re-runs, isPending() = false → ok expect(log[log.length - 1]).toMatch(/^ok:42:/) }) test('should fall back to ok when stale handler is absent', async () => { const task = createTask(async () => { await wait(50) return 99 }, { value: 42 }) let okCount = 0 createEffect(() => match(task, { ok: () => { okCount++ }, }), ) // No stale handler: falls back to ok even while pending expect(okCount).toBe(1) await wait(60) // Resolved to 99 (different value): ok again expect(okCount).toBe(2) }) test('should call stale for tuple overload when any task is re-computing', async () => { const a = createState(10) const task = createTask(async () => { await wait(50) return 99 }, { value: 0 }) let okCount = 0 let staleCount = 0 createEffect(() => match([a, task], { ok: () => { okCount++ }, stale: () => { staleCount++ }, }), ) // First run: task has seeded value 0, computing → stale expect(staleCount).toBe(1) expect(okCount).toBe(0) await wait(60) // Task resolved to 99: ok (with a=10) expect(okCount).toBe(1) expect(staleCount).toBe(1) }) test('nil takes precedence over stale', async () => { // One task unresolved (no initial value → nil), one task with seeded value (stale) const staleTask = createTask(async () => { await wait(200) return 42 }, { value: 0 }) const nilTask = createTask(async () => { await wait(200) return 99 }) let nilCount = 0 let staleCount = 0 let okCount = 0 createEffect(() => match([staleTask, nilTask], { ok: () => { okCount++ }, nil: () => { nilCount++ }, stale: () => { staleCount++ }, }), ) // nilTask throws UnsetSignalValueError → pending = true → nil wins over stale expect(nilCount).toBe(1) expect(staleCount).toBe(0) expect(okCount).toBe(0) }) test('should call stale on re-fetch after task has previously resolved', async () => { const source = createState(1) const task = createTask( async () => { const val = source.get() await wait(50) return val * 10 }, { value: 0 }, ) const log: string[] = [] createEffect(() => match(task, { ok: v => { log.push(`ok:${v}`) }, stale: () => { log.push('stale') }, }), ) expect(log).toEqual(['stale']) await wait(60) expect(log).toEqual(['stale', 'ok:10']) // Core bug: source changes → task re-fetches → stale must fire source.set(2) expect(log).toEqual(['stale', 'ok:10', 'stale']) await wait(60) expect(log).toEqual(['stale', 'ok:10', 'stale', 'ok:20']) }) test('should transition stale → ok when re-fetch resolves to same value', async () => { const source = createState(1) const task = createTask( async () => { source.get() await wait(50) return 42 }, { value: 42 }, ) const log: string[] = [] createEffect(() => match(task, { ok: () => { log.push('ok') }, stale: () => { log.push('stale') }, }), ) expect(log).toEqual(['stale']) await wait(60) // Resolved to 42 (same as seed) — stale → ok transition must fire expect(log).toEqual(['stale', 'ok']) source.set(2) expect(log).toEqual(['stale', 'ok', 'stale']) await wait(60) // Re-resolves to 42 again (value unchanged) — must still transition to ok expect(log).toEqual(['stale', 'ok', 'stale', 'ok']) }) test('stale cleanup runs before next dispatch', async () => { const task = createTask(async () => { await wait(50) return 99 }, { value: 42 }) let cleanupCount = 0 createEffect(() => match(task, { ok: () => {}, stale: () => () => { cleanupCount++ }, }), ) // First run: stale → cleanup function registered expect(cleanupCount).toBe(0) await wait(60) // Task resolved (42 → 99): effect re-runs, cleanup runs first, then ok expect(cleanupCount).toBe(1) }) }) describe('Async Handlers', () => { test('should not register cleanup from stale async handler after disposal', async () => { let cleanupRegistered = false const dispose = createEffect(() => match([], { ok: async () => { await wait(50) return () => { cleanupRegistered = true } }, }), ) await wait(10) dispose() await wait(60) expect(cleanupRegistered).toBe(false) }) test('should register and run cleanup from completed async handler', async () => { let cleanupCalled = false const dispose = createEffect(() => match([], { ok: async () => { await wait(10) return () => { cleanupCalled = true } }, }), ) await wait(20) dispose() expect(cleanupCalled).toBe(true) }) test('should route async errors to err handler', async () => { const originalConsoleError = console.error const mockConsoleError = mock(() => {}) console.error = mockConsoleError try { const source = createState(1) createEffect(() => match([source], { ok: async ([value]) => { await wait(10) if (value > 3) throw new Error('Async error') }, }), ) source.set(4) await wait(20) expect(mockConsoleError).toHaveBeenCalled() } finally { console.error = originalConsoleError } }) test('should discard stale async cleanup when effect re-runs', async () => { const source = createState(1) let staleCleanupCalled = false let freshCleanupCalled = false const dispose = createEffect(() => match([source], { ok: async ([value]) => { if (value === 1) { await wait(80) return () => { staleCleanupCalled = true } } await wait(10) return () => { freshCleanupCalled = true } }, }), ) await wait(20) source.set(2) await wait(100) expect(staleCleanupCalled).toBe(false) dispose() expect(freshCleanupCalled).toBe(true) }) test('should call async cleanup before re-running', async () => { const source = createState(0) let cleanupCount = 0 let okCount = 0 createEffect(() => match([source], { ok: async () => { okCount++ await wait(10) return () => { cleanupCount++ } }, }), ) await wait(20) expect(okCount).toBe(1) expect(cleanupCount).toBe(0) source.set(1) expect(cleanupCount).toBe(1) await wait(20) expect(okCount).toBe(2) }) }) describe('err handler cleanup', () => { test('cleanup returned by err is called when ok handler throws', () => { const source = createState(1) let cleanupCount = 0 createEffect(() => match(source, { ok: () => { throw new Error('ok failed') }, err: () => () => { cleanupCount++ }, }), ) expect(cleanupCount).toBe(0) // no cleanup on first run source.set(2) // re-run should call previous cleanup expect(cleanupCount).toBe(1) }) }) })