Debugging frontend crash and handling circular dependency

Joseph Mathew avatar

Joseph Mathew

June 10, 2026

Recently, we had a crash in our NeetoCRM application.

crash screenshot

As we can see in the screenshot, it looks like the error happened in neeto-widget-replay.js file.

At Neeto we have built an internal tool called NeetoReplay which captures users' activities in the browser. This helps us in debugging when users contact us for support or in investigating bugs. This is built on top of rrweb. Just want to add that admins of the workspace can completely opt out of NeetoReplay.

We had not changed anything in NeetoReplay for a while, so the error happening in NeetoReplay was perplexing. Upon investigation, I found that the console indeed pointed to the neeto-widget-replay.js file. But I also knew that the filename only tells us which function called console.error, not where the error originated.

The replay widget wraps console.log, console.warn, and console.error so it can capture console output for session replay. Once the page loads, every console message passes through the widget's wrapper, causing DevTools to associate those messages with the widget file. As a result, when the NeetoCRM app throws an error during startup, the console makes it look like the error came from the replay widget even though the actual exception was thrown inside the NeetoCRM bundle.

The way rrweb's console plugin works, it replaces console.log, console.warn, and console.error so console output can be captured for session replay. I checked whether rrweb provides a way to preserve the original caller information, but it doesn't. Removing the wrapper would also mean losing console capture from replays.

This isn't specific to rrweb either. Honeybadger, Sentry, PostHog, and React DevTools use similar wrapping and have the same behavior. React DevTools has an identical issue documented in facebook/react#22257.

The only known mitigation is Chrome's x_google_ignoreList source map feature, which allows DevTools to hide library frames and show the host application frame instead. However, it only works when source maps are available. Since we don't ship source maps with the production minified bundle, this isn't available in production. As a result, the console attribution is an unavoidable side effect of session replay tooling, but the actual error information remains intact.

The video mentioned below is the one I created for the internal Neeto folks. The video is being published as-is without any modifications.

Back to the error

The real error is:

TypeError: I is not a function at chunk-CL4SWXVJ.digested.js

I is the minified name for createColumn, a helper defined in commons/utils.jsx.

The failure occurs because code in commons/constants.js calls createColumn(...) before the function has been initialized. At runtime, createColumn is still undefined, causing the call to fail and preventing the React application from starting. The subsequent Failed to load component App message is simply the mount code reporting that application initialization failed.

Tracing this back further shows that commons/constants.js and commons/utils.jsx have a circular dependency. constants.js imports createColumn from utils.jsx while utils.jsx imports values from constants.js.

In development, Vite serves modules as native ES modules and the browser evaluates them depth-first. When constants.js imports createColumn from utils.jsx, the browser evaluates utils.jsx first, which initializes createColumn before control returns to constants.js. By the time constants.js calls createColumn, the function is already defined.

In production, builds are generated using esbuild, which bundles everything into a single file and has to choose a linear execution order. This can introduce subtle differences in behavior, particularly around module evaluation order and circular dependencies. In the generated bundle, code from constants.js ends up running before createColumn is initialized, which causes the crash.

The reason this surfaced now is that a recent change introduced the following import in the Deals, Leads, and Contacts Show/index.jsx files:

import { buildHeaderMoreMenu } from "components/commons/utils";

Those files did not previously import from commons/utils. The circular dependency already existed, but this additional import changed esbuild's bundling order and exposed the issue. The change did not introduce the circular dependency itself; it only made the existing problem surface in production.

The fix

The fix is to break the circular dependency by moving createColumn into a small standalone module that has no dependency on commons/constants.js. Once the circular import is removed, esbuild can no longer generate an execution order where createColumn is referenced before initialization.

How to replicate this behavior in the development environment

To help catch these issues earlier, we provide an esbuild-based development server that mirrors production bundling behavior while still supporting automatic rebuilds during development.

Start the Rails server with the ESBUILD_DEVSERVER flag enabled.

ESBUILD_DEVSERVER=true bundle exec rails server

In a separate terminal, start esbuild in watch mode.

yarn build --watch

With ESBUILD_DEVSERVER=true, Rails serves the assets generated by esbuild instead of the assets served by Vite. Running yarn build --watch ensures the bundles are rebuilt automatically whenever files change, allowing you to test the application using the same bundling behavior as production.

Why Honeybadger didn't catch this error

If JavaScript execution fails before the React tree mounts, Honeybadger never receives the error. This includes chunk load failures, syntax errors in the entry chunk, runtime errors during module evaluation, and bundling issues caused by incorrect module ordering.

The error was thrown while evaluating the App chunk and was visible in the browser console for every affected user. However, no Honeybadger issue was created.

The initialization flow looked like this:

application.js
└─ mount({ App: () => import("src/App") })
     └─ dynamic import("src/App")
          └─ <App>
               └─ <AppContainer>
                    └─ <HoneybadgerErrorBoundary>

Honeybadger.configure() is called inside HoneybadgerErrorBoundary. Since that component is rendered as a descendant of <App>, Honeybadger is initialized only after the App chunk has been successfully loaded and rendered.

When a startup failure occurs, import("src/App") rejects, mount.js catches the error and logs it using console.error(...), and the execution stops before <App> is mounted. Because the error has already been handled, window.onerror is never triggered. Since <HoneybadgerErrorBoundary> never renders, Honeybadger.configure() is never called, which means no API key is configured, no global handlers are registered, and no error is reported.

As a result, startup failures that completely prevent the application from loading are invisible to Honeybadger.

Honeybadger should be initialized before loading the App chunk so that failures during application bootstrap, chunk loading, and module evaluation are captured and reported.

The Honeybadger fix

Honeybadger is now configured in application.js, before mount() runs:

application.js
├─ Honeybadger.configure({ enableUncaught: true, ... })   // configured before mount
└─ mount({ App: () => import("src/App") })
     ├─ dynamic import("src/App") succeeds
     │    └─ <App>
     │         └─ <AppContainer>
     │              └─ <HoneybadgerErrorBoundary>   // uses pre-configured client
     │
     └─ dynamic import("src/App") fails
          └─ Honeybadger.notify(error, { name: "AppMountError" })

With enableUncaught: true, window.onerror is armed before any dynamic import runs, so failures during module evaluation are captured. And if the App chunk itself fails to load, mount() reports it explicitly via Honeybadger.notify(...) instead of only logging to the console.

Follow @bigbinary on X. Check out our full blog archive.