UNPKG

@zeix/cause-effect

Version:

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

280 lines (230 loc) 6.78 kB
import { describe, expect, test } from 'bun:test' import { batch, createEffect, createMemo, createSlot, createState, } from '../index.ts' import { InvalidSignalValueError, NullishSignalValueError } from '../src/errors' describe('Slot', () => { test('should replace delegated signal and re-subscribe sinks', () => { const local = createState(1) const parent = createState(10) const derived = createMemo(() => parent.get()) const slot = createSlot(local) const target = {} Object.defineProperty(target, 'value', slot) let runs = 0 let seen = 0 createEffect(() => { seen = (target as { value: number }).value runs++ }) expect(runs).toBe(1) expect(seen).toBe(1) slot.replace(derived) expect(runs).toBe(2) expect(seen).toBe(10) // Old delegated signal should no longer trigger downstream sinks local.set(2) expect(runs).toBe(2) parent.set(11) expect(runs).toBe(3) expect(seen).toBe(11) }) test('should forward property set to writable delegated signal', () => { const source = createState(2) const slot = createSlot(source) const target = {} Object.defineProperty(target, 'value', slot) ;(target as { value: number }).value = 3 expect(source.get()).toBe(3) expect((target as { value: number }).value).toBe(3) }) test('should throw on set when delegated signal is read-only', () => { const source = createState(2) const readonly = createMemo(() => source.get() * 2) const slot = createSlot(source) const target = {} Object.defineProperty(target, 'value', slot) slot.replace(readonly) expect(() => { ;(target as { value: number }).value = 7 }).toThrow('[Slot] Signal is read-only') }) test('should keep replace handle outside property descriptor', () => { const source = createState(1) const slot = createSlot(source) const target = {} Object.defineProperty(target, 'value', slot) const descriptor = Object.getOwnPropertyDescriptor(target, 'value') expect(descriptor).toBeDefined() expect(typeof descriptor?.get).toBe('function') expect(typeof descriptor?.set).toBe('function') expect((descriptor as unknown as { replace?: unknown }).replace).toBe( undefined, ) expect(typeof slot.replace).toBe('function') }) test('should batch multiple replacements into one downstream rerun', () => { const a = createState(1) const b = createState(2) const c = createState(3) const slot = createSlot(a) const target = {} Object.defineProperty(target, 'value', slot) let runs = 0 createEffect(() => { void (target as { value: number }).value runs++ }) expect(runs).toBe(1) batch(() => { slot.replace(b) slot.replace(c) }) expect(runs).toBe(2) }) test('should forward set through a Slot-to-Slot chain', () => { const source = createState(1) const inner = createSlot(source) const outer = createSlot(inner) outer.set(42) expect(source.get()).toBe(42) expect(outer.get()).toBe(42) }) test('should throw ReadonlySignalError when chain terminates in a read-only signal', () => { const source = createState(2) const readonly = createMemo(() => source.get() * 2) const inner = createSlot(readonly) const outer = createSlot(inner) expect(() => outer.set(99)).toThrow('[Slot] Signal is read-only') }) test('should validate initial signal and replacement signal', () => { expect(() => { // @ts-expect-error: deliberate error test createSlot(null) }).toThrow(NullishSignalValueError) const slot = createSlot(createState(1)) expect(() => { // @ts-expect-error: deliberate error test slot.replace(42) }).toThrow(InvalidSignalValueError) }) describe('Consumer disconnect and reconnect', () => { test('should return fresh value when new consumer reads after all consumers disconnected', () => { const state = createState(1) const slot = createSlot(state) let seen = 0 const dispose = createEffect(() => { seen = slot.get() }) expect(seen).toBe(1) state.set(2) expect(seen).toBe(2) // All consumers disconnect — State→SlotMemo edge is pruned via cascade dispose() // State update while unwatched state.set(3) // New consumer reconnects — should see fresh value 3, not stale 2 let seen2 = 0 const dispose2 = createEffect(() => { seen2 = slot.get() }) expect(seen2).toBe(3) dispose2() }) test('should propagate state updates after consumers disconnect and reconnect', () => { const state = createState(1) const slot = createSlot(state) let runs = 0 let seen = 0 const dispose = createEffect(() => { seen = slot.get() runs++ }) expect(runs).toBe(1) expect(seen).toBe(1) state.set(2) expect(runs).toBe(2) expect(seen).toBe(2) // All consumers disconnect dispose() state.set(3) // New consumer reconnects const dispose2 = createEffect(() => { seen = slot.get() runs++ }) // Expect fresh value on connect expect(runs).toBe(3) expect(seen).toBe(3) // Propagation must still work after reconnect state.set(4) expect(runs).toBe(4) expect(seen).toBe(4) dispose2() }) test('should propagate through Slot wrapping a Memo after consumer disconnect and reconnect', () => { const state = createState(1) const derived = createMemo(() => state.get() * 10) const slot = createSlot(derived) let runs = 0 let seen = 0 const dispose = createEffect(() => { seen = slot.get() runs++ }) expect(runs).toBe(1) expect(seen).toBe(10) state.set(2) expect(runs).toBe(2) expect(seen).toBe(20) // Disconnect and mutate while unwatched dispose() state.set(3) // Reconnect const dispose2 = createEffect(() => { seen = slot.get() runs++ }) expect(runs).toBe(3) expect(seen).toBe(30) // Propagation must continue state.set(4) expect(runs).toBe(4) expect(seen).toBe(40) dispose2() }) }) describe('SlotDescriptor', () => { test('should support creating from a `{ get, set }` descriptor', () => { const state = createState(1) const slot = createSlot({ get: () => state.get() * 2, set: (val: number) => state.set(val / 2), }) expect(slot.get()).toBe(2) let runs = 0 createEffect(() => { slot.get() runs++ }) expect(runs).toBe(1) state.set(5) expect(runs).toBe(2) expect(slot.get()).toBe(10) slot.set(100) expect(state.get()).toBe(50) expect(runs).toBe(3) expect(slot.get()).toBe(100) }) test('should support read-only `{ get }` descriptor', () => { const state = createState(1) const slot = createSlot({ get: () => state.get() * 2 }) expect(slot.get()).toBe(2) expect(() => slot.set(100)).toThrow('[Slot] Signal is read-only') }) }) })