@zeix/cause-effect
Version:
Cause & Effect - reactive state management primitives library for TypeScript.
256 lines (238 loc) • 7.2 kB
text/typescript
import { describe, expect, test } from 'bun:test'
import * as stableModule from '@zeix/cause-effect-stable'
import type { CollectionChanges } from '../index.ts'
import * as current from '../index.ts'
const stable = stableModule as unknown as typeof current
/* === DCE sink — prevents the JIT from eliminating allocations with no other observable effect === */
let _sink = 0
/* === Measurement === */
const PERF_MARGIN = 0.2
const PERF_FLOOR = 1 // minimum absolute tolerance in ms
/**
* Build both graphs, cross-warm them interleaved (100 pairs), then take 5
* alternating timed passes of `iterations` iterations each. Alternating passes
* equalize JIT state between implementations; forcing GC beforehand removes
* accumulated cross-scenario heap pressure; median of 5 suppresses OS outliers.
*/
function measurePair(
setupCurrent: () => () => void,
setupStable: () => () => void,
iterations: number,
): [number, number] {
Bun.gc(true)
const fnC = setupCurrent()
const fnS = setupStable()
for (let i = 0; i < 100; i++) {
fnC()
fnS()
}
const passesC: number[] = []
const passesS: number[] = []
for (let p = 0; p < 11; p++) {
let start = performance.now()
for (let i = 0; i < iterations; i++) fnC()
passesC.push(performance.now() - start)
start = performance.now()
for (let i = 0; i < iterations; i++) fnS()
passesS.push(performance.now() - start)
}
passesC.sort((a, b) => a - b)
passesS.sort((a, b) => a - b)
// biome-ignore lint/style/noNonNullAssertion: arrays always have 11 elements
return [passesC[5]!, passesS[5]!]
}
function check(name: string, currentMs: number, stableMs: number): void {
const limit = Math.max(stableMs * (1 + PERF_MARGIN), stableMs + PERF_FLOOR)
console.log(
` ${name}: current ${currentMs.toFixed(1)}ms |` +
` stable ${stableMs.toFixed(1)}ms |` +
` limit ${limit.toFixed(1)}ms`,
)
expect(currentMs).toBeLessThanOrEqual(limit)
}
/* === Primitive Scenarios (State / Memo / Effect) === */
describe('Performance — primitive nodes', () => {
test('deep propagation (50 layers, 1000 iterations)', () => {
const setup = (f: typeof current) => () => {
const head = f.createState(0)
let cur: { get(): number } = head
for (let i = 0; i < 50; i++) {
const c = cur
cur = f.createMemo(() => c.get() + 1)
}
f.createEffect(() => {
cur.get()
})
let i = 0
return () => f.batch(() => head.set(++i))
}
const [currentMs, stableMs] = measurePair(
setup(current),
setup(stable),
1000,
)
check('deepPropagation', currentMs, stableMs)
})
test('broad propagation (50 effects, 1000 iterations)', () => {
const setup = (f: typeof current) => () => {
const head = f.createState(0)
for (let i = 0; i < 50; i++) {
const c = f.createMemo(() => head.get() + i)
const c2 = f.createMemo(() => c.get() + 1)
f.createEffect(() => {
c2.get()
})
}
let i = 0
return () => f.batch(() => head.set(++i))
}
const [currentMs, stableMs] = measurePair(
setup(current),
setup(stable),
1000,
)
check('broadPropagation', currentMs, stableMs)
})
test('diamond propagation (width 5, 5000 iterations)', () => {
const setup = (f: typeof current) => () => {
const head = f.createState(0)
const branches = Array.from({ length: 5 }, () =>
f.createMemo(() => head.get() + 1),
)
const sum = f.createMemo(() =>
branches.reduce((a, b) => a + b.get(), 0),
)
f.createEffect(() => {
sum.get()
})
let i = 0
return () => f.batch(() => head.set(++i))
}
const [currentMs, stableMs] = measurePair(
setup(current),
setup(stable),
5000,
)
check('diamondPropagation', currentMs, stableMs)
})
test('create 1k signals (500 rounds)', () => {
const setup = (f: typeof current) => () => {
return () => {
for (let i = 0; i < 1000; i++) _sink += f.createState(i).get()
}
}
const [currentMs, stableMs] = measurePair(
setup(current),
setup(stable),
500,
)
check('signalCreation', currentMs, stableMs)
})
})
/* === Composite Scenarios (List / Store / Collection) === */
describe('Performance — composite nodes', () => {
test('list structural mutations (add+remove, 5000 iterations)', () => {
const setup = (f: typeof current) => () => {
const list = f.createList<number>([])
f.createEffect(() => {
list.get()
})
let i = 0
return () =>
f.batch(() => {
const key = list.add(++i)
list.remove(key)
})
}
const [currentMs, stableMs] = measurePair(
setup(current),
setup(stable),
5000,
)
check('listStructural', currentMs, stableMs)
})
test('list item replace — item signal to direct subscriber (10000 iterations)', () => {
// listStructural covers the list-node → structural-subscriber path.
// This scenario isolates the item-signal → direct-subscriber path,
// which is the unique contribution of replace().
const setup = (f: typeof current) => () => {
const list = f.createList<number>([0])
// biome-ignore lint/style/noNonNullAssertion: list is pre-populated
const key = list.keyAt(0)!
// biome-ignore lint/style/noNonNullAssertion: list is pre-populated
f.createEffect(() => void list.byKey(key)!.get())
let i = 0
return () => f.batch(() => list.replace(key, ++i))
}
const [currentMs, stableMs] = measurePair(
setup(current),
setup(stable),
10000,
)
check('listReplace', currentMs, stableMs)
})
test('store property update via proxy (5000 iterations)', () => {
const setup = (f: typeof current) => () => {
const store = f.createStore({ x: 0 })
f.createEffect(() => {
store.get()
})
let i = 0
return () => f.batch(() => store.x.set(++i))
}
const [currentMs, stableMs] = measurePair(
setup(current),
setup(stable),
5000,
)
check('storeUpdate', currentMs, stableMs)
})
test('collection structural mutations (add+remove via applyChanges, 5000 iterations)', () => {
type Item = { id: string }
const setup = (f: typeof current) => () => {
let apply!: (changes: CollectionChanges<Item>) => void
const col = f.createCollection<Item>(
applyChanges => {
apply = applyChanges
return () => {}
},
{ keyConfig: (item: Item) => item.id },
)
f.createEffect(() => {
col.get()
})
return () =>
f.batch(() => {
apply({ add: [{ id: 'k' }] })
apply({ remove: [{ id: 'k' }] })
})
}
const [currentMs, stableMs] = measurePair(
setup(current),
setup(stable),
5000,
)
check('collectionMutate', currentMs, stableMs)
})
test('derived collection item update (2000 iterations)', () => {
const setup = (f: typeof current) => () => {
const list = f.createList<number>(
Array.from({ length: 5 }, (_, i) => i),
)
const derived = list.deriveCollection((v: number) => v * 2)
// biome-ignore lint/style/noNonNullAssertion: list is pre-populated
const firstKey = list.keyAt(0)!
f.createEffect(() => {
derived.get()
})
let i = 0
return () => f.batch(() => list.replace(firstKey, ++i))
}
const [currentMs, stableMs] = measurePair(
setup(current),
setup(stable),
2000,
)
check('derivedCollection', currentMs, stableMs)
})
})