From cadd72b96fbfa5de8c2395ffbcc3f11239e3001c Mon Sep 17 00:00:00 2001 From: Adeel Raza Date: Tue, 28 Jul 2026 18:22:27 +0400 Subject: [PATCH] fix: contain exceptions thrown by consumer onError callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fail() runs as the terminal .catch of the serialized mount/reset chain. A consumer onError that threw would reject chainRef.current, producing an unhandled promise rejection and poisoning the chain so every subsequent mount/reset link was silently skipped — violating the documented invariant that a rejection never poisons the chain. Wrap the onError call in try/catch and route a callback exception to console.error so the diagnostic path can never become a new failure. Closes #9 --- src/ImageEditor.tsx | 12 +++++++++++- test/index.test.tsx | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/ImageEditor.tsx b/src/ImageEditor.tsx index 2151ee0..404763a 100644 --- a/src/ImageEditor.tsx +++ b/src/ImageEditor.tsx @@ -47,7 +47,17 @@ function ImageEditorInner( const err = error instanceof Error ? error : new Error(String(error)); const { onError } = latestPropsRef.current; if (onError) { - onError(err); + // A throwing onError must never escape: fail() runs as the terminal + // .catch of the serialized chain, so a throw here would reject + // chainRef and poison every subsequent link (see the invariant above). + try { + onError(err); + } catch (callbackError) { + console.error( + '[react-image-editor] onError callback threw', + callbackError + ); + } } else { console.error('[react-image-editor]', err); } diff --git a/test/index.test.tsx b/test/index.test.tsx index ac1ec0e..2eaba8c 100644 --- a/test/index.test.tsx +++ b/test/index.test.tsx @@ -477,6 +477,35 @@ it('reports a reset rejection via onError without poisoning the chain', async () expect(mockInstance.reset).toHaveBeenLastCalledWith('img-c'); }); +it('contains a throwing onError without poisoning the chain', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const onError = vi.fn(() => { + throw new Error('boom'); + }); + const { rerender } = render(); + await flush(); + + // fail() runs as the terminal .catch of the chain; a throwing onError + // must be swallowed rather than rejecting chainRef. + mockInstance.reset.mockRejectedValueOnce(new Error('reload failed')); + rerender(); + await flush(); + + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + // The callback's own throw is reported, not propagated. + expect(consoleError).toHaveBeenCalledWith( + '[react-image-editor] onError callback threw', + expect.any(Error) + ); + + // The chain survived: a later image change still resets normally. + rerender(); + await flush(); + expect(mockInstance.reset).toHaveBeenLastCalledWith('img-c'); + + consoleError.mockRestore(); +}); + it('waits for a pending reset before destroying on unmount', async () => { const resetDeferred = defer(); mockInstance.reset.mockImplementationOnce(() => resetDeferred.promise);