@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
966 lines (825 loc) • 21.4 kB
text/typescript
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)
})
})
})