opencode/packages/quark/test/reactivity.test.ts
2026-07-21 16:23:51 -04:00

427 lines
12 KiB
TypeScript

import { describe, expect, it } from "bun:test"
import { Computed, State, Transaction, type Readable } from "../src/reactivity"
describe("reactivity", () => {
it("keeps computed values lazy", () => {
const source = State.make(1)
let evaluations = 0
const doubled = Computed.make(() => {
evaluations++
return source() * 2
})
expect(evaluations).toBe(0)
expect(doubled()).toBe(2)
expect(doubled()).toBe(2)
expect(evaluations).toBe(1)
source.set(2)
expect(evaluations).toBe(1)
expect(doubled()).toBe(4)
expect(evaluations).toBe(2)
})
it("propagates a diamond without glitches", () => {
const source = State.make(1)
const left = Computed.make(() => source() * 2)
const right = Computed.make(() => source() * 3)
const total = Computed.make(() => left() + right())
const values: Array<number> = []
const dispose = total.subscribe((value) => values.push(value))
source.set(2)
expect(values).toEqual([10])
dispose()
})
it("tracks dynamic dependencies", () => {
const enabled = State.make(true)
const left = State.make(1)
const right = State.make(10)
const selected = Computed.make(() => (enabled() ? left() : right()))
const values: Array<number> = []
const dispose = selected.subscribe((value) => values.push(value))
left.set(2)
enabled.set(false)
left.set(3)
right.set(11)
expect(values).toEqual([2, 10, 11])
dispose()
})
it("batches transactions", () => {
const left = State.make(1)
const right = State.make(2)
const total = Computed.make(() => left() + right())
const values: Array<number> = []
const dispose = total.subscribe((value) => values.push(value))
Transaction.run(() => {
left.set(2)
right.set(3)
})
expect(values).toEqual([5])
dispose()
})
it("cuts off unchanged derived values", () => {
const source = State.make(1)
let parityEvaluations = 0
let labelEvaluations = 0
const parity = Computed.make(() => {
parityEvaluations++
return source() % 2
})
const label = Computed.make(() => {
labelEvaluations++
return parity() === 0 ? "even" : "odd"
})
const dispose = label.subscribe(() => {})
source.set(3)
expect(parityEvaluations).toBe(2)
expect(labelEvaluations).toBe(1)
dispose()
})
it("disposes subscriptions", () => {
const source = State.make(1)
const values: Array<number> = []
const dispose = source.subscribe((value) => values.push(value))
source.set(2)
dispose()
source.set(3)
expect(values).toEqual([2])
})
it("does not track state read by subscription listeners", () => {
const source = State.make(1)
const unrelated = State.make(1)
const values: Array<number> = []
const dispose = source.subscribe((value) => {
unrelated()
values.push(value)
})
source.set(2)
unrelated.set(2)
expect(values).toEqual([2])
dispose()
})
it("does not track the state read by update", () => {
const trigger = State.make(0)
const updated = State.make(0)
let runs = 0
const dispose = trigger.subscribe(() => {
runs++
updated.update((value) => value + 1)
})
trigger.set(1)
updated.set(10)
expect(runs).toBe(1)
expect(updated()).toBe(10)
dispose()
})
it("delivers the previous value to computed evaluation", () => {
const source = State.make(1)
const previousValues: Array<number | undefined> = []
const running = Computed.make<number>((previous) => {
previousValues.push(previous)
return (previous ?? 0) + source()
})
expect(running()).toBe(1)
source.set(2)
expect(running()).toBe(3)
source.set(3)
expect(running()).toBe(6)
expect(previousValues).toEqual([undefined, 1, 3])
})
it("detaches and reattaches dynamic dependencies", () => {
const enabled = State.make(true)
const left = State.make(1)
const right = State.make(10)
let evaluations = 0
const selected = Computed.make(() => {
evaluations++
return enabled() ? left() : right()
})
const dispose = selected.subscribe(() => {})
expect(evaluations).toBe(1)
enabled.set(false)
expect(evaluations).toBe(2)
// Detached: left writes must not re-evaluate the computed.
left.set(2)
left.set(3)
expect(evaluations).toBe(2)
// Reattached: left writes re-evaluate, right writes no longer do.
enabled.set(true)
expect(evaluations).toBe(3)
expect(selected()).toBe(3)
right.set(11)
expect(evaluations).toBe(3)
left.set(4)
expect(evaluations).toBe(4)
expect(selected()).toBe(4)
dispose()
})
it("notifies a diamond subscriber exactly once per write", () => {
const source = State.make(1)
const left = Computed.make(() => source() * 2)
const right = Computed.make(() => source() * 3)
const total = Computed.make(() => left() + right())
let notifications = 0
const dispose = total.subscribe(() => notifications++)
source.set(2)
source.set(3)
expect(notifications).toBe(2)
dispose()
})
it("batches nested transactions until the outermost ends", () => {
const left = State.make(1)
const right = State.make(2)
const total = Computed.make(() => left() + right())
const values: Array<number> = []
const dispose = total.subscribe((value) => values.push(value))
Transaction.run(() => {
left.set(2)
Transaction.run(() => {
right.set(3)
})
expect(values).toEqual([])
left.set(3)
})
expect(values).toEqual([6])
dispose()
})
it("restores batch state when a transaction throws", () => {
const source = State.make(1)
const values: Array<number> = []
const dispose = source.subscribe((value) => values.push(value))
expect(() =>
Transaction.run(() => {
source.set(2)
throw new Error("boom")
}),
).toThrow("boom")
// The write that happened before the throw still flushes once the
// transaction unwinds, and later writes are not batched.
expect(values).toEqual([2])
source.set(3)
expect(values).toEqual([2, 3])
dispose()
})
it("supports disposing another subscription during notification", () => {
const source = State.make(1)
const first: Array<number> = []
const second: Array<number> = []
let disposeSecond = () => {}
const disposeFirst = source.subscribe((value) => {
first.push(value)
disposeSecond()
})
disposeSecond = source.subscribe((value) => second.push(value))
source.set(2)
source.set(3)
expect(first).toEqual([2, 3])
expect(second).toEqual([])
disposeFirst()
})
it("does not deliver to a watcher disposed during revalidation", () => {
const source = State.make(0)
let dispose = () => {}
const gate = Computed.make(() => {
const value = source()
if (value > 0) dispose()
return value
})
const values: Array<number> = []
dispose = gate.subscribe((value) => values.push(value))
// Revalidating the watcher re-evaluates gate, whose evaluation disposes
// the subscription before the value could be delivered.
source.set(1)
source.set(2)
expect(values).toEqual([])
})
it("supports a subscription disposing itself during notification", () => {
const source = State.make(1)
const values: Array<number> = []
let dispose = () => {}
dispose = source.subscribe((value) => {
values.push(value)
dispose()
})
source.set(2)
source.set(3)
expect(values).toEqual([2])
})
it("does not track State.update reading its own value", () => {
const counter = State.make(0)
const source = State.make(1)
let evaluations = 0
const tracked = Computed.make(() => {
evaluations++
counter.update((value) => value)
return source()
})
const dispose = tracked.subscribe(() => {})
expect(evaluations).toBe(1)
counter.set(5)
expect(evaluations).toBe(1)
source.set(2)
expect(evaluations).toBe(2)
dispose()
})
it("recovers tracking after a computed evaluation throws", () => {
const shouldThrow = State.make(true)
const source = State.make(1)
const throwing = Computed.make(() => {
if (shouldThrow()) throw new Error("computed boom")
return source()
})
expect(() => throwing()).toThrow("computed boom")
// The failed evaluation must restore the active observer so unrelated
// graphs keep tracking correctly afterwards.
const other = State.make(1)
const doubled = Computed.make(() => other() * 2)
const values: Array<number> = []
const dispose = doubled.subscribe((value) => values.push(value))
other.set(2)
expect(values).toEqual([4])
dispose()
shouldThrow.set(false)
expect(throwing()).toBe(1)
source.set(2)
expect(throwing()).toBe(2)
})
it("keeps notifying other subscribers after a listener throws", () => {
const source = State.make(1)
const values: Array<number> = []
const disposeThrowing = source.subscribe(() => {
throw new Error("listener boom")
})
const dispose = source.subscribe((value) => values.push(value))
expect(() => source.set(2)).toThrow("listener boom")
disposeThrowing()
source.set(3)
expect(values).toContain(3)
dispose()
})
it("leaves no dependency links behind when subscription initialization fails", () => {
const source = State.make(1)
const throwing = Computed.make(() => {
if (source() === 1) throw new Error("init boom")
return source()
})
const values: Array<number> = []
expect(() => throwing.subscribe((value) => values.push(value))).toThrow("init boom")
// The failed subscription must not stay linked to the graph.
source.set(2)
source.set(3)
expect(values).toEqual([])
// The computed itself remains usable.
expect(throwing()).toBe(3)
})
it("propagates deep chains without recursive stack growth", () => {
// A cold pull of an unevaluated chain necessarily nests user getter
// frames, so each layer is evaluated as it is built. The kernel-owned
// paths under test are dirty propagation and revalidation, which must
// walk the full depth iteratively.
const depth = 100_000
const source = State.make(0)
const chain = Array.from({ length: depth }).reduce<Readable<number>>((current) => {
const next = Computed.make(() => current() + 1)
next()
return next
}, source)
expect(chain()).toBe(depth)
const values: Array<number> = []
const dispose = chain.subscribe((value) => values.push(value))
source.set(1)
expect(values).toEqual([depth + 1])
dispose()
})
it("propagates wide fan-out without recursive stack growth", () => {
const width = 50_000
const source = State.make(0)
const nodes = Array.from({ length: width }, () => Computed.make(() => source() + 1))
const total = Computed.make(() => nodes.reduce((sum, node) => sum + node(), 0))
let sink = 0
const dispose = total.subscribe((value) => {
sink = value
})
source.set(1)
expect(sink).toBe(width * 2)
dispose()
})
it("fails deterministically on cyclic computed dependencies", () => {
// Regression for stackblitz/alien-signals#123: mutually dependent
// computed graphs must throw a stable error instead of looping or
// exhausting memory.
const fieldA = State.make(false)
const fieldB = State.make(false)
const a: Readable<boolean | null> = Computed.make(() => (b() !== true ? fieldA() : null))
const b: Readable<boolean | null> = Computed.make(() => (a() !== true ? fieldB() : null))
// Every read that reaches the cycle fails the same way; a failed
// evaluation stays dirty and retries instead of serving a stale value.
expect(() => a()).toThrow(/cycle/i)
expect(() => b()).toThrow(/cycle/i)
fieldA.set(true)
expect(() => a()).toThrow(/cycle/i)
})
})