/* oxlint-disable react/exhaustive-deps -- tests deliberately exercise invalid dep arrays */ import { describe, it, expect, vi } from "vitest"; import { useEffect } from "../../react-hooks/useEffect"; import { useState } from "../../react-hooks/useState"; import { createTestResource, renderTest, unmountResource } from "../test-utils"; import { renderResourceFiber, commitResourceFiber, } from "../../core/ResourceFiber"; describe("Errors - Effect Errors", () => { it("should propagate errors from effects", () => { const error = new Error("Effect error"); const resource = createTestResource(() => { useEffect(() => { throw error; }); return null; }); expect(() => renderTest(resource)).toThrow(error); }); it("should propagate errors from cleanup functions", () => { const error = new Error("Cleanup error"); let dep = 0; const resource = createTestResource(() => { useEffect(() => { return () => { if (dep > 0) { throw error; } }; }, [dep]); // Cleanup will run when dep changes return dep; }); // First render and commit - establishes the effect renderResourceFiber(resource, []); commitResourceFiber(resource); // Change dep to trigger cleanup on next render dep = 1; // Second render with different dep should trigger cleanup that throws renderResourceFiber(resource, []); expect(() => commitResourceFiber(resource)).toThrow(error); }); it("should throw on invalid effect return value", () => { const resource = createTestResource(() => { useEffect(() => { return "not a function" as any; // Invalid return }); return null; }); expect(() => renderTest(resource)).toThrow( "An effect function must either return a cleanup function or nothing", ); }); it("should handle multiple effect errors", () => { const error1 = new Error("First error"); const error2 = new Error("Second error"); const goodEffect = vi.fn(); const resource = createTestResource(() => { useEffect(() => { throw error1; }); useEffect(goodEffect); // This won't run useEffect(() => { throw error2; }); return null; }); // Should throw aggregate error expect(() => renderTest(resource)).toThrowErrorMatchingInlineSnapshot(` [AggregateError: Errors during commit] `); expect(goodEffect).toHaveBeenCalledTimes(1); }); it("should continue cleanup on unmount despite errors", () => { const cleanupError = new Error("Cleanup failed"); const cleanup1 = vi.fn(() => { throw cleanupError; }); const cleanup2 = vi.fn(); const cleanup3 = vi.fn(); const resource = createTestResource(() => { useEffect(() => cleanup1); useEffect(() => cleanup2); useEffect(() => cleanup3); return null; }); renderTest(resource); // Unmount should throw the error but should still run all cleanups expect(() => unmountResource(resource)).toThrow(cleanupError); expect(cleanup1).toHaveBeenCalled(); expect(cleanup2).toHaveBeenCalled(); expect(cleanup3).toHaveBeenCalled(); }); it("should handle errors in effect with dependencies", () => { const error = new Error("Dep effect error"); let shouldThrow = false; const resource = createTestResource(() => { const [dep, setDep] = useState(0); useEffect(() => { if (shouldThrow) { throw error; } }, [dep]); // Use effect to trigger state change useEffect(() => { if (dep === 0) { shouldThrow = true; setDep(1); // Trigger effect re-run } }, [dep]); return dep; }); // The initial render will trigger setState which causes flushSync // The flushed re-render will throw the error expect(() => renderTest(resource)).toThrow(error); }); it("should handle async errors in effects", async () => { // Set up a promise to catch the async error let asyncErrorPromise: Promise; const resource = createTestResource(() => { useEffect(() => { // Async errors are not caught by the framework asyncErrorPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error("Async error")); }, 0); }); // Catch the error to prevent unhandled rejection asyncErrorPromise.catch(() => { // Expected - async errors are not caught by the framework }); }); return null; }); // This won't throw synchronously expect(() => renderTest(resource)).not.toThrow(); // Wait for the async error to be handled await new Promise((resolve) => setTimeout(resolve, 10)); }); it("should properly clean up state after effect error", () => { const error = new Error("Effect error"); let effectRan = false; const resource = createTestResource(() => { const [value] = useState("initial"); useEffect(() => { effectRan = true; throw error; }); return value; }); expect(() => renderTest(resource)).toThrow(error); expect(effectRan).toBe(true); // Resource should not have committed state since commit failed // Since commit failed, we can't check the state through normal means }); it("should handle errors in effect cleanup during re-render", () => { const cleanupError = new Error("Cleanup during re-render"); let throwOnCleanup = false; const resource = createTestResource(() => { const [count, setCount] = useState(0); useEffect(() => { return () => { if (throwOnCleanup) { throw cleanupError; } }; }, [count]); // Use effect to trigger state change useEffect(() => { if (count === 0) { throwOnCleanup = true; setCount(1); } }, [count]); return count; }); // The initial render will trigger setState which causes flushSync // During the flush, the cleanup will run and throw expect(() => renderTest(resource)).toThrow(cleanupError); }); });