Skip to content
Development

The normalization bug that only shows up on tags made of nothing

By Victor Da Luz
railsrubydev-logblog-manager

The last piece of blog-manager’s editor cluster: a chip-style tag input for the post editor, with autocomplete pulled from every tag already used on the blog. Type “home-lab,” get “homelab” suggested, pick it, done. Simple in concept.

The mechanism I built for that “home-lab means homelab” matching is a normalize function - strip everything except lowercase letters and digits, then compare. In Ruby: name.to_s.downcase.gsub(/[^a-z0-9]/, ""). Same idea hand-ported to JS for the client-side autocomplete, since this repo has no bundler and no shared module boundary between server and browser. I even left a comment on the JS version saying “must match Post.normalize_tag” - which, in hindsight, is exactly the kind of comment that should have made me suspicious. A comment enforcing an invariant instead of code enforcing it is a tell.

I built this the way I’d built the last few pieces of this cluster: read the existing code first (there’s already a Post.tag_counts from the tag operations work), validate the new logic against something concrete, write the UI, ship it. A review pass caught one real design question before I wrote any JS: my first instinct was to have the server re-canonicalize the full tags array on every save, so casing stayed consistent everywhere. The pushback: that would silently rewrite tags nobody touched - edit the post title, and an untouched tag’s casing could flip because some other post now has more instances of a different spelling. That’s real, so I scoped canonicalization to only the tag you’re actively adding, never the ones already sitting there. Good catch, correct fix, moved on.

What I didn’t think about: what happens when someone tags a post with something that’s all punctuation. Like literally ”!!!” as a tag. Nothing in my code stops that - "!!!".trim() is truthy, so it sails right through the “is this empty” check. And "!!!".downcase.gsub(/[^a-z0-9]/, "") is "". Empty string.

Here’s where it gets interesting: an empty string is a perfectly valid hash key. So if a blog somehow ends up with two different all-punctuation tags - ”!!!” and ”???”, say - my canonical_tags method groups them under the same key ("") and keeps only one, silently dropping the other from the known-tags list. Worse, if a user then types a punctuation-only string that matches an existing all-punctuation tag’s empty key, my “resolve to canonical casing” logic would confidently swap in the wrong tag entirely - a tag they never typed, silently substituted for the one they did. And on top of that: JavaScript’s String.prototype.includes("") returns true for literally any string, so a punctuation-only search query would match and display every single known tag as a “suggestion,” defeating the entire point of ranked autocomplete.

Three distinct symptoms, one root cause, and the root cause is the exact same category of thing every time a strip-based normalizer meets an adversarial-ish input: what happens at the point where normalization empties the value out entirely? I hadn’t asked that question while writing the feature, because every tag I tested with - homelab, proxmox, self-hosted - has letters in it. The bug only exists for the class of input nobody naturally types while testing their own feature.

The multi-angle code review caught this cleanly. Three separate finder angles converged on variations of the same observation, and the verification pass confirmed all three symptoms were genuinely reachable through the actual UI, not theoretical. The fix ended up small: fall back to the raw tag name as the grouping key whenever normalization produces an empty string, and add an explicit empty-key bailout before the JS match/suggestion logic runs. A few lines. The bug was expensive to find, cheap to fix - which is usually how these go.

Other things the review turned up that were worth fixing: pasting a comma-separated list into the new chip input added the whole pasted string as one malformed tag instead of splitting it, which was a straight regression against the plain text field this UI replaced (I hadn’t thought to test paste at all, only typing). Keyboard nav had an off-by-one - pressing ArrowUp before ever pressing ArrowDown skipped the last suggestion in the list, because the “no selection yet” state (-1) doesn’t behave the same under modular arithmetic as an in-range selection does. And a small one I liked catching: I’d already written a normalization regex for Dev.to’s tag sanitization in an earlier issue, and here I was, writing the identical regex again in a different file. Two independent implementations of the same rule, with zero enforcement that they’d stay in sync. Consolidated into one call.

What I didn’t fix: the hidden input that carries the tag array between my new Stimulus controller and the existing autosave controller has no server-rendered fallback value, unlike every other field in that form. If the JS controller ever failed to connect, that field would stay blank, and the next autosave would quietly wipe a post’s tags. Both my own reasoning and an independent verification pass concluded this isn’t reachable under any normal-operation path in the code as it ships today - it would require Stimulus itself failing to register a controller, which would already break several other things on the same page. I documented it in the PR instead of guarding against a failure mode that doesn’t have a path to occur, since this repo’s own convention is not to write defensive code for things that can’t happen.

That closes out the editor cluster that’s been the bulk of my recent blog-manager work. Next up is probably translation mode, which was explicitly waiting on this and the commit flow before it.

Related reading

Development

The editor commit button is a deploy button

Committing a draft to main auto-deploys the blog. Once that clicked, sync-vs-async stopped being a style question - plus the legacy-affiliate carve-out a new validator almost broke.

Read