@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
530 lines (465 loc) • 11.9 kB
text/typescript
import { describe, expect, test } from 'bun:test'
import {
createEffect,
createMemo,
createScope,
createState,
createTask,
isMemo,
isTask,
UnsetSignalValueError,
} from '../index.ts'
/* === Utility Functions === */
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
/* === Tests === */
describe('Task', () => {
describe('createTask', () => {
test('should resolve async computation', async () => {
const task = createTask(
async () => {
await wait(50)
return 42
},
{ value: 0 },
)
expect(task.get()).toBe(0)
await wait(60)
expect(task.get()).toBe(42)
})
test('should have Symbol.toStringTag of "Task"', () => {
const task = createTask(async () => 1, { value: 0 })
expect(task[Symbol.toStringTag]).toBe('Task')
})
test('should throw UnsetSignalValueError before resolution with no initial value', () => {
const task = createTask(async () => {
await wait(50)
return 42
})
expect(() => task.get()).toThrow(UnsetSignalValueError)
})
})
describe('isTask', () => {
test('should identify task signals', () => {
expect(isTask(createTask(async () => 1, { value: 0 }))).toBe(true)
})
test('should return false for non-task values', () => {
expect(isTask(42)).toBe(false)
expect(isTask(null)).toBe(false)
expect(isTask({})).toBe(false)
expect(isMemo(createTask(async () => 1, { value: 0 }))).toBe(false)
})
})
describe('isPending', () => {
test('should return true while computation is in-flight', async () => {
const task = createTask(
async () => {
await wait(50)
return 42
},
{ value: 0 },
)
task.get() // trigger computation
expect(task.isPending()).toBe(true)
await wait(60)
task.get() // read resolved value
expect(task.isPending()).toBe(false)
})
test('should return false before first get()', () => {
const task = createTask(async () => 42, { value: 0 })
expect(task.isPending()).toBe(false)
})
})
describe('abort', () => {
test('should abort the current computation', async () => {
let completed = false
const task = createTask(
async (_prev, signal) => {
await wait(50)
if (!signal.aborted) completed = true
return 42
},
{ value: 0 },
)
task.get() // trigger computation
expect(task.isPending()).toBe(true)
task.abort()
expect(task.isPending()).toBe(false)
await wait(60)
expect(completed).toBe(false)
})
})
describe('Dependency Tracking', () => {
test('should re-execute when dependencies change', async () => {
const source = createState(1)
const task = createTask(
async () => {
const val = source.get() // dependency tracked before await
await wait(50)
return val * 2
},
{ value: 0 },
)
let result = 0
createEffect(() => {
result = task.get()
})
expect(result).toBe(0)
await wait(60)
expect(result).toBe(2)
source.set(5)
await wait(60)
expect(result).toBe(10)
})
test('should work with downstream memos', async () => {
const status = createState('pending')
const task = createTask(async () => {
await wait(50)
status.set('success')
return 42
})
const derived = createMemo(() => {
try {
return task.get() + 1
} catch {
return 0
}
})
expect(derived.get()).toBe(0)
expect(status.get()).toBe('pending')
await wait(60)
expect(derived.get()).toBe(43)
expect(status.get()).toBe('success')
})
test('should run tasks in parallel without waterfalls', async () => {
const a = createTask(
async () => {
await wait(80)
return 10
},
{ value: 0 },
)
const b = createTask(
async () => {
await wait(80)
return 20
},
{ value: 0 },
)
const sum = createMemo(() => a.get() + b.get(), { value: 0 })
expect(sum.get()).toBe(0)
await wait(90)
expect(sum.get()).toBe(30)
})
})
describe('AbortSignal', () => {
test('should signal abort when dependency changes during computation', async () => {
const source = createState(1)
let wasAborted = false
const task = createTask(
async (_prev, signal) => {
const val = source.get()
await wait(100)
if (signal.aborted) wasAborted = true
return val
},
{ value: 0 },
)
task.get() // start computation
await wait(10)
source.set(2) // change dependency mid-flight
await wait(110)
expect(wasAborted).toBe(true)
})
test('should coalesce multiple rapid changes into one recomputation', async () => {
const source = createState(1)
let computationCount = 0
const task = createTask(
async () => {
computationCount++
await wait(100)
return source.get()
},
{ value: 0 },
)
task.get()
expect(computationCount).toBe(1)
source.set(2)
source.set(3)
source.set(4)
await wait(210)
expect(task.get()).toBe(4)
expect(computationCount).toBe(1)
})
})
describe('Error Handling', () => {
test('should propagate async errors on get()', async () => {
const task = createTask(
async () => {
await wait(50)
throw new Error('async failure')
},
{ value: 0 },
)
task.get()
await wait(60)
expect(() => task.get()).toThrow('async failure')
})
test('should recover from errors when dependency changes', async () => {
const source = createState(1)
const task = createTask(
async () => {
const value = source.get()
await wait(50)
if (value === 2) throw new Error('bad value')
return value
},
{ value: 0 },
)
task.get()
await wait(60)
expect(task.get()).toBe(1)
source.set(2)
task.get()
await wait(60)
expect(() => task.get()).toThrow('bad value')
source.set(3)
task.get()
await wait(60)
expect(task.get()).toBe(3)
})
})
describe('options.value (prev)', () => {
test('should return initial value before resolution', () => {
const task = createTask(
async () => {
await wait(50)
return 42
},
{ value: 10 },
)
expect(task.get()).toBe(10)
})
test('should pass initial value as prev to first computation', async () => {
let receivedPrev: number | undefined
const task = createTask(
async prev => {
receivedPrev = prev
await wait(50)
return prev + 5
},
{ value: 10 },
)
expect(task.get()).toBe(10)
await wait(60)
expect(task.get()).toBe(15)
expect(receivedPrev).toBe(10)
})
test('should pass previous resolved value on recomputation', async () => {
const source = createState(1)
const receivedPrevs: number[] = []
const task = createTask(
async prev => {
const val = source.get() // dependency tracked before await
receivedPrevs.push(prev)
await wait(50)
return val + prev
},
{ value: 0 },
)
let result = 0
createEffect(() => {
result = task.get()
})
await wait(60)
expect(result).toBe(1) // 0 + 1
source.set(2)
await wait(60)
expect(result).toBe(3) // 1 + 2
expect(receivedPrevs).toEqual([0, 1])
})
})
describe('options.equals', () => {
test('should use custom equality to skip propagation after resolution', async () => {
const source = createState(1)
let effectCount = 0
const task = createTask(
async () => {
const val = source.get() // dependency tracked before await
await wait(50)
return { x: val % 2 }
},
{
value: { x: -1 },
equals: (a, b) => a.x === b.x,
},
)
createEffect(() => {
task.get()
effectCount++
})
await wait(60) // first resolution: { x: 1 }
source.set(3) // still odd — result will be { x: 1 }, structurally equal
await wait(60)
const countAfterEqual = effectCount
source.set(2) // now even — result will be { x: 0 }, different
await wait(60)
// After the structurally different result resolves, effect should run again
expect(effectCount).toBeGreaterThan(countAfterEqual)
})
})
describe('options.guard', () => {
test('should validate initial value against guard', () => {
expect(() => {
createTask(async () => 42, {
// @ts-expect-error - Testing invalid input
value: 'foo',
guard: (v): v is number => typeof v === 'number',
})
}).toThrow('[Task] Signal value "foo" is invalid')
})
test('should accept initial value that passes guard', () => {
const task = createTask(async () => 42, {
value: 10,
guard: (v): v is number => typeof v === 'number',
})
expect(task.get()).toBe(10)
})
})
describe('Input Validation', () => {
test('should throw InvalidCallbackError for sync callback', () => {
expect(() => {
// @ts-expect-error - Testing invalid input
createTask((_a: unknown) => 42)
}).toThrow('[Task] Callback (_a) => 42 is invalid')
})
test('should throw InvalidCallbackError for non-function callback', () => {
// @ts-expect-error - Testing invalid input
expect(() => createTask(null)).toThrow(
'[Task] Callback null is invalid',
)
// @ts-expect-error - Testing invalid input
expect(() => createTask(42)).toThrow(
'[Task] Callback 42 is invalid',
)
})
test('should throw NullishSignalValueError for null initial value', () => {
expect(() => {
// @ts-expect-error - Testing invalid input
createTask(async () => 42, { value: null })
}).toThrow('[Task] Signal value cannot be null or undefined')
})
})
describe('options.watched', () => {
test('should call watched on first effect access', () => {
let watchedCount = 0
const task = createTask(
async () => {
await wait(10)
return 1
},
{
value: 0,
watched: _invalidate => {
watchedCount++
return () => {}
},
},
)
expect(watchedCount).toBe(0)
const dispose = createScope(() => {
createEffect(() => {
void task.get()
})
})
expect(watchedCount).toBe(1)
dispose()
})
test('should call cleanup when last effect stops watching', () => {
let cleanedUp = false
const task = createTask(
async () => {
await wait(10)
return 1
},
{
value: 0,
watched: _invalidate => {
return () => {
cleanedUp = true
}
},
},
)
const dispose = createScope(() => {
createEffect(() => {
void task.get()
})
})
expect(cleanedUp).toBe(false)
dispose()
expect(cleanedUp).toBe(true)
})
test('should re-execute task when invalidate is called', async () => {
let externalValue = 10
let computeCount = 0
let invalidate!: () => void
const task = createTask(
async () => {
computeCount++
await wait(10)
return externalValue
},
{
value: 0,
watched: inv => {
invalidate = inv
return () => {}
},
},
)
let observed = 0
const dispose = createScope(() => {
createEffect(() => {
observed = task.get()
})
})
await wait(20)
expect(observed).toBe(10)
expect(computeCount).toBe(1)
externalValue = 20
invalidate()
await wait(20)
expect(observed).toBe(20)
expect(computeCount).toBe(2)
dispose()
})
test('should abort in-flight task when invalidate is called', async () => {
let wasAborted = false
let invalidate!: () => void
const task = createTask(
async (_prev, signal) => {
await wait(100)
if (signal.aborted) wasAborted = true
return 1
},
{
value: 0,
watched: inv => {
invalidate = inv
return () => {}
},
},
)
const dispose = createScope(() => {
createEffect(() => {
void task.get()
})
})
await wait(10) // task is in-flight
invalidate() // should trigger re-execution, aborting the current one
await wait(110)
expect(wasAborted).toBe(true)
dispose()
})
})
})