diff --git a/packages/reflex-base/news/+onerror-without-error-object.bugfix.md b/packages/reflex-base/news/+onerror-without-error-object.bugfix.md new file mode 100644 index 00000000000..6d5faed0624 --- /dev/null +++ b/packages/reflex-base/news/+onerror-without-error-object.bugfix.md @@ -0,0 +1 @@ +A frontend error event that carries no `Error` object, such as the benign `ResizeObserver loop` notification, no longer throws inside `window.onerror` and is now reported to `handle_frontend_exception`. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index e1d7d165b7e..c1ddabda100 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -1387,7 +1387,10 @@ export const useEventLoop = ( window.onerror = function (msg, url, lineNo, columnNo, error) { addEvents([ ReflexEvent(`${app.exception_state_name}.handle_frontend_exception`, { - info: error.name + ": " + error.message + "\n" + error.stack, + // Some error events (e.g. a ResizeObserver loop) have no Error object. + info: error + ? error.name + ": " + error.message + "\n" + error.stack + : msg + "\n at " + url + ":" + lineNo + ":" + columnNo, component_stack: "", }), ]); diff --git a/tests/units/compiler/state_js.test.mjs b/tests/units/compiler/state_js.test.mjs index e1214a109ff..df8472228fb 100644 --- a/tests/units/compiler/state_js.test.mjs +++ b/tests/units/compiler/state_js.test.mjs @@ -18,6 +18,7 @@ async function setup({ disabled = false, hidden = false, storedToken = "original-token", + react = { useCallback() {}, useEffect() {}, useRef() {}, useState() {} }, } = {}) { const sockets = []; const microtasks = []; @@ -102,12 +103,12 @@ async function setup({ }, "$/reflex.json": { default: { version: "test" } }, "universal-cookie": { default: class {} }, - react: { useCallback() {}, useEffect() {}, useRef() {}, useState() {} }, + react, "react-router": { - useLocation() {}, - useNavigate() {}, - useSearchParams() {}, - useParams() {}, + useLocation: () => ({ pathname: "/", search: "", hash: "" }), + useNavigate: () => () => {}, + useSearchParams: () => [new URLSearchParams()], + useParams: () => ({}), }, "$/utils/context-registry": { app: { @@ -165,6 +166,7 @@ async function setup({ window, socket, updates, + module, dispose, flush: () => microtasks.splice(0).forEach((fn) => fn()), connect: (transports = ["websocket"]) => @@ -310,3 +312,84 @@ for (const config of [ assert.equal(app.timers.size, 0); }); } + +/** Run `useEventLoop` once with minimal hooks, keeping the effects it registers. */ +async function mountEventLoop() { + const effects = []; + const sent = []; + const emitted = { + connected: true, + emit: (name, payload) => sent.push(payload), + }; + let refs = 0; + const harness = await setup({ + react: { + useCallback: (fn) => fn, + useEffect: (fn) => effects.push(fn), + // The first ref is the socket; hand it a connected stand-in. + useRef: (initial) => ({ current: refs++ === 0 ? emitted : initial }), + useState: (initial) => [initial, () => {}], + }, + }); + harness.module.namespace.useEventLoop( + () => {}, + () => [], + {}, + ); + return { harness, effects, sent }; +} + +test("window.onerror reports an error event that carries no Error object", async () => { + const { harness, effects, sent } = await mountEventLoop(); + // Only the frontend-error effect touches `window.onerror`. + for (const effect of effects) effect(); + assert.equal(typeof harness.window.onerror, "function"); + + // The benign ResizeObserver notification: a message, but `error` is null. + const message = + "ResizeObserver loop completed with undelivered notifications."; + assert.doesNotThrow(() => + harness.window.onerror( + message, + "http://localhost:3000/app.js", + 12, + 34, + null, + ), + ); + await new Promise((resolve) => setImmediate(resolve)); + + const event = sent.find( + (e) => e.name === "exception.handle_frontend_exception", + ); + assert.ok( + event, + `expected a frontend exception event, got ${JSON.stringify(sent)}`, + ); + assert.equal( + event.payload.info, + message + "\n at http://localhost:3000/app.js:12:34", + ); + assert.equal(event.payload.component_stack, ""); +}); + +test("window.onerror preserves the original Error message", async () => { + const { harness, effects, sent } = await mountEventLoop(); + for (const effect of effects) effect(); + const error = new TypeError("same old message"); + error.stack = "TypeError: same old message\n at app.js:1:2"; + assert.equal( + harness.window.onerror("ignored", "ignored.js", 9, 8, error), + false, + ); + await new Promise((resolve) => setImmediate(resolve)); + const event = sent.find( + (e) => e.name === "exception.handle_frontend_exception", + ); + assert.ok(event); + assert.equal( + event.payload.info, + error.name + ": " + error.message + "\n" + error.stack, + ); + assert.equal(event.payload.component_stack, ""); +});