import { describe, expect, it } from 'vitest' import type { DebugRefreshReasonBase, DebugThunkInfo } from './reactive.ts' import { ReactiveGraph } from './reactive.ts' describe('a trivial graph', () => { const makeGraph = () => { const graph = new ReactiveGraph() graph.context = {} const a = graph.makeRef(1, { label: 'a' }) const b = graph.makeRef(2, { label: 'b' }) const numberOfRunsForC = { runs: 0 } const c = graph.makeThunk( (get) => { numberOfRunsForC.runs++ return get(a) + get(b) }, { label: 'c' }, ) const d = graph.makeRef(3, { label: 'd' }) const e = graph.makeThunk((get) => get(c) + get(d), { label: 'e' }) // a(1) b(2) // \ / // \ / // c = a + b // \ // \ // d(3) \ // \ \ // \ \ // e = c + d expect(graph.atoms.size).toBe(5) return { graph, a, b, c, d, e, numberOfRunsForC } } it('has the right initial values', () => { const { c, e } = makeGraph() expect(c.computeResult()).toBe(3) expect(e.computeResult()).toBe(6) }) it('propagates change through the graph', () => { const { graph, a, c, e } = makeGraph() graph.setRef(a, 5) expect(c.computeResult()).toBe(7) expect(e.computeResult()).toBe(10) }) it('does not rerun downstream computations eagerly when an upstream dep changes', () => { const { graph, a, c, numberOfRunsForC } = makeGraph() expect(numberOfRunsForC.runs).toBe(0) graph.setRef(a, 5) expect(numberOfRunsForC.runs).toBe(0) c.computeResult() expect(numberOfRunsForC.runs).toBe(1) }) it('does not rerun c when d is edited and e is rerun', () => { const { graph, d, e, numberOfRunsForC } = makeGraph() expect(numberOfRunsForC.runs).toBe(0) expect(e.computeResult()).toBe(3 + 3) expect(numberOfRunsForC.runs).toBe(1) graph.setRef(d, 4) expect(e.computeResult()).toBe(4 + 3) expect(numberOfRunsForC.runs).toBe(1) }) it('cuts off reactive propagation when a thunk evaluates to same result as before', () => { const { graph, a, c, d } = makeGraph() let numberOfRuns = 0 const f = graph.makeThunk((get) => { numberOfRuns++ return get(c) + get(d) }) expect(numberOfRuns).toBe(0) // defining f shouldn't run it yet f.computeResult() expect(numberOfRuns).toBe(1) // refreshing should run it once // f doesn't run because a is set to same value as before graph.setRef(a, 1) expect(f.computeResult()).toBe(6) // expect(numberOfRuns).toBe(1) // TODO comp caching // f runs because a is set to a different value graph.setRef(a, 5) expect(f.computeResult()).toBe(10) // expect(numberOfRuns).toBe(2) // TODO comp caching // f runs again when d is set to a different value graph.setRef(d, 4) expect(f.computeResult()).toBe(11) // expect(numberOfRuns).toBe(3) // TODO comp caching // f only runs one time if we set two refs together graph.setRefs([ [a, 6], [d, 5], ]) expect(f.computeResult()).toBe(13) // expect(numberOfRuns).toBe(4) // TODO comp caching }) it('only runs a thunk once when two upstream refs are updated together', () => { const { graph, a, b, c, numberOfRunsForC } = makeGraph() graph.setRefs([ [a, 5], [b, 6], ]) expect(numberOfRunsForC.runs).toBe(0) expect(c.computeResult()).toBe(11) expect(numberOfRunsForC.runs).toBe(1) }) describe('effects', () => { // TODO TBD whether we want to keep this as intended behavior it(`doesn't run on initial definition`, () => { const { graph, c, numberOfRunsForC } = makeGraph() expect(numberOfRunsForC.runs).toBe(0) c.computeResult() expect(numberOfRunsForC.runs).toBe(1) let numberOfEffectRuns = 0 const effect = graph.makeEffect((get) => { // establish a dependency on thunk c and mutate an outside value expect(get(c)).toBe(3) numberOfEffectRuns++ }) expect(numberOfEffectRuns).toBe(0) expect(numberOfRunsForC.runs).toBe(1) effect.doEffect() expect(numberOfEffectRuns).toBe(1) }) it('only reruns an effect if the thunk value changed', () => { const { graph, a, c } = makeGraph() let numberOfEffectRuns = 0 let aHasChanged = true expect(numberOfEffectRuns).toBe(0) const effect = graph.makeEffect((get) => { // establish a dependency on thunk c and mutate an outside value expect(get(c)).toBe(aHasChanged === true ? 3 : 4) numberOfEffectRuns++ }) expect(numberOfEffectRuns).toBe(0) effect.doEffect() expect(numberOfEffectRuns).toBe(1) // if we set a to the same value, the effect should not run again graph.setRef(a, 1) // expect(numberOfCallsToC).toBe(1) // TODO comp caching aHasChanged = false graph.setRef(a, 2) // expect(numberOfCallsToC).toBe(2) // TODO comp caching }) describe('skip refresh', () => { it(`defers effect execution until manual run`, () => { const { graph, a, c, d, numberOfRunsForC } = makeGraph() // using here both to track number oe effect runs and to "update the effect behavior" let numberOfEffectRuns = 0 const effect = graph.makeEffect((get) => { expect(get(c)).toBe(numberOfEffectRuns === 0 ? 3 : 4) numberOfEffectRuns++ }) effect.doEffect() expect(numberOfEffectRuns).toBe(1) expect(numberOfRunsForC.runs).toBe(1) graph.setRef(a, 2, { skipRefresh: true }) expect(numberOfEffectRuns).toBe(1) expect(numberOfRunsForC.runs).toBe(1) // Even setting a unrelated ref should not trigger a refresh graph.setRef(d, 0) expect(numberOfEffectRuns).toBe(1) expect(numberOfRunsForC.runs).toBe(1) graph.runDeferredEffects() expect(numberOfEffectRuns).toBe(2) expect(numberOfRunsForC.runs).toBe(2) }) it(`doesn't run deferred effects which have been destroyed already`, () => { const { graph, a, c, numberOfRunsForC } = makeGraph() let numberOfEffect1Runs = 0 const effect1 = graph.makeEffect((get) => { expect(get(c)).toBe(numberOfEffect1Runs === 0 ? 3 : 4) numberOfEffect1Runs++ }) let numberOfEffect2Runs = 0 const effect2 = graph.makeEffect((get) => { expect(get(c)).toBe(numberOfEffect2Runs === 0 ? 3 : 4) numberOfEffect2Runs++ }) effect1.doEffect() effect2.doEffect() expect(numberOfEffect1Runs).toBe(1) expect(numberOfEffect2Runs).toBe(1) expect(numberOfRunsForC.runs).toBe(1) graph.setRef(a, 2, { skipRefresh: true }) expect(numberOfEffect1Runs).toBe(1) expect(numberOfEffect2Runs).toBe(1) expect(numberOfRunsForC.runs).toBe(1) graph.destroyNode(effect1) graph.runDeferredEffects() expect(numberOfEffect1Runs).toBe(1) expect(numberOfEffect2Runs).toBe(2) expect(numberOfRunsForC.runs).toBe(2) }) }) }) describe('destroying nodes', () => { it('marks super node as dirty when a sub node is destroyed', () => { const { graph, b, c, d, e } = makeGraph() e.computeResult() graph.destroyNode(b) expect(c.isDirty).toBe(true) expect(d.isDirty).toBe(false) expect(e.isDirty).toBe(true) expect(() => c.computeResult()).toThrowErrorMatchingInlineSnapshot( `[Error: This should never happen: LiveStore Error: Attempted to compute destroyed ref (node-2): b]`, ) }) }) }) describe('a dynamic graph', () => { const makeGraph = () => { const graph = new ReactiveGraph() graph.context = {} const a = graph.makeRef(1, { label: 'a' }) const b = graph.makeRef(2, { label: 'b' }) const c = graph.makeRef<'a' | 'b'>('a', { label: 'c' }) const numberOfRunsForD = { runs: 0 } const d = graph.makeThunk( (get) => { numberOfRunsForD.runs++ return get(c) === 'a' ? get(a) : get(b) }, { label: 'd' }, ) const e = graph.makeRef(2, { label: 'e' }) const f = graph.makeThunk((get) => get(d) * get(e), { label: 'f' }) // a(1) b(2) c('a') // \ / / // \ / / // d = a or b depending on c // \ // \ // e(2) \ // \ \ // \ \ // f = d * e expect(graph.atoms.size).toBe(6) return { graph, a, b, c, d, e, f, numberOfRunsForD } } it('has the right initial values', () => { const { d, f } = makeGraph() expect(d.computeResult()).toBe(1) expect(f.computeResult()).toBe(2) }) it('dynamically adjusts d when a, b or c changes', () => { const { graph, c, d, e, f, numberOfRunsForD } = makeGraph() expect(numberOfRunsForD.runs).toBe(0) expect(d.computeResult()).toBe(1) expect(f.computeResult()).toBe(2) expect(numberOfRunsForD.runs).toBe(1) graph.setRef(c, 'b') expect(d.computeResult()).toBe(2) expect(f.computeResult()).toBe(4) expect(numberOfRunsForD.runs).toBe(2) graph.setRef(e, 3) expect(f.computeResult()).toBe(6) expect(numberOfRunsForD.runs).toBe(2) }) it('runs d only when a changes, not b', () => { const { graph, a, b, d, numberOfRunsForD } = makeGraph() numberOfRunsForD.runs = 0 d.computeResult() expect(numberOfRunsForD.runs).toBe(1) graph.setRef(a, 3) expect(d.computeResult()).toBe(3) expect(numberOfRunsForD.runs).toBe(2) graph.setRef(b, 4) expect(d.computeResult()).toBe(3) expect(numberOfRunsForD.runs).toBe(2) }) }) describe('a diamond shaped graph', () => { const makeGraph = () => { const graph = new ReactiveGraph() graph.context = {} const a = graph.makeRef(1) const b = graph.makeThunk((get) => get(a) + 1) const c = graph.makeThunk((get) => get(a) + 1) // track the number of times d has run in an object so we can mutate it const dRuns = { runs: 0 } // normally thunks aren't supposed to side effect; // we do it here to track the number of times d has run const d = graph.makeThunk((get) => { dRuns.runs++ return get(b) + get(c) }) // a(1) // / \ // b c // \ / // d = b + c expect(graph.atoms.size).toBe(4) return { graph, a, b, c, d, dRuns } } it('has the right initial values', () => { const { b, c, d } = makeGraph() expect(b.computeResult()).toBe(2) expect(c.computeResult()).toBe(2) expect(d.computeResult()).toBe(4) }) it('propagates change through the graph', () => { const { graph, a, b, c, d } = makeGraph() graph.setRef(a, 5) expect(b.computeResult()).toBe(6) expect(c.computeResult()).toBe(6) expect(d.computeResult()).toBe(12) }) // if we're being efficient, we should update b and c before updating d, // so d only needs to update one time it('only runs d once when a changes', () => { const { graph, a, d, dRuns } = makeGraph() expect(dRuns.runs).toBe(0) d.computeResult() expect(dRuns.runs).toBe(1) graph.setRef(a, 5) d.computeResult() d.computeResult() // even extra calls to computeResult should not run d again expect(dRuns.runs).toBe(2) }) }) describe('a trivial graph with undefined', () => { const makeGraph = () => { const graph = new ReactiveGraph() graph.context = {} const a = graph.makeRef(1) const b = graph.makeRef(undefined) const c = graph.makeThunk((get) => { return get(a) + (get(b) ?? 0) }) const d = graph.makeRef(3) const e = graph.makeThunk((get) => get(c) + get(d)) // a(1) b(undefined) // \ / // \ / // c = a + b // \ // \ // d(3) \ // \ \ // \ \ // e = c + d expect(graph.atoms.size).toBe(5) return { graph, a, b, c, d, e } } it('has the right initial values', () => { const { c, e } = makeGraph() expect(c.computeResult()).toBe(1) expect(e.computeResult()).toBe(4) }) }) describe('error handling', () => { it('throws an error when no context is set', () => { const graph = new ReactiveGraph() const a = graph.makeRef(1) const b = graph.makeThunk((get) => get(a) + 1) expect(() => b.computeResult()).toThrowErrorMatchingInlineSnapshot( `[Error: LiveStore Error: \`context\` not set on ReactiveGraph (graph-19)]`, ) }) }) // Bug: When an effect calls setRef during execution, it triggers nested runEffects calls. // The nested call would overwrite and clear the shared currentDebugRefresh state, // causing a TypeError when the outer runEffects tried to access currentDebugRefresh.refreshedAtoms. // Fix: Use local variables to capture debug state instead of relying on shared mutable state. describe('bug fix: currentDebugRefresh race condition', () => { it('keeps the debug refresh context when nested effect runs are triggered', () => { type TestRefreshReason = DebugRefreshReasonBase | { _tag: 'outer' } | { _tag: 'nested' } const graph = new ReactiveGraph() graph.context = {} const triggerRef = graph.makeRef(0, { label: 'trigger' }) const responseRef = graph.makeRef(0, { label: 'response' }) const triggerThunk = graph.makeThunk((get) => get(triggerRef), { label: 'triggerThunk' }) const laterThunk = graph.makeThunk((get) => get(triggerRef) + 1, { label: 'laterThunk' }) const responseThunk = graph.makeThunk((get) => get(responseRef), { label: 'responseThunk' }) const nestedEffect = graph.makeEffect((get) => { get(responseThunk) }) let observedLater: number | undefined const outerEffect = graph.makeEffect((get) => { const current = get(triggerThunk) if (current !== 0) { graph.setRef(responseRef, current, { debugRefreshReason: { _tag: 'nested' }, }) } observedLater = get(laterThunk) }) nestedEffect.doEffect() outerEffect.doEffect() graph.debugRefreshInfos.clear() graph.setRef(triggerRef, 1, { debugRefreshReason: { _tag: 'outer' } }) expect(observedLater).toBe(2) const refreshInfos = Array.from(graph.debugRefreshInfos) const outerRefresh = refreshInfos.find((info) => info.reason._tag === 'outer') expect(outerRefresh).toBeDefined() const refreshedLabels = outerRefresh!.refreshedAtoms.map((atomInfo) => atomInfo.atom.label) expect(refreshedLabels).toContain('triggerThunk') expect(refreshedLabels).toContain('laterThunk') const makeThunkRefreshes = refreshInfos.filter((info) => info.reason._tag === 'makeThunk') expect(makeThunkRefreshes).toHaveLength(0) }) it('handles nested runEffects from effect calling setRef', () => { const graph = new ReactiveGraph() graph.context = {} const a = graph.makeRef(1) const b = graph.makeRef(2) // Effect that calls setRef, triggering nested runEffects graph .makeEffect((get) => { get(a) graph.setRef(b, 3) }) .doEffect() // Effect observing b graph.makeEffect((get) => get(b)).doEffect() // Previously crashed with: Cannot read properties of undefined (reading 'refreshedAtoms') // Now handles nested runEffects correctly expect(() => graph.setRef(a, 2)).not.toThrow() }) it('handles thunk calling setRef directly', () => { const graph = new ReactiveGraph() graph.context = {} const a = graph.makeRef(1) const b = graph.makeRef(2) // Effect observing b so setRef(b) triggers runEffects graph.makeEffect((get) => get(b)).doEffect() // Thunk that calls setRef during its computation const thunk = graph.makeThunk((get) => { const val = get(a) graph.setRef(b, val * 2) // This triggers nested runEffects return val + get(b) }) // With our fix, this handles nested currentDebugRefresh correctly expect(() => thunk.computeResult()).not.toThrow() expect(thunk.computeResult()).toBe(3) // 1 + 2 (b was already set to 2) }) it('handles nested thunk computations', () => { const graph = new ReactiveGraph() graph.context = {} const a = graph.makeRef(1) // Outer thunk that creates and computes inner thunk const outerThunk = graph.makeThunk((get) => { const val = get(a) // Create and compute inner thunk during outer computation const innerThunk = graph.makeThunk((innerGet) => innerGet(a) * 2) // Nested thunk computation - previously could corrupt currentDebugRefresh return val + innerThunk.computeResult() }) // With our fix, nested thunk computations work correctly expect(() => outerThunk.computeResult()).not.toThrow() expect(outerThunk.computeResult()).toBe(3) // 1 + (1 * 2) }) }) // Bug: Nodes could have undefined or deleted super/sub properties in certain edge cases, // causing crashes with "Cannot read properties of undefined" errors. // Fix: Added validation checks in destroyNode to handle corrupted nodes gracefully. // Note: Full addEdge protection was removed, so some scenarios still crash with native errors. describe('bug fix: node corruption protection', () => { it('handles node destruction during effect execution', () => { const graph = new ReactiveGraph() graph.context = {} const a = graph.makeRef(1) const thunk1 = graph.makeThunk((get) => get(a) * 2) const thunk2 = graph.makeThunk((get) => get(thunk1)) let firstRun = true const effect = graph.makeEffect((get) => { if (firstRun !== undefined) { firstRun = false graph.destroyNode(thunk1) // Destroy dependency mid-execution } get(thunk2) }) // Should detect the destroyed node expect(() => effect.doEffect()).toThrow('LiveStore Error: Attempted to compute destroyed') }) })