/// /** * Test Pattern: Idempotent Side Effects in computed() * * CLAIM TO VERIFY (from blessed/reactivity.md): * "computed, lift, and derive CAN have side effects - but they MUST be idempotent." * * This pattern demonstrates the difference between: * 1. NON-IDEMPOTENT side effects: Appending to an array on every run (causes thrashing) * 2. IDEMPOTENT side effects: Check-before-write with deterministic keys (settles properly) * * HOW TO MANUALLY VERIFY: * 1. Click "Trigger Re-computation" button multiple times * 2. Observe the "Non-Idempotent Run Count" - it will keep growing indefinitely * 3. Observe the "Non-Idempotent Data" array - it will accumulate duplicate entries * 4. Observe the "Idempotent Run Count" - it will stabilize after a few runs * 5. Observe the "Idempotent Data" map - it will contain only unique entries * * EXPECTED BEHAVIOR (confirms the claim): * - Non-idempotent approach: Run count grows without bound (thrashing) * - Idempotent approach: Run count stabilizes quickly (system settles) * * WHY THIS HAPPENS: * - Non-idempotent: Each run adds a new element, changing the array, triggering another run * - Idempotent: Check-before-write ensures no actual change after first run, system settles */ import { Cell, computed, Default, handler, ifElse, NAME, pattern, UI, } from "commontools"; interface TestInput { // Trigger value that we can change to force re-computation triggerCount: Default; } interface TestOutput { triggerCount: Cell; nonIdempotentRunCount: number; idempotentRunCount: number; nonIdempotentData: unknown[]; idempotentData: Record; } const incrementTrigger = handler }>( (_args, state) => { state.triggerCount.set(state.triggerCount.get() + 1); }, ); export default pattern(({ triggerCount }) => { // Shared state for tracking const nonIdempotentArray = Cell.of([]); const nonIdempotentCounter = Cell.of(0); const idempotentMap = Cell.of>({}); const idempotentCounter = Cell.of(0); // Computed values for conditional rendering const isThrashing = computed(() => nonIdempotentCounter.get() > 10); const isSettling = computed(() => { const count = idempotentCounter.get(); return count > 2 && count < 10; }); // NON-IDEMPOTENT APPROACH: Append to array on every run // This causes thrashing - the computed keeps running forever because // it modifies the array, which triggers another run, which modifies again, etc. // NOTE: Only triggers when triggerCount > 0 to avoid thrashing on initial load const nonIdempotentComputed = computed(() => { // Read the trigger to create a dependency const trigger = triggerCount; // Only run side effect after user clicks (triggerCount > 0) // This prevents thrashing during initial load if (trigger > 0) { // NON-IDEMPOTENT SIDE EFFECT: Always append const current = nonIdempotentArray.get(); nonIdempotentArray.set([...current, { trigger, timestamp: Date.now() }]); // Increment counter to show how many times this ran nonIdempotentCounter.set(nonIdempotentCounter.get() + 1); } return nonIdempotentCounter.get() > 0 ? `Non-idempotent computed ran ${nonIdempotentCounter.get()} times` : "Click trigger to start"; }); // IDEMPOTENT APPROACH: Check-before-write with deterministic keys // This settles - the computed runs a few times but then stops because // after the first run, the check-before-write prevents actual mutation const idempotentComputed = computed(() => { // Read the trigger to create a dependency const trigger = triggerCount; // Only run side effect after user clicks (triggerCount > 0) if (trigger > 0) { // IDEMPOTENT SIDE EFFECT: Only write if key doesn't exist const current = idempotentMap.get(); const key = `trigger-${trigger}`; // Deterministic key based on input // Check before write - idempotent! if (!(key in current)) { idempotentMap.set({ ...current, [key]: { trigger, timestamp: Date.now() }, }); } // Increment counter to show how many times this ran idempotentCounter.set(idempotentCounter.get() + 1); } return idempotentCounter.get() > 0 ? `Idempotent computed ran ${idempotentCounter.get()} times` : "Click trigger to start"; }); return { [NAME]: "Test: Idempotent Side Effects", [UI]: (

Idempotent Side Effects Test

Trigger Count: {triggerCount}

Trigger Re-computation
{/* Non-Idempotent Column */}

❌ Non-Idempotent (Thrashing)

Status: {nonIdempotentComputed}

Run Count: {nonIdempotentCounter}

Array Length: {nonIdempotentArray.get().length}

Data (keeps growing):
                {JSON.stringify(nonIdempotentArray.get(), null, 2)}
              
{ifElse( isThrashing,
⚠️ THRASHING DETECTED! Run count: {nonIdempotentCounter}
, null, )}
{/* Idempotent Column */}

✅ Idempotent (Settles)

Status: {idempotentComputed}

Run Count: {idempotentCounter}

Unique Keys:{" "} {Object.keys(idempotentMap.get()).length}

Data (stable):
                {JSON.stringify(idempotentMap.get(), null, 2)}
              
{ifElse( isSettling,
✓ System settling (ran {idempotentCounter} times)
, null, )}

📚 Explanation

Non-Idempotent:{" "} Always appends to array → changes data → triggers re-run → appends again → infinite loop

Idempotent:{" "} Checks if key exists before writing → no change after first run → system settles

Key Insight:{" "} Side effects in computed() must be idempotent (same inputs = same state) to avoid thrashing

), triggerCount: triggerCount as unknown as Cell, nonIdempotentRunCount: nonIdempotentCounter, idempotentRunCount: idempotentCounter, nonIdempotentData: nonIdempotentArray, idempotentData: idempotentMap, }; });