An uncaught error was detected outside of a test.
Cannot read properties of undefined (reading 'prototype')
Have you ever opened a report and found a spec file whose tests weren't failing, they just weren't there? We all have!
Mine had been green for a year, on a branch where nobody had touched it. None of its tests ran, and not one of them failed either: the file never got as far as registering them, so the report had nothing for them except that single generated failure under the generic title.
The cause was an import cycle in a page-object graph. And finishing it took me three separate attempts across eight months (yes, eight): one-off fixes first, then a sweep of 49 cycles in a single area, then a sweep of the rest, and finally guardrails so the class of bug could not come back. This post is the version I wish I had read at attempt one.
Why test repos can't shrug cycles off
Most JavaScript developers have shipped a circular import without ever noticing, because ES modules tolerate them surprisingly well. The bindings are hoisted; if module A is only using something from B inside a function that runs later, everything resolves by the time it is called.
Test repos break that tolerance, for two reasons.
A page-object layer is a dense mutual graph by design. A fluent API means the parent returns children and the children return to the parent: orders.openFilters().selectRegion('EMEA').apply(). OrdersContext must know about FiltersContext, and FiltersContext must hand back an OrdersContext. That is a cycle in the module graph, as a direct consequence of the ergonomics you chose. Not sloppiness. Which is why "just don't write cycles" is not advice.
Now, here is where the plot thickens: decorators run at module evaluation time. This is the part that turns a latent cycle into a fatal one. Say your page objects are annotated for logging:
export class OrdersContext {
@step('Open the filters panel')
openFilters(): FiltersContext<OrdersContext> { /* ... */ }
}
Then step must be a real function the moment the class body is evaluated, during import, not when a test calls the method. Same for anything else evaluated eagerly: a base class in extends, a static field initialiser, a module-level new. In a cycle, one of the two modules is always mid-evaluation when the other asks for its exports, so one of those bindings is undefined. And a decorator that is undefined throws immediately.
Because it throws during import, it throws outside any test, which is exactly what the Cypress error message is telling you:
import → class body evaluates → decorator is undefined → throws outside any test
→ no test to attribute it to → the file's tests are discarded
→ reports one generated failure in their place
That's why the blast radius is a whole spec file instead of one assertion, and why your own tests vanish from the report instead of going red.
Three tools, three different jobs
I ended up using two, and they are not interchangeable, so here's what each one is for.
dpdm: static analysis, the CI gate. It parses the TypeScript graph without building anything and exits non-zero on cycles:
{
"scripts": {
"check-circular": "dpdm -T --circular --no-warning --no-tree --exit-code circular:1 \"app/**/*.ts\" \"cypress/**/*.ts\""
}
}
Four flags earn their place:
--no-treeand--no-warning→ suppress the enormous dependency dump and the warning list, so the output is only the cycles. That's what keeps CI logs readable.-T(--transform) → compiles each file before analysing it, so imports that only carry types drop out of the graph. Without it,dpdmreports type-only edges as cycles too,import typeincluded.--exit-code circular:1→ the one that matters. Without it,dpdmreports cycles and exits0, and a check that cannot fail the build is a report nobody reads.
Note both globs. A cycle that routes through a spec file counts, and I found several that did.
circular-dependency-plugin: build time, catches what the bundler actually resolves. The static tool and the bundler don't always agree; aliases, paths mappings and preprocessor config can produce edges one of them misses. Running the webpack plugin unconditionally, with caching off, is what closed that gap:
new CircularDependencyPlugin({
exclude: /node_modules/,
failOnError: true,
allowAsyncCycles: false,
cwd: process.cwd(),
})
It used to sit behind an environment variable, with a filesystem cache. Which meant it was off in every run that mattered, and when it was on, a warm cache could skip the very files that had changed. A guardrail that is conditional and cached is a guardrail that is off.
import/no-cycle (eslint-plugin-import): editor feedback. Best signal-to-effort if you want the squiggle while typing. I didn't lean on it, because it's slow on a large graph and I wanted the gate to live somewhere the developer couldn't skip.
The four cycles I kept running into
1. The leaf utility that lives in a hub module
Everything imports the hub for one decorator → the hub imports them right back.
The step decorator was defined in the support file, the same file that imports page objects to register custom commands. So every page object did this:
import { step } from '../../cypress/support/commands';
and commands.ts did this:
import { OrdersContext } from '@/orders/orders-context';
import { NotificationsContext } from '@/utilities';
app/* → commands.ts → app/*. One cycle per page object, roughly seventy of them, all from the same mistake made once.
The fix was one file and one line per consumer: move the decorator into its own module with zero imports, then re-export it from the hub for backwards compatibility.
export function step(description: string) { /* ... */ }
import { step } from './step-decorator';
export { step };
As you can see, it's almost embarrassingly small, and it's still the highest-value refactor in this post: mechanical, one line per file, and it deletes an entire family of cycles at once. A utility that everything imports must import nothing. If it lives in a file that also wires up your framework, it lives in the wrong file.
2. instanceof in a mutual pair
A type check that needs the class at runtime → an import the compiler can't erase.
The first fatal case I hit. A grid context stored the last action it created and branched on its type:
private lastActionContext: RowActionBaseContext | null = null;
if (this.lastActionContext instanceof EditableRowActionBaseContext) {
this.lastActionContext.openEditor().save();
}
Reads fine in review. Blows up at import time. instanceof needs the class as a runtime value, so unlike a type annotation, that import survives compilation. The action classes import the grid context that creates them, so the cycle is genuine at runtime. While the modules evaluate, one side is still undefined when the other's decorators and extends clauses run, and that's where errors like Cannot read properties of undefined come from.
The fix was to stop asking "what class is this?" and start asking "can it do the thing?":
if (this.lastActionContext?.openEditor) {
this.lastActionContext.openEditor().save();
}
Duck typing removes the runtime import entirely; declare the capability as an optional method on the base type and it still type-checks. It's also a better test, honestly: the branch cares about a capability, not an inheritance position.
Where a class-identity check genuinely could not be avoided, I fell back to a name comparison:
if (this.parentContext?.constructor?.name === 'ConfirmDialogContext') { /* ... */ }
That works, and I won't pretend it's nice. It's a string, so a rename silently changes behaviour, and it breaks under any minification that mangles class names. Acceptable in a test repo that ships unminified and where a rename is a grep away. Do not carry that pattern into production code (seriously).
3. The type-only import, the one that actually matters
Erased at compile time (usually) → still an edge to your lint rules.
This is the insight that turned the sweep from a slog into a mechanical pass, and it ended up being most of the work:
Types cannot cause a runtime cycle. Only values can.
A type annotation is erased by the compiler. If OrdersContext imports FiltersContext only to name a return type, that edge usually disappears in the emitted JavaScript, but a plain import statement doesn't say so. Lint rules like import/no-cycle still count it, and with verbatimModuleSyntax on, the compiler keeps it too.
So say what you mean, and the edge goes away for free:
import type { FiltersContext } from './filters-context';
import type { OrdersPropertiesContext } from './orders-properties-context';
And when you also need the constructor at runtime? Split the two needs: import type at the top for the type position, and a local require at the point of use for the value.
@step('Open the filters panel')
openFilters(): FiltersContext<OrdersContext> {
this.toolbar.clickFilters();
const { FiltersContext } = require('./filters-context');
return new FiltersContext(this);
}
The deferred require moves resolution from module-evaluation time to call time, by which point both modules are fully initialised. Two caveats, both real: it's CommonJS, so it needs a bundler or ts-node setup that permits it, and static analysers still see the edge. So it fixes the crash but may not clear the report. Which is exactly why the next pattern exists.
4. Generic parents instead of concrete ones
The child stops naming its parent → nothing left to import.
The structural fix:
export class FiltersContext<TParent> {
constructor(private readonly parentContext: TParent) {}
@step('Apply and return')
apply(): TParent {
this.button.click();
return this.parentContext;
}
}
The child is now parameterised over whoever created it. The fluent chain still type-checks end to end, the parent's return type becomes FiltersContext<OrdersContext>, and the child module imports nothing from the parent. No type import, no require, no edge in any tool's graph.
It's the shape I'd start from if I were building the layer today. Retrofitting it across an existing suite is a bigger change than the other three, which is why parts of my repo still carry the cheap version of the fix: a parameter typed any to sever the edge.
reopenSettings(sidePanel: any): this { /* ... */ }
I want to name that honestly, because posts like this usually end at the clean solution. any breaks the cycle in one line and costs you type safety at that boundary. The ones I introduced are still sitting in the lint report as known debt: tracked, not forgotten, and only fully removable by the generic-parent refactor above. Would I take the same trade again, given the alternative was spec files that silently produced no results? Yes. But it's a trade, not a fix.
Four layers so it can't come back
The sweeps were not the win. Cycles came back twice after I cleaned them, because nothing stopped them. What ended it was making the repo unable to accept one:
- Pre-commit hook.
check-circularruns before the unit tests and catches it on the machine that created it, in seconds. - An npm script in the CI preflight, alongside the repo's other structural checks, so a green pull request means no cycles.
- The bundler plugin, always on and uncached, to catch edges the static pass resolves differently.
- A scan in the test runner's own setup: the runner parses the graph in
setupNodeEventsand aborts before a single test executes:
async setupNodeEvents(on, config) {
const { parseDependencyTree, parseCircular } = require('dpdm');
const tree = await parseDependencyTree('app/**/*.ts', {
cwd: config.projectRoot,
extensions: ['.ts'],
transform: true,
});
const circular = parseCircular(tree);
if (circular.length) {
throw new Error(`Circular dependencies detected:\n${circular.map(c => c.join(' → ')).join('\n')}`);
}
// ...
}
Basically, the cycle path becomes the error message, arrows and all.
Layer four is the one I'd argue for hardest, and it's the one people push back on. You're asking "isn't that overkill? The gate already exists in CI!" It does. But the failure mode it prevents is a four-hour run that reports partial results, where a cycle silently voids a handful of spec files and the summary looks plausible. Failing in ten seconds with the cycle path printed is strictly better than passing 900 tests and quietly skipping 40. Trade a silent late failure for a loud early one every time.
Where the sweep landed, three attempts and eight months later: 49 cycles cleared in one area, then the rest, the any stopgaps parked in the lint report where I can see them, and four layers between any new cycle and a spec file. Next time a spec nobody touched throws that error at you, look at the graph, not the spec.
What about you? Has a cycle ever eaten one of your spec files, or did your tooling catch it first? Tell me in the comments, I'd love to compare notes!
