June 10, 2026
Recently, we had a crash in our NeetoCRM application.

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.
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 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.
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.
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.
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.