← Ascendy

frontend

A component vanished quietly — the cost of half-following a convention

· Ascendy Engineering


TL;DR

About this piece. A retrospective distilled from a frontend-team intake; every defect mentioned has been fixed and shipped. A neighboring case from the same team is three misdiagnoses a silent fallback produced — though its thesis (an absent signal supplies misdiagnoses) is different from this one’s (the unused half of a convention stays unverified).

The symptom — not an error, not an empty page

The invite screen should show a QR code. It showed a blank white box.

One thing to establish first: this was not a production-only bug. Dev rendered the same blank box. Exactly one thing differed — in dev the console logged a “failed to resolve component” warning, and in production there wasn’t even that. And nobody was reading that line. The report came from production because that’s where the actual users are, not because that’s where it broke.

That shape — “blank white box” — tells you a lot about the character of this bug. An error screen has a stack. Nothing rendering at all collapses the layout and gets noticed. But when the space is occupied exactly and only the contents are missing, it reads as the component rendered and its insides came up empty. So suspicion naturally moves to the logic that fills the inside.

The plausible misdiagnosis

A patch had just landed that changed how QR codes were generated — moving from an external service to local generation, as a security improvement.

The timing lined up perfectly. QR stopped appearing after that patch. So that patch broke something is the most reasonable inference available. That was the operator’s first hypothesis, and I’d have started there too.

One more thing compounded it. The QR component contained a silent catch for generation failures — on failure it leaves the image data as an empty string and moves on. The existence of that code completes the story: “the library failed in production, the catch swallowed it, hence the blank box.” The more you read the code, the more right that hypothesis looks.

The bundle redirected us

The breakthrough wasn’t digging further into the symptom. It was reading the build output.

First, in dev, we imported the QR library directly and generated a code. It worked. The library hypothesis wobbled once here.

Then we built for production and grepped the client bundle for an error string unique to the QR library.

npm run build
grep -r "too big to be stored in a QR Code" dist/    # a string only that library has
# → no results

It matters not to jump to a conclusion here. One missing string is not proof that the library is absent from the bundle. Minification and dead-code elimination could have removed just that string; it could have landed in a server-only chunk; it could have been externalized; or we could have grepped the wrong slice of the output.

The value of that grep wasn’t proof — it was redirection. The “the library failed” story wobbled, and that decides where to look next: how did that component actually compile?

A lesson to take. Problems that split along environments usually have their answer in the build. And a library’s unique strings are a good starting point — unlike versions or paths, bundlers rarely rewrite them. But that’s a lead, not a verdict.

The real cause — the name was different

Following the compiled output of the call site:

// Not replaced by a static import; left as a lookup by name at runtime
const N = resolveComponent("QrCodeImage")

We checked the auto-import registry. The component was registered not as QrCodeImage but as CommonQrCodeImage.

That’s what settled it. No static import in the compiled output, and the name the template used isn’t registered — those two together also explain the earlier grep. With no importer, the bundler had no reason to pull that library in.

The rule: the name of the directory containing the component is prefixed to its registered name. QrCodeImage.vue under common/ becomes CommonQrCodeImage. The templates, meanwhile, wrote <QrCodeImage> with no explicit import. No such name is registered.

An unresolved name renders as an unresolved custom element. Browsers don’t error on unknown tags — they treat them as inline elements and move on. Hence a blank white box.

This also explains why the trap lived in only one directory. Components whose filenames already start with their directory name (say mobile/MobileXxx.vue) get the duplication collapsed and register as MobileXxx. So bare tags there happened to be correct. Only common/ was mined.

The point — the cost of half-following a convention

Here’s the substance.

This repo mostly followed an explicit-import convention. Use a component, write an import at the top. A good convention, and most files honored it.

The problem is that auto-import was never turned off. So the repo’s actual state wasn’t “we use explicit imports” but “we mostly use explicit imports, and a few places depend on auto-import.” And nobody was aware of those few places.

Which produces the crux. Because auto-import was barely used, nobody had ever checked how it actually registers names. Had it been a daily-use feature, someone would have hit the prefix rule in the first week and it would have become team knowledge. Because it was barely used, it stayed wrong for months.

That’s the danger of a half-followed convention. When two approaches coexist, the less-used one is the more dangerous one — because verification comes from usage. Heavily used paths get walked every day and are verified as a side effect; lightly used paths never get walked, so nobody learns they’re wrong.

So there are two acceptable states. Follow the convention completely (turn auto-import off), or acknowledge that both mechanisms coexist and explicitly verify the one you use less. The worst state is the one we were in: believing “we use explicit imports” while a few places don’t.

The siblings the sweep found

Once you know the cause, the same pattern is mechanically findable. We ran a script comparing every bare tag usage against explicit imports.

Two more turned up. One was an error alert on the signup page; the other was a consent dialog — silently not rendering, by the same pattern. Like the QR, nobody had reported either. Things that don’t appear don’t raise errors.

Each fix was one explicit import line. Days to find the cause, three lines to fix. That ratio is typical for this class of bug.

We added a defensive layer too: the QR component’s silent catch became a log plus an error emit, and on failure it now falls back to showing the referral code as text. If the QR doesn’t render, the user can still do the thing.

The follow-up is a static scan in CI that catches bare component usage — moving from people remembering a convention to a machine catching the deviation.

Takeaways

The most dangerous code isn’t code that’s wrong. It’s code nobody has ever checked for being wrong.


Authorship & citation: Written by Ascendy Engineering; quotable with attribution. Found something wrong? Let us know via a GitHub issue.


Tags: nuxt, vue, debugging, auto-import, silent-failure, postmortem, bundle-analysis