UNPKG

@zeix/cause-effect

Version:

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

850 lines (752 loc) 19.1 kB
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()