@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
850 lines (752 loc) • 19.1 kB
text/typescript
import { bench, group, run } from 'mitata'
import {
batch,
createCollection,
createEffect,
createList,
createMemo,
createSensor,
createSlot,
createState,
createStore,
createTask,
SKIP_EQUALITY,
} from '../index.ts'
import type { ReactiveFramework } from '../test/util/reactive-framework'
/* === Framework Adapter === */
const framework: ReactiveFramework = {
name: 'cause-effect',
// @ts-expect-error ReactiveFramework doesn't have non-nullable signals
signal: <T extends {}>(initialValue: T) => {
const s = createState(initialValue)
return { write: s.set, read: s.get }
},
// @ts-expect-error ReactiveFramework doesn't have non-nullable signals
computed: <T extends {}>(fn: () => T) => {
const c = createMemo(fn)
return { read: c.get }
},
effect: (fn: () => undefined) => {
createEffect(() => fn())
},
withBatch: fn => batch(fn),
withBuild: <T>(fn: () => T) => fn(),
}
/* === Kairo Benchmarks === */
function setupDeep(fw: ReactiveFramework) {
const len = 50
const head = fw.signal(0)
let current = head as { read: () => number }
for (let i = 0; i < len; i++) {
const c = current
current = fw.computed(() => c.read() + 1)
}
fw.effect(() => {
current.read()
})
let i = 0
return () => {
fw.withBatch(() => {
head.write(++i)
})
}
}
function setupBroad(fw: ReactiveFramework) {
const head = fw.signal(0)
for (let i = 0; i < 50; i++) {
const current = fw.computed(() => head.read() + i)
const current2 = fw.computed(() => current.read() + 1)
fw.effect(() => {
current2.read()
})
}
let i = 0
return () => {
fw.withBatch(() => {
head.write(++i)
})
}
}
function setupDiamond(fw: ReactiveFramework) {
const width = 5
const head = fw.signal(0)
const branches: { read(): number }[] = []
for (let i = 0; i < width; i++) {
branches.push(fw.computed(() => head.read() + 1))
}
const sum = fw.computed(() =>
branches.map(x => x.read()).reduce((a, b) => a + b, 0),
)
fw.effect(() => {
sum.read()
})
let i = 0
return () => {
fw.withBatch(() => {
head.write(++i)
})
}
}
function setupTriangle(fw: ReactiveFramework) {
const width = 10
const head = fw.signal(0)
let current = head as { read: () => number }
const list: { read: () => number }[] = []
for (let i = 0; i < width; i++) {
const c = current
list.push(current)
current = fw.computed(() => c.read() + 1)
}
const sum = fw.computed(() =>
list.map(x => x.read()).reduce((a, b) => a + b, 0),
)
fw.effect(() => {
sum.read()
})
let i = 0
return () => {
fw.withBatch(() => {
head.write(++i)
})
}
}
function setupMux(fw: ReactiveFramework) {
const heads = new Array(100).fill(null).map(_ => fw.signal(0))
const mux = fw.computed(() =>
Object.fromEntries(heads.map(h => h.read()).entries()),
)
const splited = heads
// biome-ignore lint/style/noNonNullAssertion: fixed-size array
.map((_, index) => fw.computed(() => mux.read()[index]!))
.map(x => fw.computed(() => x.read() + 1))
for (const x of splited) {
fw.effect(() => {
x.read()
})
}
let i = 0
return () => {
const idx = i % heads.length
fw.withBatch(() => {
// biome-ignore lint/style/noNonNullAssertion: fixed-size array
heads[idx]!.write(++i)
})
}
}
function setupUnstable(fw: ReactiveFramework) {
const head = fw.signal(0)
const double = fw.computed(() => head.read() * 2)
const inverse = fw.computed(() => -head.read())
const current = fw.computed(() => {
let result = 0
for (let i = 0; i < 20; i++) {
result += head.read() % 2 ? double.read() : inverse.read()
}
return result
})
fw.effect(() => {
current.read()
})
let i = 0
return () => {
fw.withBatch(() => {
head.write(++i)
})
}
}
function setupAvoidable(fw: ReactiveFramework) {
const head = fw.signal(0)
const computed1 = fw.computed(() => head.read())
const computed2 = fw.computed(() => {
computed1.read()
return 0
})
const computed3 = fw.computed(() => computed2.read() + 1)
const computed4 = fw.computed(() => computed3.read() + 2)
const computed5 = fw.computed(() => computed4.read() + 3)
fw.effect(() => {
computed5.read()
})
let i = 0
return () => {
fw.withBatch(() => {
head.write(++i)
})
}
}
function setupRepeatedObservers(fw: ReactiveFramework) {
const size = 30
const head = fw.signal(0)
const current = fw.computed(() => {
let result = 0
for (let i = 0; i < size; i++) {
result += head.read()
}
return result
})
fw.effect(() => {
current.read()
})
let i = 0
return () => {
fw.withBatch(() => {
head.write(++i)
})
}
}
/* === CellX Benchmark === */
function setupCellx(fw: ReactiveFramework, layers: number) {
const start = {
prop1: fw.signal(1),
prop2: fw.signal(2),
prop3: fw.signal(3),
prop4: fw.signal(4),
}
type CellxLayer = {
prop1: { read(): number }
prop2: { read(): number }
prop3: { read(): number }
prop4: { read(): number }
}
let layer: CellxLayer = start
for (let i = layers; i > 0; i--) {
const m: CellxLayer = layer
const s = {
prop1: fw.computed(() => m.prop2.read()),
prop2: fw.computed(() => m.prop1.read() - m.prop3.read()),
prop3: fw.computed(() => m.prop2.read() + m.prop4.read()),
prop4: fw.computed(() => m.prop3.read()),
}
fw.effect(() => {
s.prop1.read()
})
fw.effect(() => {
s.prop2.read()
})
fw.effect(() => {
s.prop3.read()
})
fw.effect(() => {
s.prop4.read()
})
fw.effect(() => {
s.prop1.read()
})
fw.effect(() => {
s.prop2.read()
})
fw.effect(() => {
s.prop3.read()
})
fw.effect(() => {
s.prop4.read()
})
layer = s
}
const end = layer
let toggle = false
return () => {
toggle = !toggle
fw.withBatch(() => {
start.prop1.write(toggle ? 4 : 1)
start.prop2.write(toggle ? 3 : 2)
start.prop3.write(toggle ? 2 : 3)
start.prop4.write(toggle ? 1 : 4)
})
end.prop1.read()
end.prop2.read()
end.prop3.read()
end.prop4.read()
}
}
/* === $mol_wire Benchmark === */
function setupMolWire(fw: ReactiveFramework) {
const fib = (n: number): number => {
if (n < 2) return 1
return fib(n - 1) + fib(n - 2)
}
const hard = (n: number, _log: string) => n + fib(16)
const numbers = Array.from({ length: 5 }, (_, i) => i)
const A = fw.signal(0)
const B = fw.signal(0)
const C = fw.computed(() => (A.read() % 2) + (B.read() % 2))
const D = fw.computed(() =>
numbers.map(i => ({ x: i + (A.read() % 2) - (B.read() % 2) })),
)
// biome-ignore lint/style/noNonNullAssertion: fixed-size array
const E = fw.computed(() => hard(C.read() + A.read() + D.read()[0]!.x, 'E'))
// biome-ignore lint/style/noNonNullAssertion: fixed-size array
const F = fw.computed(() => hard(D.read()[2]!.x || B.read(), 'F'))
const G = fw.computed(
// biome-ignore lint/style/noNonNullAssertion: fixed-size array
() => C.read() + (C.read() || E.read() % 2) + D.read()[4]!.x + F.read(),
)
fw.effect(() => {
hard(G.read(), 'H')
})
fw.effect(() => {
G.read()
})
fw.effect(() => {
hard(F.read(), 'J')
})
let i = 0
return () => {
i++
fw.withBatch(() => {
B.write(1)
A.write(1 + i * 2)
})
fw.withBatch(() => {
A.write(2 + i * 2)
B.write(2)
})
}
}
/* === Signal Creation Benchmark === */
function benchCreateSignals(fw: ReactiveFramework, count: number) {
return () => {
for (let i = 0; i < count; i++) {
fw.signal(i)
}
}
}
function benchCreateComputations(fw: ReactiveFramework, count: number) {
const src = fw.signal(0)
return () => {
for (let i = 0; i < count; i++) {
fw.computed(() => src.read())
}
}
}
/* === Run Benchmarks === */
// Kairo benchmarks
const kairoBenchmarks = [
['deep propagation', setupDeep],
['broad propagation', setupBroad],
['diamond', setupDiamond],
['triangle', setupTriangle],
['mux', setupMux],
['unstable', setupUnstable],
['avoidable propagation', setupAvoidable],
['repeated observers', setupRepeatedObservers],
] as const
for (const [name, setup] of kairoBenchmarks) {
group(`Kairo: ${name}`, () => {
bench('cause-effect', setup(framework))
})
}
// CellX benchmarks
for (const layers of [10]) {
group(`CellX ${layers} layers`, () => {
bench('cause-effect', setupCellx(framework, layers))
})
}
// $mol_wire benchmark
group('$mol_wire', () => {
bench('cause-effect', setupMolWire(framework))
})
// Creation benchmarks
group('Create 1k signals', () => {
bench('cause-effect', benchCreateSignals(framework, 1_000))
})
group('Create 1k computations', () => {
bench('cause-effect', benchCreateComputations(framework, 1_000))
})
/* === Task Benchmarks === */
group('Create 100 tasks', () => {
bench('cause-effect', () => {
const src = createState(0)
for (let i = 0; i < 100; i++) {
createTask(async () => src.get() + 1)
}
})
})
group('Task: resolve propagation', () => {
const wait = () => new Promise<void>(r => setTimeout(r, 0))
const src = createState(1)
const task = createTask(async () => src.get() * 2, {
value: 0,
})
createEffect(() => {
task.get()
})
let i = 1
bench('cause-effect', async () => {
batch(() => src.set(++i))
await wait()
})
})
/* === Sensor Benchmarks === */
group('Sensor: create + update (with equality)', () => {
bench('cause-effect', () => {
let setFn: (v: number) => void
const sensor = createSensor<number>(set => {
setFn = set
set(0)
return () => {}
})
createEffect(() => {
sensor.get()
})
for (let i = 0; i < 10; i++) {
// biome-ignore lint/style/noNonNullAssertion: assigned in start callback
setFn!(i)
}
})
})
group('Sensor: create + update (SKIP_EQUALITY)', () => {
bench('cause-effect', () => {
const obj = { x: 0 }
let setFn: (v: typeof obj) => void
const sensor = createSensor<typeof obj>(
set => {
setFn = set
set(obj)
return () => {}
},
{ value: obj, equals: SKIP_EQUALITY },
)
createEffect(() => {
sensor.get()
})
for (let i = 0; i < 10; i++) {
obj.x = i
// biome-ignore lint/style/noNonNullAssertion: assigned in start callback
setFn!(obj)
}
})
})
/* === List Benchmarks === */
group('List: create 100 items', () => {
const items = Array.from({ length: 100 }, (_, i) => i + 1)
bench('cause-effect', () => {
createList(items)
})
})
group('List: add + remove 10 items', () => {
bench('cause-effect', () => {
const list = createList<number>([1, 2, 3])
for (let i = 0; i < 10; i++) list.add(i + 10)
for (let i = 0; i < 10; i++) list.remove(0)
})
})
group('List: sort 50 items', () => {
bench('cause-effect', () => {
const list = createList(
Array.from({ length: 50 }, () => Math.random() * 100),
)
list.sort((a, b) => a - b)
})
})
group('List: set (diff) 50 items', () => {
const initial = Array.from({ length: 50 }, (_, i) => i)
const updated = Array.from({ length: 50 }, (_, i) => i * 2)
bench('cause-effect', () => {
const list = createList(initial.slice())
list.set(updated)
})
})
group('List: reactive propagation', () => {
const list = createList([1, 2, 3])
const memo = createMemo(() => list.get().reduce((a, b) => a + b, 0))
createEffect(() => {
memo.get()
})
let i = 0
bench('cause-effect', () => {
list.set([++i, 2, 3])
})
})
/* === Collection Benchmarks === */
group('Collection: derive 50 items (sync)', () => {
bench('cause-effect', () => {
const list = createList(Array.from({ length: 50 }, (_, i) => i + 1))
const col = list.deriveCollection((v: number) => v * 2)
col.get()
})
})
group('Collection: chain 2 derivations', () => {
bench('cause-effect', () => {
const list = createList(Array.from({ length: 20 }, (_, i) => i + 1))
const col1 = list.deriveCollection((v: number) => v * 2)
const col2 = col1.deriveCollection((v: number) => v + 1)
col2.get()
})
})
group('Collection: reactive update', () => {
const list = createList([1, 2, 3, 4, 5])
const col = list.deriveCollection((v: number) => v * 10)
createEffect(() => {
col.get()
})
let i = 0
bench('cause-effect', () => {
list.set([++i, 2, 3, 4, 5])
})
})
/* === Store Benchmarks === */
group('Store: create with 10 properties', () => {
const obj = Object.fromEntries(
Array.from({ length: 10 }, (_, i) => [`key${i}`, i]),
)
bench('cause-effect', () => {
createStore(obj)
})
})
group('Store: property access + set', () => {
const store = createStore({ a: 1, b: 2, c: 3 })
createEffect(() => {
store.a.get()
})
let i = 1
bench('cause-effect', () => {
store.a.set(++i)
})
})
group('Store: set (diff) entire object', () => {
const store = createStore({ x: 0, y: 0, z: 0 })
createEffect(() => {
store.get()
})
let i = 0
bench('cause-effect', () => {
store.set({ x: ++i, y: i * 2, z: i * 3 })
})
})
group('Store: nested store propagation', () => {
const nested = createStore({
user: { name: 'Alice', prefs: { theme: 'light' } },
})
createEffect(() => {
nested.get()
})
let toggle = false
bench('cause-effect', () => {
toggle = !toggle
nested.user.prefs.theme.set(toggle ? 'dark' : 'light')
})
})
/* === Heavy List Benchmarks === */
group('List: large reactive propagation (1000 items)', () => {
const items = Array.from({ length: 1000 }, (_, i) => i)
const list = createList(items.slice())
createEffect(() => {
list.get()
})
let i = 0
bench('cause-effect', () => {
items[0] = ++i
list.set(items.slice())
})
})
group('List: large set diff (1000 items, all changed)', () => {
const initial = Array.from({ length: 1000 }, (_, i) => i)
const updated = Array.from({ length: 1000 }, (_, i) => i + 1)
bench('cause-effect', () => {
const list = createList(initial.slice())
list.set(updated)
})
})
group('List: large add + remove 100 items', () => {
bench('cause-effect', () => {
const list = createList<number>([])
for (let i = 0; i < 100; i++) list.add(i)
for (let i = 0; i < 100; i++) list.remove(0)
})
})
group('List: replace in 1000-item list', () => {
const list = createList(Array.from({ length: 1000 }, (_, i) => i))
// biome-ignore lint/style/noNonNullAssertion: list is pre-populated
const key = list.keyAt(0)!
createEffect(() => {
list.get()
})
let i = 0
bench('cause-effect', () => {
batch(() => list.replace(key, ++i))
})
})
/* === Heavy Collection Benchmarks === */
group('Collection: derive 1000 items (sync)', () => {
bench('cause-effect', () => {
const list = createList(Array.from({ length: 1000 }, (_, i) => i + 1))
const col = list.deriveCollection((v: number) => v * 2)
col.get()
})
})
group('Collection: chain 5 derivations (100 items)', () => {
bench('cause-effect', () => {
const list = createList(Array.from({ length: 100 }, (_, i) => i + 1))
let col = list.deriveCollection((v: number) => v * 2)
for (let i = 1; i < 5; i++)
col = col.deriveCollection((v: number) => v + 1)
col.get()
})
})
group('Collection: large reactive update (1000 items)', () => {
const list = createList(Array.from({ length: 1000 }, (_, i) => i))
const col = list.deriveCollection((v: number) => v * 10)
// biome-ignore lint/style/noNonNullAssertion: list is pre-populated
const firstKey = list.keyAt(0)!
createEffect(() => {
col.get()
})
let i = 0
bench('cause-effect', () => {
batch(() => list.replace(firstKey, ++i))
})
})
group(
'Collection: externally-driven structural mutations (1000 iterations)',
() => {
type Item = { id: string }
let apply!: (changes: { add?: Item[]; remove?: Item[] }) => void
const col = createCollection<Item>(
applyChanges => {
apply = applyChanges
return () => {}
},
{ keyConfig: (item: Item) => item.id },
)
createEffect(() => {
col.get()
})
let i = 0
bench('cause-effect', () => {
batch(() => {
apply({ add: [{ id: `k${i}` }] })
apply({ remove: [{ id: `k${i}` }] })
i++
})
})
},
)
/* === Heavy Store Benchmarks === */
group('Store: 50 properties, single update', () => {
const obj = Object.fromEntries(
Array.from({ length: 50 }, (_, i) => [`key${i}`, i]),
)
const store = createStore(obj)
createEffect(() => {
store.get()
})
let i = 0
bench('cause-effect', () => {
;(store as Record<string, { set: (v: number) => void }>).key0?.set(++i)
})
})
group('Store: large set diff (50 properties)', () => {
const initial = Object.fromEntries(
Array.from({ length: 50 }, (_, i) => [`key${i}`, i]),
)
const updated = Object.fromEntries(
Array.from({ length: 50 }, (_, i) => [`key${i}`, i + 1]),
)
const store = createStore(initial)
createEffect(() => {
store.get()
})
bench('cause-effect', () => {
store.set(updated)
})
})
/* === Heavy Slot Benchmarks === */
group('Slot: chain 5 slots', () => {
bench('cause-effect', () => {
const base = createState(0)
let current = createSlot(base)
for (let i = 1; i < 5; i++) current = createSlot(current)
createEffect(() => {
current.get()
})
current.set(1)
})
})
group('Slot: replace with 10 subscribers', () => {
const s1 = createState(0)
const s2 = createState(1)
const slot = createSlot<number>(s1)
for (let i = 0; i < 10; i++) {
createEffect(() => {
slot.get()
})
}
let toggle = false
bench('cause-effect', () => {
toggle = !toggle
slot.replace(toggle ? s2 : s1)
})
})
/* === Heavy Sensor Benchmarks === */
group('Sensor: 10-sensor fan-in (1 effect)', () => {
// watched is called lazily on first subscription, so we collect setters via a Map
const setterMap = new Map<number, (v: number) => void>()
const sensors: ReturnType<typeof createSensor<number>>[] = []
for (let i = 0; i < 10; i++) {
const idx = i
sensors.push(
createSensor<number>(set => {
setterMap.set(idx, set)
set(idx)
return () => {}
}),
)
}
// Reading all sensors in an effect triggers their watched callbacks
createEffect(() => {
for (const s of sensors) s.get()
})
let i = 0
bench('cause-effect', () => {
// biome-ignore lint/style/noNonNullAssertion: populated by effect above
setterMap.get(i % 10)!(++i)
})
})
group('Sensor: 1 sensor → 10 effects fanout', () => {
let setter!: (v: number) => void
const sensor = createSensor<number>(set => {
setter = set
set(0)
return () => {}
})
for (let i = 0; i < 10; i++) {
createEffect(() => {
sensor.get()
})
}
let i = 0
bench('cause-effect', () => {
setter(++i)
})
})
/* === Heavy Task Benchmarks === */
group('Task: 10 tasks reading 1 state (fanout)', () => {
const wait = () => new Promise<void>(r => setTimeout(r, 0))
const src = createState(0)
const tasks = Array.from({ length: 10 }, () =>
createTask(async () => src.get() * 2, { value: 0 }),
)
for (const t of tasks) createEffect(() => void t.get())
let i = 0
bench('cause-effect', async () => {
batch(() => src.set(++i))
await wait()
})
})
group('Task: chain of 5 tasks', () => {
const wait = () => new Promise<void>(r => setTimeout(r, 0))
const src = createState(1)
let current = createTask(async () => src.get(), { value: 0 })
for (let i = 0; i < 4; i++) {
const prev = current
current = createTask(async () => prev.get() + 1, { value: 0 })
}
createEffect(() => void current.get())
let i = 0
bench('cause-effect', async () => {
batch(() => src.set(++i))
await wait()
})
})
await run()