<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: greymoth</title>
    <description>The latest articles on DEV Community by greymoth (@greymothjp).</description>
    <link>https://dev.to/greymothjp</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3937147%2F66fce836-aa25-43f0-bb5f-632fc17ebf44.jpeg</url>
      <title>DEV Community: greymoth</title>
      <link>https://dev.to/greymothjp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/greymothjp"/>
    <language>en</language>
    <item>
      <title>ime bugs, display-width bugs, and bidi bugs are the same bug</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Sat, 11 Jul 2026 01:29:24 +0000</pubDate>
      <link>https://dev.to/greymothjp/ime-bugs-display-width-bugs-and-bidi-bugs-are-the-same-bug-45g4</link>
      <guid>https://dev.to/greymothjp/ime-bugs-display-width-bugs-and-bidi-bugs-are-the-same-bug-45g4</guid>
      <description>&lt;p&gt;Payload CMS has a search box in its admin UI. Type a query in Japanese, press Enter to confirm the kanji your IME just converted, and the search fires on the partial string that was still mid-conversion, not the query you meant to type. The Enter that closes an IME conversion and the Enter that submits a form are, to the browser, the exact same keydown event. Nothing in &lt;code&gt;SearchInput&lt;/code&gt;'s handler checked which one it was looking at.&lt;/p&gt;

&lt;p&gt;That's &lt;a href="https://github.com/payloadcms/payload/pull/17138" rel="noopener noreferrer"&gt;payloadcms/payload#17138&lt;/a&gt;, one line: skip the handler when &lt;code&gt;event.nativeEvent?.isComposing&lt;/code&gt; is true. I've filed close to identical fixes in dozens of other projects. Not similar bugs. The identical bug, in code that has never seen the others.&lt;/p&gt;

&lt;p&gt;I keep a corpus of these. Right now it holds 129 documented bugs across 120 open-source libraries. 115 of those are pull requests I opened myself, 65 already merged; the other 14 cite an existing report someone else filed. Every entry links to a real GitHub PR or issue, and the build fails on any entry whose link isn't one. After sorting the categories for a while, the 129 collapse into three broken assumptions, not twelve.&lt;/p&gt;

&lt;h2&gt;
  
  
  a keystroke is a submit
&lt;/h2&gt;

&lt;p&gt;This is the Payload bug, and it's the largest bucket by a wide margin: 40 of the 129 entries, 31 percent, more than any other single category in the corpus. It shows up in React, Vue, Svelte, Angular, and every headless component library that lets you bind a handler to Enter. &lt;a href="https://github.com/CopilotKit/CopilotKit/pull/5764" rel="noopener noreferrer"&gt;CopilotKit/CopilotKit#5764&lt;/a&gt; has the same shape in an Angular chat widget: the composer submitted the message on the Enter that confirms an IME composition instead of waiting for the real one. Different framework, different UI, same missing &lt;code&gt;isComposing&lt;/code&gt; check. The fix is always one line. The bug survives review because most contributors, and every CI runner, type in a language where a keystroke and a finished character are the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  a character is a byte
&lt;/h2&gt;

&lt;p&gt;ratatui's barchart centers a value label by measuring its display width, correctly, in the sibling function right next to it, and by its UTF-8 byte length in this one. &lt;code&gt;"中文"&lt;/code&gt; is 4 columns wide on a terminal but 6 bytes long, so the centering math undershoots and the label lands left of where it should sit (&lt;a href="https://github.com/ratatui/ratatui/pull/2625" rel="noopener noreferrer"&gt;ratatui/ratatui#2625&lt;/a&gt;). A CJK character isn't one byte, isn't always two bytes, and isn't always one terminal column either, and code that treats those as interchangeable drifts the moment real text shows up.&lt;/p&gt;

&lt;p&gt;pdf.js has a version of the same mistake one layer down. &lt;code&gt;Font.prototype.encodeString&lt;/code&gt; silently drops the character that follows U+FFFE or U+FFFF when saving or printing a PDF, because its surrogate-pair guard treats those two single code units as if they started a surrogate pair, then swallows the next character as the pair's second half (&lt;a href="https://github.com/mozilla/pdf.js/pull/21538" rel="noopener noreferrer"&gt;mozilla/pdf.js#21538&lt;/a&gt;). The fix is a tighter range check. It's the same family as ratatui's bug: code written against "how many units is this" instead of "what does this actually represent."&lt;/p&gt;

&lt;h2&gt;
  
  
  text is left-to-right
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;out-of-character&lt;/code&gt; exists specifically to catch invisible and abusable Unicode. It already stripped the implicit bidi marks (U+200E, U+200F, U+061C), and let the explicit bidi embedding, override, and isolate controls (U+202A-U+202E, U+2066-U+2069) pass straight through untouched (&lt;a href="https://github.com/spencermountain/out-of-character/pull/90" rel="noopener noreferrer"&gt;spencermountain/out-of-character#90&lt;/a&gt;). Those are the characters behind Trojan Source (CVE-2021-42574): reorder how a line renders without changing a single byte of what's actually there. A library built to catch this class of character missed nine of the code points in its own job description.&lt;/p&gt;

&lt;p&gt;bangumi/server-private had the narrower version of the same gap: its printable-character check matched the older bidi marks but not the directional isolates U+2066-U+2069, so a string made entirely of isolate controls passed validation as ordinary text (&lt;a href="https://github.com/bangumi/server-private/pull/1700" rel="noopener noreferrer"&gt;bangumi/server-private#1700&lt;/a&gt;). Bidi and control characters are one of the smaller categories in the corpus, 4 of 129, but it's the one where "harmless-looking bug" and "supply-chain attack vector" turn out to be the same finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  using the corpus
&lt;/h2&gt;

&lt;p&gt;Everything above comes straight out of &lt;a href="https://github.com/greymoth-jp/cjk-failure-corpus/blob/main/data/corpus.json" rel="noopener noreferrer"&gt;&lt;code&gt;data/corpus.json&lt;/code&gt;&lt;/a&gt;: one object per bug, with the category, the symptom, a minimal repro, and the fix, checked against the live GitHub API so a closed or reverted PR can't sit there uncorrected. A companion repo, &lt;a href="https://github.com/greymoth-jp/cjk-agent-fixtures" rel="noopener noreferrer"&gt;cjk-agent-fixtures&lt;/a&gt;, turns the repros into CI fixtures, so a regression on any of these three assumptions gets caught before it ships again instead of waiting for someone with a Japanese keyboard to notice by hand.&lt;/p&gt;

&lt;p&gt;None of the twelve categories in the corpus are actually twelve problems. They're three, wearing whatever framework or language happened to be lying around. If your test suite only ever types in English, at least one of them is sitting in your codebase right now. The corpus is the fastest way to check which.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/greymoth-jp/cjk-failure-corpus" rel="noopener noreferrer"&gt;github.com/greymoth-jp/cjk-failure-corpus&lt;/a&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>frontend</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Output is cheap now. Keep the receipts.</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Thu, 09 Jul 2026 20:00:39 +0000</pubDate>
      <link>https://dev.to/greymothjp/output-is-cheap-now-keep-the-receipts-4gfl</link>
      <guid>https://dev.to/greymothjp/output-is-cheap-now-keep-the-receipts-4gfl</guid>
      <description>&lt;p&gt;Caveat first, because most posts like this bury it and I'd rather lead with the part that's actually honest. What I'm building can prove a process happened. It cannot prove a human did the thinking. If you automate the steps, or paste an LLM's answer through them, the record still fills up. So this is not "proof of human." It's proof of process. If that distinction doesn't matter to you, you can close the tab now and we're still friends.&lt;/p&gt;

&lt;p&gt;Here's the thing that's been bugging me for months.&lt;/p&gt;

&lt;p&gt;A year ago, if someone handed you a tight decision memo — three options weighed, one picked, the reasons written down — the artifact itself was evidence. Producing it cost judgment and time, so having it meant someone spent both. That link is gone. Anyone can generate a plausible version of that memo, or a clean PR description, or a crisp design doc, in about nine seconds. The output stopped being proof of anything.&lt;/p&gt;

&lt;p&gt;So what's actually scarce now? Not the answer. The trail to it. What you decided, what you rejected, when, and what happened after reality pushed back on the call. That part AI can't hand you, because it doesn't have your context, your constraints, or your consequences. The decision-to-outcome loop is yours. The problem is almost nobody records it, so it evaporates. You end up with the polished final thing and no memory of the reasoning that got you there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this got urgent
&lt;/h2&gt;

&lt;p&gt;We're drowning in plausible output, and we're starting not to trust any of it. Reviewers can't tell what a person reasoned through from what got autocompleted. The main response so far has been detectors, which are a losing arms race — every detector gets beaten, and worse, they flag careful human writing as fake. I think the more honest move isn't to detect the fake. It's to let the real work keep a receipt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm building
&lt;/h2&gt;

&lt;p&gt;Working name Glovrex. The short version: you record decisions as you make them. What you chose, the options you killed, the reason. Then it links each decision to what actually happened later — the outcome, not just the intention. The record is tamper-evident, so you can't quietly backdate a call to look smart after the fact, and neither can anyone reading it.&lt;/p&gt;

&lt;p&gt;What comes out the other end is a portable log. "On this date I decided X over Y and Z, for these reasons, and here's how it aged." A receipt for your own judgment. It's useful to you, because your past self is a stranger and this is how you audit whether your reasoning was any good or you just got lucky. And it's useful to show other people, because a track record beats the polished final artifact that everyone has learned to distrust. The version of this I already trust most is boring: a &lt;a href="https://github.com/greymoth-jp" rel="noopener noreferrer"&gt;GitHub profile full of other people's merged PRs&lt;/a&gt;. Nobody can generate that one for you.&lt;/p&gt;

&lt;p&gt;That's the visible value. I'm going to stay quiet on how it decides what to keep and surface. That's the part I'm still building and the part that's mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limit
&lt;/h2&gt;

&lt;p&gt;Back to the caveat, because it's load-bearing. Provenance proves the process ran: these steps, at these times, in this order, unaltered since. It does not prove the quality or the humanity of the thinking inside. Recording a decision doesn't make it a good decision. And a determined faker can perform the whole ritual with a bot.&lt;/p&gt;

&lt;p&gt;What tamper-evidence actually buys you is narrower and more real: the record can't be silently rewritten later. The timeline is honest even when the thinking wasn't. That's a much smaller claim than "verified human work," and I'd rather ship the smaller true claim than the bigger false one. If I ever start selling this as proof a human did the cognitive labor, call me on it.&lt;/p&gt;

&lt;p&gt;One more thing worth saying plainly. This sits on top of LLMs, not against them. I use them all day. The point isn't "AI bad." It's that when generation is free, the generated thing carries less information, and the trail around it carries more. Glovrex is a layer for the trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is
&lt;/h2&gt;

&lt;p&gt;Pre-launch. No signup wall to shove at you, nothing "revolutionary," no metrics I haven't earned. I'm writing this partly to think out loud and partly to find the people who already feel the problem — engineers, researchers, anyone whose real value is their judgment over time, watching that judgment get harder to prove as the output around it turns to noise.&lt;/p&gt;

&lt;p&gt;If that's you, here's the disagreement I actually want: where does "proof of process" stop being useful and start being theater? That's the question I don't have fully answered yet, and it's the one that decides whether this is worth building.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>career</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Teaching a grader the difference between pаypаl and paypal</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Sat, 04 Jul 2026 18:36:48 +0000</pubDate>
      <link>https://dev.to/greymothjp/teaching-a-grader-the-difference-between-paypal-and-paypal-21pi</link>
      <guid>https://dev.to/greymothjp/teaching-a-grader-the-difference-between-paypal-and-paypal-21pi</guid>
      <description>&lt;p&gt;Look at these two strings: &lt;code&gt;paypal&lt;/code&gt; and &lt;code&gt;pаypаl&lt;/code&gt;. In most fonts they render the same. The second one has two Cyrillic а characters standing in for Latin a. A person can't reliably tell them apart on sight, and a plain &lt;code&gt;==&lt;/code&gt; comparison can't tell them apart at all unless it's checking code points, not glyphs.&lt;/p&gt;

&lt;p&gt;That pair is one of 72 test cases in something I finished today: a grader for the kind of text bug that's obvious once you see it and invisible until then. It's built against Prime Intellect's Environments Hub, which collects RL environments and evals that AI labs train and test models against. This one grades text correctness specifically — nothing about the pretty stuff, just: is this string handled right.&lt;/p&gt;

&lt;p&gt;I've spent about a year finding these bugs by hand, in real repos, as pull requests. 115 of mine are merged upstream as of this morning (misskey, strapi, MUI, Vue Router, Wails, Tencent's tdesign, and a long tail of smaller ones — zero self-merged, &lt;code&gt;github.com/greymoth-jp&lt;/code&gt; if you want to check). Most of what I found reduces to a short list of repeating shapes: an Enter key that submits a form mid-IME-conversion, a &lt;code&gt;.length&lt;/code&gt; check that splits a kanji in half, a locale file that silently drifts out of date behind the English source. I keep the CJK/Unicode ones in a public corpus, &lt;a href="https://github.com/greymoth-jp/cjk-failure-corpus" rel="noopener noreferrer"&gt;cjk-failure-corpus&lt;/a&gt; — 97 of them now, each linked to a real PR or issue, not written from memory.&lt;/p&gt;

&lt;p&gt;At some point the question stopped being "can I find one more of these" and started being "can a program judge whether an answer is correct, the way I've been judging them by hand." That's what a grader is. Building one turned out to be a different skill from finding bugs — but the same underlying judgment, made explicit and checkable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three families, 72 cases, no stored answers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;tokenization-length&lt;/code&gt;&lt;/strong&gt; (26 cases). Given a string, report its length three different ways: grapheme clusters (what a person sees), Unicode code points, and UTF-16 code units, and flag whether a naive count would get it wrong. &lt;code&gt;𠮷野家&lt;/code&gt; — the kanji Yoshinoya prints on its own storefront sign — is one code point and two UTF-16 units. A plain &lt;code&gt;.length&lt;/code&gt; in JavaScript reports 2 for a string a human reads as one character.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;encoding-injection&lt;/code&gt;&lt;/strong&gt; (30 cases — the paypal one lives here). Decide whether a string hides something: a homoglyph swap, an invisible character splicing a token in two, a bidirectional-override character (&lt;code&gt;U+202E&lt;/code&gt;, the mechanism behind Trojan Source, publicly tracked as CVE-2021-42574) that can make a file actually named &lt;code&gt;txt.exe&lt;/code&gt; display as &lt;code&gt;exe.txt&lt;/code&gt;, or a fullwidth character that survives Unicode normalization into something dangerous (&lt;code&gt;ｄｅｌｅｔｅ&lt;/code&gt; normalizes to &lt;code&gt;delete&lt;/code&gt;). Every positive case is paired with a negative one — a legitimate Japanese or Korean string, or a real emoji sequence — so a detector that just flags every non-ASCII string scores no better than chance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;rendering-output&lt;/code&gt;&lt;/strong&gt; (16 cases). Text that's fine going in and corrupted coming out: UTF-8 misread as Latin-1 leaves control characters that never occur in real text, or a Private-Use-Area code point that most default font stacks render as a blank box.&lt;/p&gt;

&lt;p&gt;The part I care about more than the bug list: nothing in the grader is a stored answer key. Every oracle is re-derived at grading time, in Python — &lt;code&gt;len()&lt;/code&gt; for code points, &lt;code&gt;.encode('utf-16-le')&lt;/code&gt; for UTF-16 units, the &lt;code&gt;grapheme&lt;/code&gt; package for clusters, a small reference scanner for the injection checks. A correct answer scores 1.0 across every class. A naive baseline — roughly the level of check most real code actually ships — scores 0.43 on average. That gap is the training signal.&lt;/p&gt;

&lt;p&gt;I deliberately left two things out. The source corpus has a couple of checks (&lt;code&gt;pangu&lt;/code&gt; spacing, &lt;code&gt;budoux&lt;/code&gt; line-breaking) whose ground truth comes from a JavaScript library. Porting that to Python would mean trusting a second implementation to stay byte-identical to the first forever, which breaks the entire "re-derive, don't store" premise — so I cut them instead of faking the confidence. Two Indic-conjunct cases got cut for the same reason: the Python grapheme library predates the current Unicode rule for that script (UAX #29 GB9c) and would grade them wrong on purpose. Scoping something out because you can't verify it honestly is a different move from shipping it and hoping nobody checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I don't know yet
&lt;/h2&gt;

&lt;p&gt;Whether this earns anything is untested. Prime Intellect runs a funded program for environment submissions — real money, open-tier bounties in the low hundreds and an approval-gated tier in the low thousands — but every credited contributor listed so far is an org (Arcee AI, Hud.so, Groq), not a solo account with no prior relationship. Publishing is one command. Getting paid, or even reviewed, by people who've never seen my name before is the actual experiment here, not this post.&lt;/p&gt;

&lt;p&gt;What I do know: the corpus that took a year of manual, unglamorous bug-hunting to build turned out to be exactly the training data a grader like this needs. That wasn't the plan when I started filing PRs into strangers' repos for no clearer reason than "this is wrong and I can fix it." It's the kind of connection you only notice once you've done enough of the boring version by hand.&lt;/p&gt;

</description>
      <category>unicode</category>
      <category>i18n</category>
      <category>opensource</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>a width check said the string was safe to cut. it split a kanji in half.</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Fri, 03 Jul 2026 20:41:00 +0000</pubDate>
      <link>https://dev.to/greymothjp/a-width-check-said-the-string-was-safe-to-cut-it-split-a-kanji-in-half-4hjk</link>
      <guid>https://dev.to/greymothjp/a-width-check-said-the-string-was-safe-to-cut-it-split-a-kanji-in-half-4hjk</guid>
      <description>&lt;p&gt;a name went into a terminal table and came out broken. the surname was 𠮷田. that first character is not the ordinary 吉 you get from the 吉 key, it is 𠮷 (U+20BB7), a rarer form that real people in Japan actually have on their family register. the table truncated the cell to fit a column, and what printed was 𠮷 followed by a replacement character. the kanji had been cut in half.&lt;/p&gt;

&lt;p&gt;the interesting part is where the bug lived. not in the truncation loop. in a one-line shortcut that decided, before truncating, that this particular string was safe to cut by raw index. it was wrong, and it was wrong for a reason that only shows up on the exact character I just described.&lt;/p&gt;

&lt;h2&gt;
  
  
  three numbers that are usually the same, and one string where they aren't
&lt;/h2&gt;

&lt;p&gt;a JavaScript string has more than one length depending on what you ask.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;"𠮷".length&lt;/code&gt; is &lt;code&gt;2&lt;/code&gt;. &lt;code&gt;.length&lt;/code&gt; counts UTF-16 code units, and 𠮷 lives outside the Basic Multilingual Plane, so it is stored as a surrogate pair: two code units, &lt;code&gt;𠮷&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;its code-point count is &lt;code&gt;1&lt;/code&gt;. &lt;code&gt;[..."𠮷"].length&lt;/code&gt; is &lt;code&gt;1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;its display width, the number of terminal columns it occupies, is &lt;code&gt;2&lt;/code&gt;. it is an East Asian wide character.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;for plain ASCII these all collapse to the same number. &lt;code&gt;"abc"&lt;/code&gt; is 3 code units, 3 code points, 3 columns. that coincidence is what a lot of text code quietly leans on. it holds right up until a character makes two of those numbers agree for different reasons.&lt;/p&gt;

&lt;p&gt;𠮷 is exactly that character. two code units because it is a surrogate pair. two columns because it is wide. same number, &lt;code&gt;2&lt;/code&gt;, arrived at two completely different ways. hold onto that, it is the whole bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  the real code
&lt;/h2&gt;

&lt;p&gt;this is the truncation helper in cli-table3, the library a lot of CLIs use to draw tables. &lt;code&gt;strlen&lt;/code&gt; here is display width. it strips ANSI color codes and runs the string through &lt;code&gt;string-width&lt;/code&gt;, which counts a wide CJK character as 2. so &lt;code&gt;strlen&lt;/code&gt; answers "how many columns," not "how many characters."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;truncateWidth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;desiredLength&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;desiredLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;desiredLength&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;read the first branch as an optimization. "if the code-unit length equals the display width, then every character is one unit and one column, so there are no wide characters and nothing tricky, I can just cut by index with &lt;code&gt;substr&lt;/code&gt;." for &lt;code&gt;"abc"&lt;/code&gt; that is true, &lt;code&gt;3 === 3&lt;/code&gt;, cut away.&lt;/p&gt;

&lt;p&gt;now feed it &lt;code&gt;"𠮷𠮷"&lt;/code&gt;. code-unit length is &lt;code&gt;4&lt;/code&gt;. display width is &lt;code&gt;4&lt;/code&gt;. &lt;code&gt;4 === 4&lt;/code&gt;, so the branch fires and it cuts by code unit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;𠮷𠮷&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// "𠮷" + "\uD842"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;substr(0, 3)&lt;/code&gt; takes three code units: the full first 𠮷, then the high surrogate of the second one. the low surrogate is left behind. you get one clean kanji followed by a lone high surrogate &lt;code&gt;\uD842&lt;/code&gt;, which is not a character at all. terminals render it as the replacement box. that is the half a kanji in the table cell.&lt;/p&gt;

&lt;p&gt;the shortcut was built for the case where length equals width because everything is one-to-one. a surrogate-pair wide character satisfies &lt;code&gt;length === width&lt;/code&gt; too, &lt;code&gt;2 === 2&lt;/code&gt;, but for the opposite reason, both numbers are 2 because the character is doubled on both axes. it walks straight into the fast path and gets sliced by index, which is the one thing that path assumed it would never have to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  why it survived
&lt;/h2&gt;

&lt;p&gt;the obvious question is how a CJK bug survives in a table library that people clearly use with CJK. the answer is that ordinary Japanese and Chinese text never reaches this branch.&lt;/p&gt;

&lt;p&gt;take 漢. it is &lt;code&gt;U+6F22&lt;/code&gt;, inside the BMP, so &lt;code&gt;"漢".length&lt;/code&gt; is &lt;code&gt;1&lt;/code&gt;. its width is &lt;code&gt;2&lt;/code&gt;. &lt;code&gt;1 === 2&lt;/code&gt; is false, so 漢 skips the fast path entirely and goes to the &lt;code&gt;while&lt;/code&gt; loop below. every common kanji, every kana, every Hangul syllable behaves this way: one code unit, two columns, length never equals width. they are all safe.&lt;/p&gt;

&lt;p&gt;the fast path only misfires when a single character is a surrogate pair &lt;em&gt;and&lt;/em&gt; wide. that intersection is small. it is CJK Extension B and beyond, the rare kanji that show up in personal names and place names, plus emoji, which are also non-BMP and mostly width 2. so the library worked for years of 東京 and 漢字 and quietly mangled 𠮷田 and anything with an emoji in a narrow column. the common case took a different branch, so the shortcut looked safe.&lt;/p&gt;

&lt;p&gt;the slow path had a milder version of the same disease, by the way. &lt;code&gt;str.slice(0, -1)&lt;/code&gt; removes one code unit, not one character. hand the loop a string ending in a surrogate pair and it lops off a low surrogate on the first pass and leaves the high one dangling. same family, quieter symptom.&lt;/p&gt;

&lt;h2&gt;
  
  
  the fix
&lt;/h2&gt;

&lt;p&gt;two changes. guard the fast path so it refuses any string that contains a high surrogate, and make the slow path trim whole code points instead of code units.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;truncateWidth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;desiredLength&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// `str.length === strlen(str)` is also true for surrogate-pair characters&lt;/span&gt;
  &lt;span class="c1"&gt;// (e.g. CJK Extension B or emoji), which count as 2 code units and 2 columns.&lt;/span&gt;
  &lt;span class="c1"&gt;// `substr`/`slice` cut by code unit, so exclude them here and trim by code&lt;/span&gt;
  &lt;span class="c1"&gt;// point below to avoid splitting a surrogate pair into a lone surrogate.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;D800-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;DBFF&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;desiredLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;desiredLength&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Array.from(str)&lt;/code&gt; iterates by code point, so &lt;code&gt;Array.from("𠮷𠮷")&lt;/code&gt; is a two-element array, each element a whole kanji. &lt;code&gt;pop()&lt;/code&gt; removes one whole character. the loop can no longer stop in the middle of a surrogate pair because there is no middle to stop in. the fast path stays for the genuinely simple case, ASCII and other strings with no surrogates, where &lt;code&gt;substr&lt;/code&gt; is both correct and cheaper.&lt;/p&gt;

&lt;p&gt;worth naming the tools. &lt;code&gt;Array.from&lt;/code&gt; and the spread operator both split by code point, which fixes surrogate pairs. they do not split by grapheme, so a flag emoji or a family emoji built from several code points joined with zero-width joiners will still come apart. if you need whole user-perceived characters, that is &lt;code&gt;Intl.Segmenter&lt;/code&gt; with &lt;code&gt;granularity: 'grapheme'&lt;/code&gt;. code point was the right level here because the unit of width is the code point, but know which one you are reaching for.&lt;/p&gt;

&lt;h2&gt;
  
  
  the failing fixture
&lt;/h2&gt;

&lt;p&gt;this is the test that goes red before the fix and green after. it is the whole point, because the fix is one line and the value is keeping it fixed, not finding it once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;does not split a surrogate-pair wide char (CJK Ext B)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;kanji&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCodePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x20bb7&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// 𠮷&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;truncate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;kanji&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;kanji&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;…&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;truncate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;kanji&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a…&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;truncate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kanji&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;kanji&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kanji&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;…&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;does not split a surrogate-pair wide char (emoji)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;emoji&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCodePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x1f600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;truncate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;emoji&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a…&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;truncate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;emoji&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;emoji&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;y&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;emoji&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;…&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;note the inputs are built with &lt;code&gt;String.fromCodePoint&lt;/code&gt;, not pasted glyphs. that keeps the test readable in any editor and makes the code point explicit, so nobody later "cleans up" 𠮷 into 吉 and deletes the coverage without noticing. the assertion that matters most is &lt;code&gt;truncate(kanji + kanji, 3)&lt;/code&gt;: a width budget that lands between the two columns of the second character. the old code returned a lone surrogate there. that is the exact spot the bug lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  the check, for the next one
&lt;/h2&gt;

&lt;p&gt;the general shape is bigger than one library. any code that truncates, pads, aligns, or measures text is juggling three different numbers for one string, and it is only correct if it uses the same one throughout:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;string&lt;/th&gt;
&lt;th&gt;code units (&lt;code&gt;.length&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;code points&lt;/th&gt;
&lt;th&gt;display columns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;abc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;漢字&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;𠮷&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;😀&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;the failure mode is always the same: measure by one number, cut by another. cli-table3 measured width, then cut by code unit, and the two disagreed on the one character where they happened to be equal for different reasons. so the check is a habit, not a rule. when you slice a string with &lt;code&gt;substr&lt;/code&gt;, &lt;code&gt;slice&lt;/code&gt;, or a bare index, ask what unit that index is in. it is code units. then ask whether the length you compared it against was in the same unit. if you measured display width or code points and then cut by index, you have this bug, and it is invisible until a non-BMP character walks through.&lt;/p&gt;

&lt;p&gt;and test it deliberately. one CJK Extension B character, &lt;code&gt;String.fromCodePoint(0x20bb7)&lt;/code&gt;, and one emoji, at a width that lands mid-character. ASCII will never show you this. you have to hand the function the input it is quietly afraid of.&lt;/p&gt;

&lt;p&gt;this one is a single entry in a corpus of 97 real CJK, IME, and Unicode failures I have been collecting, most of them one-line fixes hiding in libraries that work perfectly in English. the same split-a-code-point shape shows up in opentype.js clamping cmap character codes (open), in slate keeping Indic conjuncts together (open), and in web UI truncation and a markdown smart-quotes pass where I filed the same fix and it did not land (clerk and markdown-it, both closed). the corpus and a runnable fixture suite in JS and Go are linked below. don't take my word for the diagnosis, the cli-table3 diff is public, read it and decide if it holds.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;corpus: &lt;a href="https://greymoth-jp.github.io/cjk-failure-corpus/" rel="noopener noreferrer"&gt;https://greymoth-jp.github.io/cjk-failure-corpus/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;fixtures (JS + Go): &lt;a href="https://github.com/greymoth-jp/cjk-agent-fixtures" rel="noopener noreferrer"&gt;https://github.com/greymoth-jp/cjk-agent-fixtures&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;the fix in this post: &lt;a href="https://github.com/cli-table/cli-table3/pull/360" rel="noopener noreferrer"&gt;https://github.com/cli-table/cli-table3/pull/360&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;— greymoth (@greymoth__)&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>unicode</category>
      <category>i18n</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Enter key that submits your form while a Japanese user is still typing</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Thu, 02 Jul 2026 21:05:57 +0000</pubDate>
      <link>https://dev.to/greymothjp/the-enter-key-that-submits-your-form-while-a-japanese-user-is-still-typing-4h6f</link>
      <guid>https://dev.to/greymothjp/the-enter-key-that-submits-your-form-while-a-japanese-user-is-still-typing-4h6f</guid>
      <description>&lt;p&gt;Here's the whole lesson up front, so you can leave after one paragraph if you want:&lt;/p&gt;

&lt;p&gt;If your text field submits on Enter, it almost certainly submits on the Enter a Japanese, Chinese, or Korean user presses to &lt;em&gt;confirm&lt;/em&gt; a word. That Enter isn't "send." It's "yes, that kanji." Your handler can't tell the difference unless you check one flag, and your English test suite will pass green forever while this ships. The flag is &lt;code&gt;event.isComposing&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's it. The rest is why it happens, why CI is blind to it, and a free way to pin it so it doesn't crawl back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens
&lt;/h2&gt;

&lt;p&gt;Japanese, Chinese, and Korean don't map one key to one character. You type a phonetic guess, the IME shows candidates, and you press Enter (or Space, then Enter) to pick one. That confirming Enter fires a &lt;code&gt;keydown&lt;/code&gt; with &lt;code&gt;key: "Enter"&lt;/code&gt;, same as any other. If your submit handler only looks at &lt;code&gt;key&lt;/code&gt;, it fires. The user was mid-word. Their first attempt is gone.&lt;/p&gt;

&lt;p&gt;The tell is that it eats the &lt;em&gt;first&lt;/em&gt; one. A Japanese user types a message, hits Enter to confirm the conversion, and the form submits with half a sentence, or the tag commits early, or the command palette runs the highlighted command. They learn to type, confirm somewhere else, then paste. That's the workaround real users invent for your bug.&lt;/p&gt;

&lt;p&gt;I hit this in a Vue library, &lt;code&gt;naive-ui&lt;/code&gt;. Its &lt;code&gt;n-dynamic-tags&lt;/code&gt; committed a tag on the Enter that confirmed an IME conversion, so you couldn't type a multi-character CJK tag without it splitting early. The fix that got &lt;a href="https://github.com/tusen-ai/naive-ui/pull/8115" rel="noopener noreferrer"&gt;merged&lt;/a&gt; is small on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// inside the Enter handler&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputInstRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;isCompositing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Guard the handler while composition is active, and the confirming Enter does nothing. The real Enter, the one after &lt;code&gt;compositionend&lt;/code&gt;, still commits. Twenty-nine lines including the changelog and the test. The bug had been there a while; nobody typing in English would ever meet it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your CI never sees it
&lt;/h2&gt;

&lt;p&gt;This is the part that matters for anyone shipping to a global audience. You don't reproduce this by reading the code. You reproduce it by having an IME on and composing a word. Nobody on the review is typing &lt;code&gt;日本語&lt;/code&gt; into the field. So the diff looks fine, the tests are green, and the regression ships.&lt;/p&gt;

&lt;p&gt;The portable guard, if you're not in a framework that wraps it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isComposing&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;229&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="c1"&gt;// IME is mid-composition&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;e.isComposing&lt;/code&gt; is true between &lt;code&gt;compositionstart&lt;/code&gt; and &lt;code&gt;compositionend&lt;/code&gt;. &lt;code&gt;keyCode === 229&lt;/code&gt; is the legacy signal for the same state and still shows up on older Safari and some Android keyboards. In React you read it off &lt;code&gt;e.nativeEvent.isComposing&lt;/code&gt;, because the synthetic event doesn't always carry it. Frameworks differ in the spelling; the idea is identical.&lt;/p&gt;

&lt;p&gt;So the fix is trivial. The problem is that "fix it once" and "keep it fixed" are different jobs. There's no lint rule that reliably flags "this Enter handler forgot about composition," and the next refactor that touches the handler can drop the guard, and again, no English-only test goes red. It comes back within a release or two. I've watched it come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pinning it so it can't come back
&lt;/h2&gt;

&lt;p&gt;The only thing that keeps this dead is a test that composes a word and asserts the submit &lt;em&gt;didn't&lt;/em&gt; fire. That's a specific, slightly annoying test to write, and it's the same test every project needs, which is exactly the kind of thing worth sharing instead of everyone re-deriving it.&lt;/p&gt;

&lt;p&gt;So I put the cases in a small MIT package: &lt;a href="https://github.com/greymoth-jp/cjk-agent-fixtures" rel="noopener noreferrer"&gt;&lt;code&gt;@greymoth/cjk-agent-fixtures&lt;/code&gt;&lt;/a&gt;. It's a runnable regression fixture pack for eleven of these input bugs, in JavaScript (Vitest/Jest) and Go, standard library only. For the IME case it hands you the keyboard/composition event sequence and the correct result, and you replay it against your own handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;editorCases&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;applyEvents&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@greymoth/cjk-agent-fixtures&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createInput&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../src/text.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// your code&lt;/span&gt;

&lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;editorCases&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$slug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;correct&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;applyEvents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;createInput&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;submitted&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;correct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;submitted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// false during composition&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Be clear about what that is. It's &lt;strong&gt;not a scanner&lt;/strong&gt;. It doesn't read your bundle and guess whether you're vulnerable. You point it at your functions, it holds the inputs and the expected answers, and your CI goes red when your handler gets it wrong. Every case also carries the &lt;em&gt;wrong&lt;/em&gt; value a common broken handler returns, so you can confirm the test actually bites before you trust the green.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other ten, briefly
&lt;/h2&gt;

&lt;p&gt;The IME Enter is one of eleven, and they cluster into a few wrong assumptions about text. A quick sense of the neighbours, because if you have one you probably have three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A byte slice through &lt;code&gt;日本語&lt;/code&gt; (3 bytes per char) lands mid-character and prints &lt;code&gt;U+FFFD&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;str.length&lt;/code&gt; over-counts a rare kanji like &lt;code&gt;𠮷&lt;/code&gt; or any emoji, and a slice at an odd UTF-16 boundary leaves a lone surrogate.&lt;/li&gt;
&lt;li&gt;A field of only full-width spaces (&lt;code&gt;　　&lt;/code&gt;, U+3000, what the IME types on the space bar) passes your ASCII &lt;code&gt;.trim()&lt;/code&gt; "not empty" check.&lt;/li&gt;
&lt;li&gt;Half-width katakana &lt;code&gt;ﾊﾝｶｸ&lt;/code&gt; and &lt;code&gt;ハンカク&lt;/code&gt; compare unequal, so your "username already taken" check misses the collision.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same shape every time: code that was written assuming one character is one byte is one column in one encoding, meeting text where none of that holds. The full taxonomy and a receipt (a real PR) for each is in the &lt;a href="https://greymoth-jp.github.io/cjk-failure-corpus" rel="noopener noreferrer"&gt;corpus&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The IME guard has genuine edge cases. Some browsers keep &lt;code&gt;isComposing&lt;/code&gt; true after focus leaves mid-composition, so a naive guard can freeze the field until refocus. The fixtures cover that as a separate case (#5), but if you only copy the one-liner above you can trade one bug for another.&lt;/li&gt;
&lt;li&gt;Fixtures don't find your bug for you. If your Enter handler lives somewhere the cases can't reach without a five-line adapter, that's real work, not a drop-in.&lt;/li&gt;
&lt;li&gt;If your product genuinely has zero CJK/RTL/emoji users and never will, this is ceremony. I don't think that's most products shipping in 2026, but it's a real out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If one confirming-Enter test saves one Japanese user from losing their first message, it paid for itself. That's the entire pitch. No account, no signup, MIT, works offline.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>i18n</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Three ways CJK text breaks big open-source projects, over and over</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Thu, 02 Jul 2026 16:30:41 +0000</pubDate>
      <link>https://dev.to/greymothjp/three-ways-cjk-text-breaks-big-open-source-projects-over-and-over-14pi</link>
      <guid>https://dev.to/greymothjp/three-ways-cjk-text-breaks-big-open-source-projects-over-and-over-14pi</guid>
      <description>&lt;p&gt;I keep a small corpus of Japanese/CJK bugs I've found in open-source projects while sending fixes upstream. At some point I stopped looking at them as individual bugs and started looking at them as a small set of repeating shapes. Three of them show up constantly, in codebases with nothing else in common: a federated social network, a CRM, a component library, a commerce platform, a local-AI desktop app, a data-grid, a design system, a headless CMS. Different stacks, same failure.&lt;/p&gt;

&lt;p&gt;None of these are exotic. Each one is a real merged fix, and each one is boring enough that it passed code review and CI without anyone noticing, sometimes for years. That's the actual finding: these bugs aren't hard to fix once you see them. They're hard to &lt;em&gt;see&lt;/em&gt;, because the systems that would normally catch a regression, tests, linting, review, don't have Japanese input in them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: IME composition treated as a keystroke
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; Typing Japanese, Chinese, or Korean doesn't produce final characters one key at a time. You type romaji, an Input Method Editor shows a preedit string, and you press Enter to &lt;em&gt;confirm&lt;/em&gt; the conversion into kanji. That confirming Enter is the same physical key most web apps bind to "submit."&lt;/p&gt;

&lt;p&gt;If a keydown handler doesn't check composition state, the confirming Enter fires the handler mid-word: a chat message sends half-typed, a rename commits before the kanji conversion finished, a dropdown closes on the wrong item.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it's invisible.&lt;/strong&gt; It only happens with an IME switched on. Most contributors and most CI runners never turn one on. The input works perfectly for every test that types plain ASCII, which is nearly all of them. No exception is thrown, nothing fails a snapshot test, the bug just silently eats or mangles the user's keystroke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real example.&lt;/strong&gt; &lt;a href="https://github.com/misskey-dev/misskey/pull/17646" rel="noopener noreferrer"&gt;misskey-dev/misskey#17646&lt;/a&gt;, merged into a repo with over 11,000 stars: the chat composer's &lt;code&gt;onKeydown&lt;/code&gt; checked &lt;code&gt;ev.key === 'Enter'&lt;/code&gt; and sent the message, with no composition guard at all. Mid-conversion Enter sent a half-typed message. The fix is one line: &lt;code&gt;if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return;&lt;/code&gt; before the send logic runs.&lt;/p&gt;

&lt;p&gt;It's not a one-off oversight. &lt;a href="https://github.com/twentyhq/twenty/pull/22270" rel="noopener noreferrer"&gt;twentyhq/twenty#22270&lt;/a&gt;, a CRM with over 52,000 stars, had the identical gap in two unrelated components at once: the attachment-rename input and the AI chat-thread rename input. Same missing guard, same fix, two files, same PR. And &lt;a href="https://github.com/vuetifyjs/vuetify/pull/22974" rel="noopener noreferrer"&gt;vuetifyjs/vuetify#22974&lt;/a&gt;, a component library with over 41,000 stars, already &lt;em&gt;had&lt;/em&gt; a shared &lt;code&gt;isComposingIgnoreKey&lt;/code&gt; helper elsewhere in the codebase for exactly this problem. &lt;code&gt;VAutocomplete&lt;/code&gt;'s keydown handler just never called it. The knowledge existed one file over. It didn't reach this one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to catch it.&lt;/strong&gt; Switch your OS keyboard to a Japanese or Chinese IME. Type into every input that reacts to Enter or Escape, and watch what fires before you've confirmed the conversion. Or grep for &lt;code&gt;key === 'Enter'&lt;/code&gt; across your codebase and check each hit for a composition guard. The primary composer usually has one. Count how many of the smaller inputs next to it don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: locale files silently fall behind
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; A product gets translated into Japanese once, then the English source keeps shipping new strings. Every string added to &lt;code&gt;en.json&lt;/code&gt; after that point exists only in English until someone notices and backfills it. There's no build error, no lint rule, no CI check that a locale file has drifted, because a missing key isn't invalid JSON. It's just a hole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it's invisible.&lt;/strong&gt; The UI doesn't crash. i18next and most i18n libraries fall back to the English string (or the raw key) automatically. The product looks fully localized to anyone who isn't reading it in Japanese, including most of the team that shipped it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real example.&lt;/strong&gt; &lt;a href="https://github.com/medusajs/medusa/pull/15839" rel="noopener noreferrer"&gt;medusajs/medusa#15839&lt;/a&gt;, an e-commerce platform with roughly 34,900 stars: the admin dashboard's Japanese locale file was 511 keys behind English. Not mistranslated, just absent, across product options, inventory, order fulfillment, MFA settings, and permissions. Someone had done a full Japanese translation pass at some point; the product just kept growing past it.&lt;/p&gt;

&lt;p&gt;Jan, a local-AI desktop client with over 43,000 stars, showed the same drift spread across multiple namespaces rather than one. &lt;code&gt;settings.json&lt;/code&gt; alone was 69 keys short with 4 more still sitting in English (&lt;a href="https://github.com/janhq/jan/pull/8352" rel="noopener noreferrer"&gt;janhq/jan#8352&lt;/a&gt;), and &lt;code&gt;common.json&lt;/code&gt;, the namespace backing search, the providers panel, and toast messages, was 109 strings behind (&lt;a href="https://github.com/janhq/jan/pull/8349" rel="noopener noreferrer"&gt;janhq/jan#8349&lt;/a&gt;). It took three separate PRs to bring &lt;code&gt;ja&lt;/code&gt; back to parity because the drift had been accumulating across releases, not from one gap.&lt;/p&gt;

&lt;p&gt;Sometimes the gap is a handful of keys, not hundreds. &lt;a href="https://github.com/mui/mui-x/pull/23001" rel="noopener noreferrer"&gt;mui/mui-x#23001&lt;/a&gt; found that four Data Grid locale strings, including the "no columns" overlay text, had already been translated for &lt;code&gt;zh-CN&lt;/code&gt; and &lt;code&gt;ko-KR&lt;/code&gt; but were left commented out for &lt;code&gt;ja-JP&lt;/code&gt; since the feature shipped. Two other locales got the follow-up treatment. Japanese didn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to catch it.&lt;/strong&gt; Run a key-diff between your source locale and every target locale on every release, not just at translation time. If &lt;code&gt;ja.json&lt;/code&gt; has fewer leaf keys than &lt;code&gt;en.json&lt;/code&gt;, you already have this bug, whether or not anyone's filed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: translated, but wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; The key exists, the string isn't empty, and it's still broken, because the translation carries the wrong meaning into a UI context the translator wasn't shown. This is the pattern that key-diffing and automated QA can't catch at all, because nothing is missing. Everything renders. It's just incorrect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it's invisible.&lt;/strong&gt; A native Japanese speaker skimming the label in isolation, outside the UI, might not catch it either. The error only shows up when the word sits next to the control it's supposed to describe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real example.&lt;/strong&gt; &lt;a href="https://github.com/ant-design/ant-design/pull/58563" rel="noopener noreferrer"&gt;ant-design/ant-design#58563&lt;/a&gt;, a component library with over 98,000 stars: the Typography component's expand/collapse control was labeled &lt;code&gt;拡大する&lt;/code&gt; ("to enlarge/zoom in") for expand and &lt;code&gt;崩壊&lt;/code&gt; ("collapse," as in a building collapsing or a system failing) for collapse. Both are real, dictionary-correct Japanese words. Neither means "show more text" or "show less text." The fix swapped them for &lt;code&gt;展開する&lt;/code&gt; and &lt;code&gt;折り畳む&lt;/code&gt;, the actual UI-collapse vocabulary.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/strapi/strapi/pull/26845" rel="noopener noreferrer"&gt;strapi/strapi#26845&lt;/a&gt;, a headless CMS with over 72,000 stars, had the WYSIWYG editor's character counter labeled &lt;code&gt;キャラクター&lt;/code&gt;, a loanword that means "character" in the fictional, personified sense (a cartoon character, a game character), not "character" as in a unit of text. The correct word for a text character in this context is &lt;code&gt;文字&lt;/code&gt;. Someone had translated the English word, not the meaning it carried in that specific control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to catch it.&lt;/strong&gt; This one doesn't have a mechanical check. It needs a native speaker actually looking at the rendered UI, not a spreadsheet of key-value pairs, because the failure lives in the gap between a word's dictionary sense and the sense the interface needs at that exact spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual pattern is one level up
&lt;/h2&gt;

&lt;p&gt;Stack these three next to each other and a shape appears. Composition-state handling, key-completeness checks, and meaning-in-context review are three different kinds of infrastructure, and English-only teams don't build any of them by default, because English doesn't need them. English text is typed one character at a time, English locale files are the source of truth so they can't drift behind themselves, and translation isn't a concept that applies to the language you already wrote the UI in.&lt;/p&gt;

&lt;p&gt;So none of this is really about translation quality. Translation is a one-time act on strings. What actually breaks is the surrounding system: does the input layer understand non-Latin text entry, does the release process notice a locale falling behind, does anyone check meaning-in-context instead of string presence. Localization is what happens when all three of those hold at once, continuously, not just on the day someone did a translation pass. Every project above is a well-maintained, actively developed repo. The gap wasn't effort. It was infrastructure nobody had a reason to build until an outsider pointed at the specific line.&lt;/p&gt;

&lt;p&gt;I keep a running, searchable corpus of bugs like these, CJK-specific breakage across open-source input handling, locale files, and Unicode edge cases, with repro cases and the fix for each: &lt;a href="https://github.com/greymoth-jp/cjk-failure-corpus" rel="noopener noreferrer"&gt;github.com/greymoth-jp/cjk-failure-corpus&lt;/a&gt;. If you maintain something with text input or a translated locale, it's a fast way to check whether your project already has one of these three shapes sitting in it.&lt;/p&gt;

&lt;p&gt;More of this kind of thing: &lt;a href="https://github.com/greymoth-jp" rel="noopener noreferrer"&gt;github.com/greymoth-jp&lt;/a&gt; · &lt;a href="https://glovrex.com" rel="noopener noreferrer"&gt;glovrex.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>opensource</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Sandwich Test: How I Check If A Dev-Tool Idea Is Actually Winnable Before I Build It</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Thu, 02 Jul 2026 11:26:55 +0000</pubDate>
      <link>https://dev.to/greymothjp/the-sandwich-test-how-i-check-if-a-dev-tool-idea-is-actually-winnable-before-i-build-it-36m</link>
      <guid>https://dev.to/greymothjp/the-sandwich-test-how-i-check-if-a-dev-tool-idea-is-actually-winnable-before-i-build-it-36m</guid>
      <description>&lt;p&gt;Four dev-tool ideas this week. Four dead, all from the same cause, and it took me embarrassingly long to see the pattern instead of just the individual rejections.&lt;/p&gt;

&lt;p&gt;I'm a solo dev, no team, no funding, building in public-ish. The move I keep reaching for is the classic one: ship something free (a CLI, a GitHub Action, a linter) that devs adopt for free, then sell a paid backend on top: history, dashboards, team alerts, whatever the free tool can't do alone. It's the Sentry/Vercel playbook, scaled down. It's also, as of 2026, mostly a trap if you're doing it alone with no existing audience. Here's the check I wish I'd been running from idea one instead of idea four.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea that looked good on paper
&lt;/h2&gt;

&lt;p&gt;The one I actually got excited about: flaky-test analytics. Real, universal pain (every CI setup eventually has a test that fails 1 time in 20 for no reason), and unlike most of my other ideas, there's an actual company charging real money for it. BuildPulse has been selling this since around 2019, three tiers, $99/$249/$499 a month, same structure on the pricing page for years. That's rare. Most "obvious" dev-tool ideas don't have anyone visibly paying for them at all.&lt;/p&gt;

&lt;p&gt;So I went looking for the wedge: free CLI/Action reads your JUnit XML, no write access needed, dead simple to adopt. Then I checked who else is standing in that spot.&lt;/p&gt;

&lt;p&gt;Trunk.io raised $28.5M in venture funding. Their flaky-test detection is &lt;strong&gt;free&lt;/strong&gt; for any team under 5 monthly active committers, and it already works with GitHub Actions today. That's not a roadmap promise, that's the current pricing page. Datadog bundles flaky-test tracking into CI Visibility at $8/committer/month, money most teams are already spending on Datadog for other reasons. Cypress Cloud includes flake detection starting at $67/mo. Gradle has a named "Flaky Test Detection" feature in Develocity. None of these companies built flaky-test detection as the product. They built it as a reason to keep you inside a bigger bill you already pay.&lt;/p&gt;

&lt;p&gt;The wedge I wanted, free CLI for small teams not big enough for Datadog, is exactly the slice Trunk.io just made free. Not "hard to compete with." Actually free, today, for my target customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same shape, different idea
&lt;/h2&gt;

&lt;p&gt;I ran the same check on a completely different idea (cross-repo drift detection, catching when a bugfix in one repo doesn't get propagated to its sibling repos, something I'd noticed doing OSS work across a bunch of related codebases). Different problem, same two walls.&lt;/p&gt;

&lt;p&gt;Low end: Renovate (21,901 stars, free, AGPL) and GitHub's own Dependabot already handle dependency-drift-across-repos for zero dollars. Multi-gitter (1,212 stars, free, Apache-2.0) already does bulk cross-repo PRs. High end: Moderne, the closest real competitor, closed a $30M Series B in early 2025, roughly $50M raised total, and their OpenRewrite tech is already embedded in bigger vendors' code-automation stacks. Sourcegraph raised $245M and sits at a $2.6B valuation. Snyk, if you frame it as a security-drift problem instead, has raised $1.6B.&lt;/p&gt;

&lt;p&gt;Free OSS eating the bottom, $50M-to-$1.6B-funded companies owning the top via enterprise trust (SSO, compliance, the stuff that makes a security team say yes) that I cannot produce alone. No gap in the middle. Same shape as flaky tests. Same shape, it turns out, as a CI-autofix idea I killed a week earlier too: GitHub's own Copilot Autofix already owns that lane by default, built into the platform, and the two independent players in that space (Sweep, Korbit) didn't survive as independents either. One pivoted its whole product to a JetBrains plugin, the other got folded into a security company two months ago.&lt;/p&gt;

&lt;p&gt;I started calling this the sandwich. You're the filling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual check (steal this)
&lt;/h2&gt;

&lt;p&gt;Before I sink a week into a "free tool, paid backend" idea now, I ask two questions, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is a funded company already giving away the exact free-tier version of my wedge, on purpose, as customer acquisition for something bigger?&lt;/strong&gt; Not "could they." Is there a live pricing page right now where my target customer gets it free. This isn't rare in 2026, it's the default move for anyone with a seed round.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does actually landing a paying customer require trust infrastructure I can't produce solo&lt;/strong&gt; (SSO, compliance paperwork, security audits, an incident-response story)? If the buyer needs to trust the company as much as the tool, that buyer is not going to hand a credit card to an anonymous solo dev with a GitHub repo, no matter how good the tool is.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the answer to both is yes, I stop. Not "make the free tier better," not "find a niche within the niche." Stop, because the structure doesn't change with more effort. It changes with more funding or an existing reputation, neither of which building harder gets you.&lt;/p&gt;

&lt;p&gt;The part that took me longest to internalize: real pain is not the same thing as willingness to pay. Flaky tests are a genuinely universal complaint. Nobody's going to argue with you that it's annoying. But the fix for that pain is already a checkbox inside four different tools teams already have open bills with — so the pain being real doesn't mean anyone owes a fifth, separate invoice to a stranger. A tool that only flags a problem (a linter, a checker, a "here's what's wrong" CLI) is the weakest version of this trap, because a check is a feature, and features get copied into the next platform release for free. They don't get billed separately. If your whole product is "I noticed the bug," you don't have a product, you have a feature request someone bigger will ship next quarter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually did survive the check
&lt;/h2&gt;

&lt;p&gt;Not everything did die, which is the part worth keeping. Two patterns from this same search held up under the same scrutiny, and neither of them is "free tool, hope people upgrade."&lt;/p&gt;

&lt;p&gt;IPinfo (IP geolocation data, one guy, Ben Dowling, out of a Stack Overflow post in 2014) never had a free-adoption funnel at all. It's metered API access, paid from day one, grew off SEO and dev search instead of a personal audience. Sidekiq (Mike Perham, background-job processing for Ruby, solo for most of its life, reportedly into seven figures a year) kept the free OSS core but sold the paid layer as a license key for extra features shipped in code, not a hosted SaaS with dashboards and team seats. No infra to run, no "please upgrade" funnel to babysit, no enterprise trust apparatus required because you're not asking anyone to hand you their CI pipeline's write access.&lt;/p&gt;

&lt;p&gt;The thing both have in common: neither one is trying to convert someone else's free users. They charge their own users, directly, for a scoped thing, from the start. That's the opposite move from "give it away and hope."&lt;/p&gt;

&lt;p&gt;Back to idea five. At least now I've got a filter that kills the bad ones in an afternoon instead of a week.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **greymoth&lt;/em&gt;&lt;em&gt;. I build developer tools and write about where software quietly breaks — Japanese/CJK edge cases, i18n, the boring infra nobody checks. → *&lt;/em&gt;&lt;a href="https://glovrex.com" rel="noopener noreferrer"&gt;glovrex.com&lt;/a&gt;** · &lt;strong&gt;&lt;a href="https://github.com/greymoth-jp" rel="noopener noreferrer"&gt;github.com/greymoth-jp&lt;/a&gt;&lt;/strong&gt;*&lt;/p&gt;

</description>
      <category>devtools</category>
      <category>startup</category>
      <category>opensource</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>How this page breaks Japanese lines</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Wed, 01 Jul 2026 22:47:04 +0000</pubDate>
      <link>https://dev.to/greymothjp/how-this-page-breaks-japanese-lines-14g3</link>
      <guid>https://dev.to/greymothjp/how-this-page-breaks-japanese-lines-14g3</guid>
      <description>&lt;p&gt;Open a Japanese sentence in a narrow column and watch where the browser breaks it. It will happily split 特定商取引法 into 特定商取引 / 法, or push a 。 to the start of the next line. Japanese has no spaces, so the default line-breaker treats almost every character boundary as fair game. To a Japanese reader that looks broken in the same way &lt;code&gt;impor / tant&lt;/code&gt; would look broken to you.&lt;/p&gt;

&lt;p&gt;Most sites ship exactly that. It is the kind of thing you only notice if you read the page in Japanese, which is most of the point of this whole site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule we actually want
&lt;/h2&gt;

&lt;p&gt;Japanese wraps at phrase boundaries — 文節, roughly a content word plus its trailing particles. It also follows 禁則: a closing bracket or a 。 never starts a line, an opening bracket never ends one. Those two together are what "set correctly" means.&lt;/p&gt;

&lt;p&gt;CSS gives you half of it for free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.prose&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;line-break&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;strict&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c"&gt;/* keep 。 、 ) off the start of a line */&lt;/span&gt;
  &lt;span class="nl"&gt;word-break&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;keep-all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* never break inside a run of characters */&lt;/span&gt;
  &lt;span class="nl"&gt;overflow-wrap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;break-word&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;line-break: strict&lt;/code&gt; handles the kinsoku edge. &lt;code&gt;word-break: keep-all&lt;/code&gt; tells the browser to stop breaking between characters at all. But now nothing breaks, and a long sentence overflows the column. We have to hand the browser the break points back — the &lt;em&gt;right&lt;/em&gt; ones this time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the phrases
&lt;/h2&gt;

&lt;p&gt;The break points are the phrase boundaries, and finding them means segmenting Japanese, which is the hard part. I use &lt;a href="https://github.com/google/budoux" rel="noopener noreferrer"&gt;BudouX&lt;/a&gt;, Google's small phrase model. It turns a sentence into chunks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;loadDefaultJapaneseParser&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;budoux&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;loadDefaultJapaneseParser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;特定商取引法の表示ページが無い。&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// → ["特定商取引法の", "表示ページが", "無い。"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I join the chunks with &lt;code&gt;&amp;lt;wbr&amp;gt;&lt;/code&gt;, the "break here if you must" tag. With &lt;code&gt;word-break: keep-all&lt;/code&gt; in force, the browser breaks &lt;em&gt;only&lt;/em&gt; at those points:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- &amp;lt;p&amp;gt;特定商取引法の表示ページが無い。&amp;lt;/p&amp;gt;
&lt;/span&gt;&lt;span class="gi"&gt;+ &amp;lt;p&amp;gt;特定商取引法の&amp;lt;wbr&amp;gt;表示ページが&amp;lt;wbr&amp;gt;無い。&amp;lt;/p&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the 。 stayed glued to 無い. That is the kinsoku rule falling out of phrase segmentation for free — the model never puts a boundary in front of trailing punctuation, so there is nothing to break before it.&lt;/p&gt;

&lt;p&gt;I run this at build time, not in the browser. A small pass walks the rendered HTML, inserts &lt;code&gt;&amp;lt;wbr&amp;gt;&lt;/code&gt; into Japanese text, and skips anything inside &lt;code&gt;&amp;lt;code&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt; so code samples are left alone. The model stays on the build machine. The reader downloads a few &lt;code&gt;&amp;lt;wbr&amp;gt;&lt;/code&gt; tags and no JavaScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stops
&lt;/h2&gt;

&lt;p&gt;BudouX is a model, not a rulebook, so it is about right, not exactly right. It occasionally splits a rare compound in a place a typographer wouldn't, and it has nothing to say about full justification or 約物 spacing. For body text at a normal measure I have not needed to correct it by hand yet. If I do, I will say so here.&lt;/p&gt;

&lt;p&gt;The honest limit is the usual one: this fixes the mechanical part. It cannot tell you the Japanese was worth reading. That is still a human call.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **greymoth&lt;/em&gt;&lt;em&gt;. I build developer tools and write about where software quietly breaks — Japanese/CJK edge cases, i18n, the boring infra nobody checks. → *&lt;/em&gt;&lt;a href="https://glovrex.com" rel="noopener noreferrer"&gt;glovrex.com&lt;/a&gt;** · &lt;strong&gt;&lt;a href="https://github.com/greymoth-jp" rel="noopener noreferrer"&gt;github.com/greymoth-jp&lt;/a&gt;&lt;/strong&gt;*&lt;/p&gt;

</description>
      <category>cjk</category>
      <category>typography</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Enter key that fires while you're still typing</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Wed, 01 Jul 2026 10:57:04 +0000</pubDate>
      <link>https://dev.to/greymothjp/the-enter-key-that-fires-while-youre-still-typing-goo</link>
      <guid>https://dev.to/greymothjp/the-enter-key-that-fires-while-youre-still-typing-goo</guid>
      <description>&lt;p&gt;Type &lt;code&gt;きょう&lt;/code&gt; into a search box, press the spacebar to convert it to 今日, and press Enter to accept the kanji. On a lot of sites the search fires right then — on &lt;code&gt;きょう&lt;/code&gt;, or on nothing, or it submits the whole form. You wanted to &lt;em&gt;pick a word&lt;/em&gt;. The page heard &lt;em&gt;go&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If you only ever type English you will never reproduce this, because you never compose. That is exactly why it ships. The person who wrote the handler pressed Enter a thousand times and it always meant submit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Enter that confirms is the same Enter you're listening for
&lt;/h2&gt;

&lt;p&gt;An IME turns keystrokes into candidate text and waits for you to confirm. The confirming keypress is usually Enter. The problem is that your &lt;code&gt;keydown&lt;/code&gt; listener sees that Enter too, and by default it can't tell "commit this conversion" apart from "submit the form."&lt;/p&gt;

&lt;p&gt;The browser does leave you a tell. While the IME is composing, a &lt;code&gt;keydown&lt;/code&gt; carries &lt;code&gt;isComposing === true&lt;/code&gt;, and — going further back — reports &lt;code&gt;keyCode === 229&lt;/code&gt; instead of the real key. The Enter that closes the conversion is a composing keydown. The Enter you actually want, the one &lt;em&gt;after&lt;/em&gt; the word is settled, is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is a guard clause
&lt;/h2&gt;

&lt;p&gt;Bail out of the handler while composition is in flight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isComposing&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;229&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// still converting&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isComposing&lt;/code&gt; is the modern, readable check. &lt;code&gt;keyCode === 229&lt;/code&gt; covers browsers old enough not to set it. Keeping both costs nothing and the second one has saved me on a stock Android WebView more than once.&lt;/p&gt;

&lt;h2&gt;
  
  
  React hides the flag one level down
&lt;/h2&gt;

&lt;p&gt;React wraps the DOM event, and on the synthetic event &lt;code&gt;isComposing&lt;/code&gt; is not reliably populated. The value you want is on the native event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- onKeyDown={(e) =&amp;gt; { if (e.key === "Enter") search(); }}
&lt;/span&gt;&lt;span class="gi"&gt;+ onKeyDown={(e) =&amp;gt; {
+   if (e.nativeEvent.isComposing) return;
+   if (e.key === "Enter") search();
+ }}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same bug, same one-line fix, just reached through &lt;code&gt;nativeEvent&lt;/code&gt;. This is the version I paste into most codebases, because most of them are React and most of them read &lt;code&gt;e.isComposing&lt;/code&gt;, find it &lt;code&gt;undefined&lt;/code&gt;, and quietly do nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracking composition yourself
&lt;/h2&gt;

&lt;p&gt;If you'd rather hold the state explicitly — say you toggle other behavior during composition — the events are &lt;code&gt;compositionstart&lt;/code&gt; and &lt;code&gt;compositionend&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;composing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compositionstart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;composing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compositionend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;composing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;composing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where it stops
&lt;/h2&gt;

&lt;p&gt;The flag approach has one sharp edge worth knowing. Browsers don't agree on the order of the last two events. In some, &lt;code&gt;compositionend&lt;/code&gt; fires &lt;em&gt;before&lt;/em&gt; the confirming &lt;code&gt;keydown&lt;/code&gt;, so your &lt;code&gt;composing&lt;/code&gt; flag is already &lt;code&gt;false&lt;/code&gt; and the Enter leaks through as a submit — the exact bug you were fixing. That is why I lead with the per-event &lt;code&gt;isComposing&lt;/code&gt; / &lt;code&gt;keyCode 229&lt;/code&gt; check: it reads the state of the keypress itself instead of a flag you have to keep in sync.&lt;/p&gt;

&lt;p&gt;And the honest limit: none of this proves your form works in Japanese. It proves this one keypress does. The only way to know the rest holds is to actually type Japanese into it — which is the thing that never happens in a test suite written by someone who doesn't.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **greymoth&lt;/em&gt;&lt;em&gt;. I build developer tools and write about where software quietly breaks — Japanese/CJK edge cases, i18n, the boring infra nobody checks. → *&lt;/em&gt;&lt;a href="https://glovrex.com" rel="noopener noreferrer"&gt;glovrex.com&lt;/a&gt;** · &lt;strong&gt;&lt;a href="https://github.com/greymoth-jp" rel="noopener noreferrer"&gt;github.com/greymoth-jp&lt;/a&gt;&lt;/strong&gt;*&lt;/p&gt;

</description>
      <category>cjk</category>
      <category>ime</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I cataloged 93 CJK and Unicode bugs in open source. Most are the same five mistakes.</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Tue, 30 Jun 2026 03:52:19 +0000</pubDate>
      <link>https://dev.to/greymothjp/i-cataloged-93-cjk-and-unicode-bugs-in-open-source-most-are-the-same-five-mistakes-54ob</link>
      <guid>https://dev.to/greymothjp/i-cataloged-93-cjk-and-unicode-bugs-in-open-source-most-are-the-same-five-mistakes-54ob</guid>
      <description>&lt;p&gt;I keep a Japanese keyboard on while reading other people's code. Not for any noble reason at first, it's just my keyboard. But after a while you start seeing the same small breakages over and over, in libraries that are otherwise excellent and work perfectly in English. So I started writing them down. The list is now 93 entries across 87 libraries, and it's public:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://greymoth-jp.github.io/cjk-failure-corpus" rel="noopener noreferrer"&gt;https://greymoth-jp.github.io/cjk-failure-corpus&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's built like caniuse, except instead of "does this browser support X" it's "here is a real text-handling bug, the library it's in, a minimal repro, and the fix." Every row links to an actual pull request or issue. I'll get to why that matters at the end.&lt;/p&gt;

&lt;p&gt;The thing I didn't expect: 93 bugs, but they're not 93 different problems. They cluster into about five.&lt;/p&gt;

&lt;h2&gt;
  
  
  One bug is a third of the list
&lt;/h2&gt;

&lt;p&gt;36 of the 93 are the same bug. When you type Japanese, Chinese, or Korean, you don't type final characters. You type romaji, an IME shows you a preedit, and you press Enter to &lt;em&gt;confirm&lt;/em&gt; the conversion into kanji. That confirming Enter is the same physical Enter your form is listening for.&lt;/p&gt;

&lt;p&gt;So a user is mid-word, hits Enter to pick the right kanji, and the handler fires &lt;code&gt;onSearch&lt;/code&gt; or &lt;code&gt;commitName&lt;/code&gt; or &lt;code&gt;handleSave&lt;/code&gt; on text that isn't finished. No error, no stack trace, CI green. It only reproduces with an IME on, which most maintainers don't have, so it lives forever.&lt;/p&gt;

&lt;p&gt;The fix is one property. While a composition is active, &lt;code&gt;isComposing&lt;/code&gt; is true:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// before&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// after&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nativeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isComposing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part isn't the fix, it's &lt;em&gt;where&lt;/em&gt; it's missing. Codebases usually already know about this. They just stopped one input short. In LibreChat the main message textarea was guarded and there was even a comment explaining it; the prompt-name field, the labels form, and the tag input next to it weren't. Trilium already had an &lt;code&gt;isIMEComposing&lt;/code&gt; helper used by the note editor; the board view's card and column editors just never imported it. Same repo, same knowledge, one screen over.&lt;/p&gt;

&lt;p&gt;So it's not "teams don't know about IME." The guard lives on the input everyone tests, and the secondary inputs are the ones nobody types Japanese into during review. Search box, inline rename, tag input, modal. Four shapes, over and over.&lt;/p&gt;

&lt;p&gt;(One fiddly note if you go fix your own: in React you reach through to &lt;code&gt;e.nativeEvent.isComposing&lt;/code&gt; rather than trust the synthetic event, and &lt;code&gt;|| e.keyCode === 229&lt;/code&gt; is a legacy fallback for code paths that report 229 instead of setting the flag. There's a genuinely annoying edge right when composition ends where &lt;code&gt;isComposing&lt;/code&gt; can already read false on the confirming Enter, browser depending. I haven't found one rule that holds everywhere; checking both is what's survived for me.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The other four
&lt;/h2&gt;

&lt;p&gt;After IME, the list thins out into four more shapes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Locale leftovers (24).&lt;/strong&gt; A key exists in &lt;code&gt;en&lt;/code&gt; and never made it to &lt;code&gt;ja&lt;/code&gt;, so a string silently falls back to English. select2 had &lt;code&gt;removeItem&lt;/code&gt; and &lt;code&gt;search&lt;/code&gt; in every locale except &lt;code&gt;ja.js&lt;/code&gt;; screen readers read those aloud, so a Japanese user heard English. Or it's a parse table that formats a date but can't read its own output back, because the diacritic or the era character got dropped. A 和暦 library I looked at produced 令和元年5月1日 and then refused to parse it, because the year matcher was &lt;code&gt;[0-9]{1,2}&lt;/code&gt; and 元 (gannen, "year one") isn't a digit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surrogate and grapheme (11).&lt;/strong&gt; Code that walks text by code unit instead of grapheme cluster. Surrogate pairs split down the middle, ZWJ emoji get mis-counted, combining marks drift off their base, variation selectors get dropped. Anything that does &lt;code&gt;str[i]&lt;/code&gt; or &lt;code&gt;.length&lt;/code&gt; on user text is a candidate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kana and romaji (8).&lt;/strong&gt; Transliteration tables that drop or reverse a kana. The clean test is a round-trip: convert and convert back, you should land where you started. One library could decompose ヷ and ヺ but passed ヸ and ヹ straight through, the other half of the same wa-row family.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Width and normalization (5).&lt;/strong&gt; A CJK character renders two cells wide in a monospace terminal, but &lt;code&gt;.length&lt;/code&gt; says one. Table formatters and truncation that count characters instead of display width overflow the box every time the text is Japanese.&lt;/p&gt;

&lt;p&gt;That's 84 of the 93 in five buckets. The long tail is numerals (kanji numbers, including the 大字 forms used in contracts), regex round-trips, and a byte-order mark one code path strips and its sibling leaves glued to the first field name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why every row links to a PR
&lt;/h2&gt;

&lt;p&gt;The honest part. Most of these entries are pull requests I sent. I only mark one "merged" when the GitHub API says merged, not when I push it and not while it's in review. As I write this, 15 of the 93 have merged; the rest are open. A few entries aren't mine at all, they're cited from other people's bug reports that document the same failure, and those are marked &lt;code&gt;cited&lt;/code&gt; and link to the original report.&lt;/p&gt;

&lt;p&gt;I built it this way on purpose. The site is one Node script over a JSON file, and the build &lt;em&gt;fails loudly&lt;/em&gt; if an entry doesn't point at a real PR or issue. So the page physically can't claim a fix it can't link to. That constraint is the whole value of it as a reference: you don't have to trust me, you click through.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do with it
&lt;/h2&gt;

&lt;p&gt;If you maintain something with a text input, the ten-minute version is: switch your keyboard to a Japanese IME, then type into every input that does something on Enter and watch what fires before you've confirmed the word. The main composer is probably fine. Try the search box. Try the inline rename. Try the chip input buried in a settings panel.&lt;/p&gt;

&lt;p&gt;If you'd rather grep: find every &lt;code&gt;key === 'Enter'&lt;/code&gt; and count how many have a composition guard. The main one will. Count the rest.&lt;/p&gt;

&lt;p&gt;And if you hit a text-handling bug that isn't in the list, tell me and I'll add it. That's sort of the point of keeping a list instead of re-finding the same thing every month.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://greymoth-jp.github.io/cjk-failure-corpus" rel="noopener noreferrer"&gt;https://greymoth-jp.github.io/cjk-failure-corpus&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **greymoth&lt;/em&gt;&lt;em&gt;. I build developer tools and write about where software quietly breaks — Japanese/CJK edge cases, i18n, the boring infra nobody checks. → *&lt;/em&gt;&lt;a href="https://glovrex.com" rel="noopener noreferrer"&gt;glovrex.com&lt;/a&gt;** · &lt;strong&gt;&lt;a href="https://github.com/greymoth-jp" rel="noopener noreferrer"&gt;github.com/greymoth-jp&lt;/a&gt;&lt;/strong&gt;*&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>i18n</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A searchable corpus of CJK and Unicode bugs in open-source libraries</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Mon, 29 Jun 2026 20:50:00 +0000</pubDate>
      <link>https://dev.to/greymothjp/a-searchable-corpus-of-cjk-and-unicode-bugs-in-open-source-libraries-c29</link>
      <guid>https://dev.to/greymothjp/a-searchable-corpus-of-cjk-and-unicode-bugs-in-open-source-libraries-c29</guid>
      <description>&lt;p&gt;A Japanese user types into your search box. They write とうきょう, press Space to convert it to 東京, then press Enter to confirm the candidate. The search fires. The query that went through was the half-finished one, before the conversion committed.&lt;/p&gt;

&lt;p&gt;This is the most common internationalization bug I run into, and it is almost always one line to fix. The Enter that confirms an IME conversion is the same Enter your keydown handler is listening for. The guard is to skip the handler while a composition is still active: &lt;code&gt;event.isComposing&lt;/code&gt;, or &lt;code&gt;keyCode === 229&lt;/code&gt;. In React you have to read it off &lt;code&gt;event.nativeEvent.isComposing&lt;/code&gt;, because the synthetic event drops the field.&lt;/p&gt;

&lt;p&gt;I kept hitting variations of this across different libraries, so I started writing them down. That list is now a small public reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  CJK / Unicode Failure Corpus
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://greymoth-jp.github.io/cjk-failure-corpus/" rel="noopener noreferrer"&gt;https://greymoth-jp.github.io/cjk-failure-corpus/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is a searchable list of real CJK, IME, and Unicode text-handling bugs in open-source libraries. For each entry there is a one-line symptom, a minimal repro, the library it hits, and the fix. Right now it has 89 entries across 84 libraries. 15 of the fixes have merged, the rest are open or were closed.&lt;/p&gt;

&lt;p&gt;The point is to have something to reach for when one of these bites you. Search the library or the symptom, get the repro and the one-line fix that already worked somewhere else. Most of these are the same handful of mistakes, made over and over, in code that works fine in English.&lt;/p&gt;

&lt;p&gt;A few entries are not my PRs. They are cited upstream issues from the wider ecosystem that document the same failure, marked &lt;code&gt;cited&lt;/code&gt; and linked to the original report. Everything else is a PR I opened, with the title, repo, URL, and merge status pulled from the GitHub API rather than written from memory. The build refuses to publish an entry that does not point at a real PR or issue, so the page cannot claim a fix it cannot link to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three entries, to show the shape
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The IME Enter, in naive-ui (Vue, merged).&lt;/strong&gt; In &lt;code&gt;n-dynamic-tags&lt;/code&gt;, pressing Enter to confirm a kana-to-kanji conversion creates a tag from the in-progress text instead of just finishing the conversion. Repro: render &lt;code&gt;&amp;lt;n-dynamic-tags&amp;gt;&lt;/code&gt;, focus the input, type とうきょう with a Japanese IME, Space to get 東京, then Enter to pick the candidate. A tag gets added from the unconfirmed text. Fix: skip tag creation while &lt;code&gt;e.isComposing&lt;/code&gt; is true, and only act on the Enter that fires after &lt;code&gt;compositionend&lt;/code&gt;. This exact category shows up across React, Vue, Svelte, and Angular, so the corpus tracks it as one pattern with per-framework notes (React needs &lt;code&gt;nativeEvent.isComposing&lt;/code&gt;; Svelte exposes the native event directly; Safari and Chromium even disagree on whether the commit keydown reports &lt;code&gt;isComposing&lt;/code&gt; or &lt;code&gt;keyCode 229&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dropped apostrophe, in hepburn (kana to romaji).&lt;/strong&gt; Katakana ン before a vowel or a Y gets romanized without the syllabic-n apostrophe, unlike hiragana ん. So シンヨウ comes out as &lt;code&gt;SHINYOU&lt;/code&gt; when it should be &lt;code&gt;SHIN'YOU&lt;/code&gt;, and now it collides with シニョウ.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fromKana&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hepburn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;fromKana&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;しんよう&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// SHIN'YOU&lt;/span&gt;
&lt;span class="nf"&gt;fromKana&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;シンヨウ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// SHINYOU  &amp;lt;- apostrophe dropped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Round-trip is the oracle here: kana to romaji and back should be stable, and the hiragana sibling already did it right. The fix is to map katakana ン the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A locale that cannot parse its own output, in date-fns.&lt;/strong&gt; This one is not even CJK, which is exactly why it is in the list. In the Galician (&lt;code&gt;gl&lt;/code&gt;) locale, June formats as &lt;code&gt;xuño&lt;/code&gt;, but the June parse pattern is &lt;code&gt;/^xun/i&lt;/code&gt;. That matches the abbreviation &lt;code&gt;xun&lt;/code&gt; and not the wide form, because the third character is ñ, not n. So format then parse fails, for June only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2021&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MMMM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// 'xuño'&lt;/span&gt;
&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MMMM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;                   &lt;span class="c1"&gt;// Invalid Date&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The locale's own test snapshot already records &lt;code&gt;Invalid Date&lt;/code&gt; for June while the other eleven months parse fine. Fix: widen the pattern to &lt;code&gt;/^xu[nñ]/i&lt;/code&gt;, the way Catalan already folds diacritics into its patterns. It belongs next to the CJK entries because it is the same class of bug: text round-tripping that nobody tested in a script with characters outside ASCII.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is not
&lt;/h2&gt;

&lt;p&gt;It is not a linter and not a guarantee. It tells you that a specific bug existed and how it was fixed. Whether your code has the same one is still something you have to check. The detection is mechanical, the judgment is yours.&lt;/p&gt;

&lt;p&gt;And not every PR landed. A few were closed, because the maintainer fixed it another way or did not want the change. Those stay in the list, marked closed, because a closed PR is still a documented failure with a repro attached.&lt;/p&gt;

&lt;p&gt;If you maintain a library that takes text input and you want to know whether it has one of these, the fastest path is to search the corpus for your stack and skim the IME and locale-data sections first. That is where most of the bodies are buried.&lt;/p&gt;

&lt;p&gt;There is also a companion repo that turns the repros into CI fixtures, so the regressions can be caught automatically instead of rediscovered: &lt;a href="https://github.com/greymoth-jp/cjk-agent-fixtures" rel="noopener noreferrer"&gt;https://github.com/greymoth-jp/cjk-agent-fixtures&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Corpus: &lt;a href="https://greymoth-jp.github.io/cjk-failure-corpus/" rel="noopener noreferrer"&gt;https://greymoth-jp.github.io/cjk-failure-corpus/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>japan</category>
      <category>i18n</category>
      <category>webdev</category>
      <category>unicode</category>
    </item>
    <item>
      <title>Your main input handles IME composition. The rename box next to it doesn't.</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Mon, 29 Jun 2026 13:39:40 +0000</pubDate>
      <link>https://dev.to/greymothjp/your-main-input-handles-ime-composition-the-rename-box-next-to-it-doesnt-26ci</link>
      <guid>https://dev.to/greymothjp/your-main-input-handles-ime-composition-the-rename-box-next-to-it-doesnt-26ci</guid>
      <description>&lt;p&gt;Almost every app I look at guards its primary text input against IME composition. The search box, the inline rename field, the tag input, the modal next to it: those get forgotten. That's where the same bug keeps living.&lt;/p&gt;

&lt;p&gt;I've been sending one-line fixes for this across a bunch of editors and AI tools for a while now, and at this point it's predictable enough that I can usually guess which file the bug is in before I open the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  the bug, in 30 seconds
&lt;/h2&gt;

&lt;p&gt;When you type Japanese (or Chinese, or Korean) you don't type final characters. You type romaji, an IME shows a preedit, and you press Enter or Space to &lt;em&gt;confirm&lt;/em&gt; the conversion into kanji. That confirming Enter is the same physical Enter your form listens for.&lt;/p&gt;

&lt;p&gt;So a user is mid-word, hits Enter to pick the right kanji, and your handler fires &lt;code&gt;onSearch&lt;/code&gt; or &lt;code&gt;commitName&lt;/code&gt; or &lt;code&gt;handleSave&lt;/code&gt; on text that isn't finished yet. No error. No stack trace. CI is green. It only happens with an IME turned on, which most of the maintainers don't have, so it sits there.&lt;/p&gt;

&lt;p&gt;The fix is one property. While a composition is active, &lt;code&gt;isComposing&lt;/code&gt; is true:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// before&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;onSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// after&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nativeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isComposing&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;onSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. (&lt;a href="https://github.com/payloadcms/payload/pull/17138" rel="noopener noreferrer"&gt;payloadcms/payload#17138&lt;/a&gt;, one line.)&lt;/p&gt;

&lt;h2&gt;
  
  
  the part I actually want to point at
&lt;/h2&gt;

&lt;p&gt;Here's what made me start writing this down. The codebases usually &lt;em&gt;already know&lt;/em&gt; about the bug. They just stopped one input short.&lt;/p&gt;

&lt;p&gt;In LibreChat the main message textarea is guarded. The fix I sent for the prompt-name field, the labels form, and the dynamic tag input has a comment I left pointing right at it: &lt;code&gt;Ignore the Enter that commits an IME composition (see useTextarea.ts).&lt;/code&gt; The knowledge was in the repo. It just never made it to the three smaller inputs sitting beside the composer. (&lt;a href="https://github.com/danny-avila/LibreChat/pull/13996" rel="noopener noreferrer"&gt;danny-avila/LibreChat#13996&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Trilium was even clearer. It already had a helper, &lt;code&gt;isIMEComposing&lt;/code&gt;, living in &lt;code&gt;services/shortcuts&lt;/code&gt;, used by the note editor. The board view's card and column title editors just didn't import it. Same repo, same helper, one screen over, unguarded. (&lt;a href="https://github.com/TriliumNext/Trilium/pull/10315" rel="noopener noreferrer"&gt;TriliumNext/Trilium#10315&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;So this isn't really "teams don't know about IME." It's that the guard lives on the input everyone tests, and the secondary inputs are the ones nobody types Japanese into during review.&lt;/p&gt;

&lt;h2&gt;
  
  
  where it hides
&lt;/h2&gt;

&lt;p&gt;If you go looking, the spots repeat. In Jan it was the add-project and rename-thread dialogs (&lt;a href="https://github.com/menloresearch/jan/pull/8359" rel="noopener noreferrer"&gt;menloresearch/jan#8359&lt;/a&gt;). In Excalidraw it was the search menu's Enter-to-jump-to-next-match (&lt;a href="https://github.com/excalidraw/excalidraw/pull/11573" rel="noopener noreferrer"&gt;excalidraw/excalidraw#11573&lt;/a&gt;). In Twenty it was attachment rename and the AI chat thread title (&lt;a href="https://github.com/twentyhq/twenty/pull/22270" rel="noopener noreferrer"&gt;twentyhq/twenty#22270&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Search, rename, tag/chip, dialog. Four shapes, over and over. The early-return form is what most of them ended up with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onKeyDown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nativeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isComposing&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keyCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;229&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth knowing if you go to write this yourself.&lt;/p&gt;

&lt;p&gt;In React you reach through to &lt;code&gt;e.nativeEvent.isComposing&lt;/code&gt;. Every one of these fixes does that rather than trust the synthetic event. And the &lt;code&gt;|| e.keyCode === 229&lt;/code&gt; is a legacy fallback: on some code paths the keydown that fires mid-composition reports keyCode 229 instead of setting &lt;code&gt;isComposing&lt;/code&gt;. There's also a genuinely fiddly bit at the exact moment composition ends, where &lt;code&gt;isComposing&lt;/code&gt; can already read false on the very Enter that confirms, depending on the browser. I haven't found one clean rule that holds everywhere. The belt-and-suspenders check of both is what's survived for me in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  finding it in your own app
&lt;/h2&gt;

&lt;p&gt;You don't need a tool. Switch your keyboard to a Japanese IME, then type into every input that does something on Enter and watch what fires before you've confirmed the word. The composer will probably be fine. Try the search box. Try the inline rename. Try the chip input in a settings panel.&lt;/p&gt;

&lt;p&gt;Or grep. Find every &lt;code&gt;key === 'Enter'&lt;/code&gt; (or your keymap's equivalent) and check each one for a composition guard. The main one will have it. Count how many of the rest don't.&lt;/p&gt;

&lt;p&gt;One honest note on numbers, since the links above are the evidence. Two of these have merged as I write this, the payload and Twenty fixes; the rest are still open. I'd rather point at the ones that landed than claim I swept the ecosystem. The shape is identical in all of them, which is sort of the point: the guard sits on the input everyone tests and stops one box short.&lt;/p&gt;

&lt;p&gt;It's a small fix. It stays unfixed because it's invisible to the people writing the code, and the people who hit it ten times a day mostly shrug and don't report it. If you ship anything with a text input, it's worth ten minutes with an IME on.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **greymoth&lt;/em&gt;&lt;em&gt;. I build developer tools and write about where software quietly breaks — Japanese/CJK edge cases, i18n, the boring infra nobody checks. → *&lt;/em&gt;&lt;a href="https://glovrex.com" rel="noopener noreferrer"&gt;glovrex.com&lt;/a&gt;** · &lt;strong&gt;&lt;a href="https://github.com/greymoth-jp" rel="noopener noreferrer"&gt;github.com/greymoth-jp&lt;/a&gt;&lt;/strong&gt;*&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>react</category>
      <category>i18n</category>
      <category>a11y</category>
    </item>
  </channel>
</rss>
