<?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>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>
    <item>
      <title>Japan-readiness is a cliff, not a gradient: what 92 dev tools' front doors actually look like</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Sun, 28 Jun 2026 23:49:40 +0000</pubDate>
      <link>https://dev.to/greymothjp/japan-readiness-is-a-cliff-not-a-gradient-what-92-dev-tools-front-doors-actually-look-like-4hkp</link>
      <guid>https://dev.to/greymothjp/japan-readiness-is-a-cliff-not-a-gradient-what-92-dev-tools-front-doors-actually-look-like-4hkp</guid>
      <description>&lt;p&gt;Two things held up after I scanned 92 developer tools for how ready their front door is for a Japanese buyer. One thing I wanted to be true didn't, and I'm not going to fake it.&lt;/p&gt;

&lt;p&gt;First: readiness isn't a slope, it's a cliff. A tool either ships a real Japanese site or it ships nothing. Of the 52 tools I added this round, 36 scored a flat zero and 16 had a genuine localized surface. Almost nothing sat in between.&lt;/p&gt;

&lt;p&gt;Second, and this is the one I didn't expect. The companies that did localize, the mature ones with a Japanese marketing site and a Tokyo office, still skip the two signals that actually gate a purchase. Datadog, Snowflake, MongoDB, Okta, Twilio, Atlassian, Elastic, Databricks. Every one of them has a polished Japanese site. None of them has a 特商法 page. None of them prices in yen.&lt;/p&gt;

&lt;p&gt;The thing I couldn't get was a clean "more readiness means more Japan revenue" number. More on why below, and why I'd rather say "I can't measure this" than dress up a correlation that falls apart when you push on it.&lt;/p&gt;

&lt;p&gt;This is a follow-up. The first pass covered 40 tools and nearly all of them scored zero, which is useless for learning anything because there's no variance to look at. So this round I deliberately stacked in the incumbents to see what the top of the range looks like. Total is now 92 distinct tools, scanned the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cliff
&lt;/h2&gt;

&lt;p&gt;The score distribution has basically two piles and an empty middle.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;where it lands&lt;/th&gt;
&lt;th&gt;how many (of 52 new)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;scored exactly 0&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;genuine localized JP surface&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;somewhere in between&lt;/td&gt;
&lt;td&gt;almost none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And every single one of the 16 with a real Japanese surface is a late-stage or public company. Nothing early-stage cleared the bar. The early and mid-stage tools (Bun, Replit, Groq, Mistral, Qdrant, CockroachDB, and most of the rest) returned &lt;code&gt;&amp;lt;html lang="en"&amp;gt;&lt;/code&gt; with zero Japanese characters and no &lt;code&gt;/ja&lt;/code&gt; route worth the name.&lt;/p&gt;

&lt;p&gt;I'm fairly sure the cliff is real. I'm less sure why the middle is empty. My best guess is that partial localization has almost no payoff, so a team either commits a full Japan motion or doesn't bother, and you rarely see the half-built version in public.&lt;/p&gt;

&lt;p&gt;One honesty note that matters: the jump from ~3 localized tools in the first 40 to 16 here is &lt;strong&gt;not&lt;/strong&gt; a market shift. It's selection. I added the incumbents on purpose to find the ceiling. If you read this as "the market is getting more localized over time," that's me stacking the deck, not a trend. Don't take that number as movement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Even the localized incumbents stop one step short
&lt;/h2&gt;

&lt;p&gt;This is the part worth sitting with.&lt;/p&gt;

&lt;p&gt;The mature companies already did the expensive work. Staffed Japan, shipped a full Japanese site. These aren't &lt;code&gt;/ja&lt;/code&gt; routes that 200-OK into an English shell. Stripe's &lt;code&gt;/ja-jp&lt;/code&gt; renders around 36,000 Japanese characters. Atlassian's &lt;code&gt;/ja&lt;/code&gt; around 20,000. Snowflake's &lt;code&gt;/ja&lt;/code&gt; around 5,700. Real pages a Japanese buyer can actually read.&lt;/p&gt;

&lt;p&gt;And then they stop. Across all 52 tools this round:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;特商法&lt;/code&gt; page in the homepage HTML: &lt;strong&gt;0 of 52&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;real default-JPY pricing: &lt;strong&gt;~0&lt;/strong&gt; (Stripe lists JPY in its currency data, the only borderline case)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the companies that spent the most on Japan still leave the cheap legal and billing trust layer on the floor.&lt;/p&gt;

&lt;p&gt;I don't read this as negligence, and I don't think it's worth dunking on anyone for it. It looks structural. Localization is a marketing project with a clear owner and a clear KPI. The 特商法 page and JPY handling live in a seam between legal, finance, and growth, and that seam has no obvious owner. So it falls through, even at companies sophisticated enough to localize 36,000 characters of marketing copy. The same crack catches the beginner and the incumbent.&lt;/p&gt;

&lt;p&gt;Quick reminder of why a Japanese buyer reacts to the 特商法 gap specifically, since it's the one signal that's both cheap and high-impact. Japanese buyers, including individual developers and procurement teams, are trained to scroll to the footer and look for the 特定商取引法に基づく表記 page before they pay. When it's missing, the read isn't "they broke a law." It's "this vendor isn't set up to sell to us," and they quietly leave. You never see that in a support ticket. It's silent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The correlation I couldn't honestly draw
&lt;/h2&gt;

&lt;p&gt;Going in, the question I actually wanted to answer was: does higher readiness predict more Japan traction. It would've been the headline. It doesn't hold up, for two reasons, and both are worth stating plainly.&lt;/p&gt;

&lt;p&gt;One. There's no cheap public proxy for real traction. JP revenue share, JP customer count, JP monthly actives: none of it is public. The only thing I could pull from a page was whether a careers or company page names Japan or Tokyo. That measures "this company has a Japan org," not "this company makes money in Japan."&lt;/p&gt;

&lt;p&gt;Two. Even on that weak proxy, readiness and the Japan-org flag move together almost perfectly. Of the 16 with a localized surface, 15 also name Japan in careers. Of the 36 zeros, basically none do. That looks like a strong correlation right up until you notice the causality runs the wrong way for the thesis. A company makes an APAC bet, hires a Japan team, and &lt;em&gt;then&lt;/em&gt; localizes the site. Readiness is a lagging symptom of a go-to-market decision, not a leading cause of revenue. Bolting &lt;code&gt;hreflang&lt;/code&gt; and a 特商法 page onto an early-stage tool would not manufacture the revenue Datadog has.&lt;/p&gt;

&lt;p&gt;So I'm dropping the correlation claim. It's confounded and I can't observe the outcome side, and a number you can't defend is worse than no number. What survives is the boring, checkable stuff: the cliff, and the incumbent gap. Both are just things you can see in the HTML.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing the scanner got wrong (and I'd warn you about)
&lt;/h2&gt;

&lt;p&gt;While I'm being honest about numbers. My first run over-reported JPY readiness, and I want to flag the bug because if you build your own scan, it'll bite you too.&lt;/p&gt;

&lt;p&gt;The currency check matched the bare substring &lt;code&gt;jpy&lt;/code&gt;. Which means it happily flagged base64 asset hashes, a CSS class (&lt;code&gt;css-1lxjpys&lt;/code&gt; on MongoDB), and build-hash fragments on a handful of others. Seven false positives out of eight things it flagged. I caught it re-checking the passes by hand, which felt wrong for that many "wins," subtracted the bogus ones, and tightened the match to require an actual currency context. Every JPY number above already has those seven removed. Lesson, if you want it: &lt;code&gt;jpy&lt;/code&gt; as a loose substring is not a currency signal, it's a footgun.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I measured it
&lt;/h2&gt;

&lt;p&gt;Each value is a deterministic function of fetched public HTML. &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;Accept-Language: en-US&lt;/code&gt;, follow redirects. Five signals: discoverability (hreflang / lang / .jp / ja-path), Japanese content over 80 characters, JPY, 特商法, language switcher. On top of that I probed &lt;code&gt;/ja&lt;/code&gt;, &lt;code&gt;/jp&lt;/code&gt;, &lt;code&gt;/japan&lt;/code&gt; for a real localized surface, and read careers or company pages as the weak Japan-org proxy. No value is fabricated. Anything I couldn't verify is marked as such. Scan date 2026-06-29.&lt;/p&gt;

&lt;p&gt;A 200 from a &lt;code&gt;/ja&lt;/code&gt; path is not localization, by the way. The rendered Japanese character count is. That distinction is doing a lot of work in the 16-vs-36 split.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you maintain one of these
&lt;/h2&gt;

&lt;p&gt;If your tool is in the set and you want the exact per-signal evidence string for its score, I keep that per tool and I'm happy to hand it over. If something shipped since 2026-06-29 and a number here is now wrong, tell me and I'll correct it. I'd rather be corrected than confidently stale.&lt;/p&gt;

&lt;p&gt;The readiness raw isn't public yet. If you want to see the format I publish data in, two other public sets are up in the same spirit: &lt;a href="https://github.com/greymoth-jp/sibling-leftover-dataset" rel="noopener noreferrer"&gt;sibling-leftover-dataset&lt;/a&gt; (structural sibling bugs mined from merged PRs, CC-BY-4.0) and &lt;a href="https://github.com/greymoth-jp/cjk-agent-fixtures" rel="noopener noreferrer"&gt;cjk-agent-fixtures&lt;/a&gt; (runnable CJK / IME regression fixtures, MIT). Both are checkable line by line, which is the only kind of claim worth making here.&lt;/p&gt;

</description>
      <category>japan</category>
      <category>localization</category>
      <category>saas</category>
      <category>devtools</category>
    </item>
    <item>
      <title>I find Japan-shaped holes in global software</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Sun, 28 Jun 2026 23:06:51 +0000</pubDate>
      <link>https://dev.to/greymothjp/i-find-japan-shaped-holes-in-global-software-5dnf</link>
      <guid>https://dev.to/greymothjp/i-find-japan-shaped-holes-in-global-software-5dnf</guid>
      <description>&lt;p&gt;There's a category of bug that only exists if you type Japanese, and it keeps showing up in software built by people who don't. Same shape, different repo, over and over. After a while you stop being surprised and start being able to guess which file it's in before you open the project.&lt;/p&gt;

&lt;p&gt;The one I hit most: you're typing into a search box, the IME pops up conversion candidates, you press Enter to pick the right kanji, and instead of confirming the word the app runs the search on half-finished text. No error. No stack trace. CI is green. The maintainers never see it, because most of them don't type with an IME.&lt;/p&gt;

&lt;p&gt;Quick version of why this happens, if you've never used one. When you type Japanese you don't type final characters. You type romaji, the 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 exact same physical key your form is already listening for. So the handler can't tell "I'm done picking a word" from "submit this."&lt;/p&gt;

&lt;p&gt;Here's the part that actually got me writing this down. The main input is usually fine. The big composer, the primary message box, somebody already guarded that one. It's the search box, the inline rename field, the tag input sitting right next to it that fire early. The fix exists in the codebase. It just stopped one input short.&lt;/p&gt;

&lt;p&gt;The fix itself is one property. While a conversion is in progress, &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="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: ignore the Enter that's confirming an IME conversion&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="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;/p&gt;

&lt;p&gt;Two things I learned the annoying way, if you go to write this yourself. In React you reach down to &lt;code&gt;e.nativeEvent.isComposing&lt;/code&gt; rather than trust the synthetic event. And &lt;code&gt;isComposing&lt;/code&gt; on its own isn't quite enough: on some code paths the keydown fires reporting &lt;code&gt;keyCode === 229&lt;/code&gt; instead of setting the flag, so a lot of these end up keeping &lt;code&gt;|| e.keyCode === 229&lt;/code&gt; as a fallback. There's also a genuinely fiddly moment at the exact instant a composition &lt;em&gt;ends&lt;/em&gt;, where &lt;code&gt;isComposing&lt;/code&gt; can already read false on the very Enter that confirmed it, depending on the browser. I haven't found one clean rule that holds everywhere. Checking both is what's survived for me in practice, and I'm honestly still not sure it's airtight.&lt;/p&gt;

&lt;p&gt;So this isn't "teams don't know about IME." The knowledge is usually already in the repo, one screen over. The guard lives on the input everyone tests, and the secondary inputs are the ones nobody types Japanese into during review. That's the hole. Not the bug itself, the structure that keeps the bug alive.&lt;/p&gt;

&lt;p&gt;I've sent one-line fixes of this exact shape to a handful of open source projects. Every one was the same move: find the keydown that does something on Enter, add the guard. Unglamorous. But you can only really catch it if you've sat there and hit it yourself, in Japanese, ten times.&lt;/p&gt;

&lt;p&gt;And once you start seeing it, the IME Enter is just the most common one. The same blind spot produces a small family of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dates.&lt;/strong&gt; A form validates the year as a four-digit number between 1900 and 2100. A Japanese user types &lt;code&gt;令和7&lt;/code&gt; (Reiwa 7, which is 2025). Era-based years are all over tax forms, government paperwork, official documents. The validator has never heard of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Names.&lt;/strong&gt; A "First name / Last name" form assumes given-name-first with a space in the middle. Japanese names are family-name-first and written with no space at all. Split on the space and you mangle the name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full-width vs half-width digits is a third one in the same spirit (a phone field strips everything that isn't &lt;code&gt;[0-9]&lt;/code&gt; and silently eats the &lt;code&gt;０１２&lt;/code&gt; a Japanese keyboard produces by default), but you get the idea. None of these are hard. They're just invisible to someone who never types this way.&lt;/p&gt;

&lt;p&gt;I keep the reproductions and the data public if you want to look at the actual cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;github.com/greymoth-jp/cjk-agent-fixtures&lt;/code&gt;: runnable CI fixtures for the ways CJK / IME input breaks in editors, terminals, and agents.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;github.com/greymoth-jp/sibling-leftover-dataset&lt;/code&gt;: the broader pattern, where a fix lands in one place and the identical sibling right next to it gets left behind. Mined from merged PRs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You don't need any of that to check your own app, though. Switch your keyboard to a Japanese IME and type a word into every input that does something on Enter. The main form will probably be fine. Try the search box. Try the inline rename. Try the little tag input buried in a settings panel. If Enter fires before you've confirmed the word, there's a hole there.&lt;/p&gt;

&lt;p&gt;There's usually one.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>i18n</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>how to build a CJK/IME regression suite for a terminal or editor app</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Sun, 28 Jun 2026 21:28:01 +0000</pubDate>
      <link>https://dev.to/greymothjp/how-to-build-a-cjkime-regression-suite-for-a-terminal-or-editor-app-c9m</link>
      <guid>https://dev.to/greymothjp/how-to-build-a-cjkime-regression-suite-for-a-terminal-or-editor-app-c9m</guid>
      <description>&lt;p&gt;the one thing i've learned after fixing CJK input across 23 open-source PRs: if you don't have a regression fixture for IME composition, it breaks again within six months.&lt;/p&gt;

&lt;p&gt;this isn't theoretical. i've watched the same compositionend-on-blur bug re-enter codebases that had no IME test at all. the fix goes in, the bug comes back, nobody notices until a Japanese or Chinese user files a report.&lt;/p&gt;

&lt;p&gt;here's how to actually lock it down.&lt;/p&gt;




&lt;h2&gt;
  
  
  why IME input is different from regular keyboard input
&lt;/h2&gt;

&lt;p&gt;when a user types Japanese on a standard western keyboard, they're not pressing letter keys one-to-one. they type romaji (the romanized phonetic form), and the operating system's IME intercepts those keystrokes to build a candidate string. the user sees a temporary underlined "composition string" and then confirms it -- either by pressing Enter/Space or by selecting a candidate.&lt;/p&gt;

&lt;p&gt;during composition, the browser or terminal runtime fires a sequence of events:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;compositionstart&lt;/code&gt; -- composition begins, the IME takes control&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;compositionupdate&lt;/code&gt; -- the candidate string changes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;compositionend&lt;/code&gt; -- the user confirmed; the final character(s) are committed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;the key insight: &lt;strong&gt;between compositionstart and compositionend, normal keypress/keydown events still fire&lt;/strong&gt; -- but they carry &lt;code&gt;isComposing: true&lt;/code&gt; and typically &lt;code&gt;keyCode 229&lt;/code&gt; (the IME virtual keycode). code that handles Enter or Backspace without checking &lt;code&gt;isComposing&lt;/code&gt; will fire prematurely, eating the composition mid-input.&lt;/p&gt;

&lt;p&gt;that's how "pressing Enter to send a message" also confirms the IME and sends, all at once. annoying but fixable. what's harder to catch is the combination of bugs.&lt;/p&gt;




&lt;h2&gt;
  
  
  the five failure modes that actually show up
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. early Enter fire&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;handler checks for &lt;code&gt;keyCode === 13&lt;/code&gt; but doesn't check &lt;code&gt;isComposing&lt;/code&gt;. the Enter that confirms Japanese input also triggers "submit" or "next line."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. byte-slice crash&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;code that truncates or slices a string by raw byte index -- common in Rust/Go/C++ integrations, or old Node.js Buffer code -- hits the middle of a multi-byte sequence. Japanese characters are 3 bytes in UTF-8. a &lt;code&gt;substring(0, 10)&lt;/code&gt; that means "10 characters" but operates on bytes will silently corrupt text or panic at runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. fullwidth width mismatch&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;terminal emulators and some custom text renderers assume each character takes exactly one column. fullwidth characters (most CJK, fullwidth latin) take two columns. if column math ignores this, the cursor ends up at the wrong position and the UI tears or wraps incorrectly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. commit callback drop on focus shift&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;this one is subtle. the user is mid-composition when focus moves to another element -- say, a dropdown opens or a modal appears programmatically. some runtimes fire &lt;code&gt;compositionend&lt;/code&gt; correctly. some don't. the pending composition string is either dropped silently or committed in a broken state. neither outcome is logged anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. composition re-entry after blur/refocus&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;after the composition is committed and the field re-focuses, &lt;code&gt;isComposing&lt;/code&gt; may still read as true on some older browser versions. code that early-exits on &lt;code&gt;isComposing&lt;/code&gt; will then refuse all keyboard input until the user manually dismisses or clicks away and back.&lt;/p&gt;




&lt;h2&gt;
  
  
  building a regression fixture
&lt;/h2&gt;

&lt;p&gt;the goal is three minimal fixtures that can run headless in CI.&lt;/p&gt;

&lt;h3&gt;
  
  
  fixture 1: compose-confirm
&lt;/h3&gt;

&lt;p&gt;this is the most important one. it simulates:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;fire &lt;code&gt;compositionstart&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;fire &lt;code&gt;compositionupdate&lt;/code&gt; with a candidate string (&lt;code&gt;"にほん"&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;fire &lt;code&gt;compositionend&lt;/code&gt; with the final committed value (&lt;code&gt;"日本"&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;assert: the committed value is in the buffer; no side effects (submit, navigation) fired&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;for browser-based editors, script this with &lt;code&gt;CompositionEvent&lt;/code&gt;:&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;simulateCompose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;final&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&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;CompositionEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="na"&gt;bubbles&lt;/span&gt;&lt;span class="p"&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;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&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;CompositionEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;compositionupdate&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;bubbles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&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;CompositionEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="na"&gt;bubbles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;final&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;for terminal apps (Rust/Go/C++) that don't have a JS runtime, inject the raw byte sequence into the PTY or input buffer and assert the resulting text buffer state directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  fixture 2: byte-boundary
&lt;/h3&gt;

&lt;p&gt;take a string that mixes ASCII and CJK: &lt;code&gt;"ok日本語test"&lt;/code&gt;. pass it through every string operation your code performs -- truncate, pad, wrap, tokenize. assert that the result contains only complete codepoints.&lt;/p&gt;

&lt;p&gt;a cheap check in Node.js:&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;hasOrphanBytes&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;Buffer&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or in Go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="s"&gt;"unicode/utf8"&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;hasOrphanBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ValidString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&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;run this assertion after every transform that touches string length or slice boundaries.&lt;/p&gt;

&lt;h3&gt;
  
  
  fixture 3: focus-shift composition drop
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;start a composition (fire &lt;code&gt;compositionstart&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;programmatically move focus away (call &lt;code&gt;.blur()&lt;/code&gt; on the element or trigger a modal)&lt;/li&gt;
&lt;li&gt;assert: either the composition was cleanly cancelled (no partial text in buffer) or it was committed to its pre-blur state without corruption&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;the acceptable outcomes differ by app contract. the test doesn't enforce which -- it enforces that the outcome is one of the two valid states, not a third corrupted one.&lt;/p&gt;




&lt;h2&gt;
  
  
  wiring these into CI
&lt;/h2&gt;

&lt;p&gt;if your test suite is Jest or Vitest, these are plain unit tests. &lt;code&gt;jsdom&lt;/code&gt; dispatches composition events cleanly enough for fixtures 1 and 3. for fixture 2, you don't need a DOM at all -- just import the string util and assert.&lt;/p&gt;

&lt;p&gt;if you're testing a terminal emulator or native editor, you're likely using a PTY-based harness. the principle is identical: inject the sequence, snapshot the buffer state, diff.&lt;/p&gt;

&lt;p&gt;the test itself can be 20 lines. the value is having it in CI at all -- so the next refactor that breaks composition is caught in the pull request, not six months later when a user files a bug titled "Japanese input broken after update."&lt;/p&gt;




&lt;h2&gt;
  
  
  why english-native teams miss it
&lt;/h2&gt;

&lt;p&gt;the honest answer: nobody on the team types Japanese. the IME code path is invisible in their daily workflow.&lt;/p&gt;

&lt;p&gt;more specifically: the browser and OS hide the composition abstraction well enough that you can build a functional text editor entirely in English without ever triggering a &lt;code&gt;compositionstart&lt;/code&gt; event. the bugs are invisible until a user who relies on an IME hits them.&lt;/p&gt;

&lt;p&gt;there's also a framing problem. "i18n" gets treated as a translation layer -- add locale files, ship. the input layer is a separate problem and typically falls outside i18n tickets entirely. it lives in no one's backlog.&lt;/p&gt;

&lt;p&gt;the fix isn't harder tooling. it's adding three fixtures to CI and putting "CJK input" on the review checklist alongside "keyboard accessibility" and "mobile viewport." if it's on the list, reviewers look for it. if it's not, it will never surface in review.&lt;/p&gt;

&lt;p&gt;that's the whole playbook. three fixtures, one checklist item, and you've eliminated most of the regression surface for the second-largest writing system on earth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The five fixtures from this post, runnable in CI (JS + Go, MIT):&lt;/strong&gt; &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;&lt;em&gt;field notes on the Japan-shaped holes in global software · &lt;a href="https://github.com/greymoth-jp" rel="noopener noreferrer"&gt;github.com/greymoth-jp&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>i18n</category>
      <category>ai</category>
      <category>japanese</category>
    </item>
    <item>
      <title>I tested whether AI coding agents break on Japanese input</title>
      <dc:creator>greymoth</dc:creator>
      <pubDate>Sun, 28 Jun 2026 21:05:05 +0000</pubDate>
      <link>https://dev.to/greymothjp/i-tested-whether-ai-coding-agents-break-on-japanese-input-3fb</link>
      <guid>https://dev.to/greymothjp/i-tested-whether-ai-coding-agents-break-on-japanese-input-3fb</guid>
      <description>&lt;p&gt;here's what i found: every AI coding environment i tested has the same structural bug when you type in Japanese.&lt;/p&gt;

&lt;p&gt;not "some of them." all of them, across different codebases and different teams.&lt;/p&gt;

&lt;p&gt;the bug isn't flashy. it doesn't crash anything. but if you type Japanese in an AI coding tool and wonder why it feels off, this is probably why.&lt;/p&gt;

&lt;h2&gt;
  
  
  what IME composition actually is
&lt;/h2&gt;

&lt;p&gt;Japanese input works differently from English. you type roman characters to form a Japanese word, and the OS enters a "composition state" -- the text is provisional, unconfirmed. you press Enter (or a function key) to confirm the word and commit it to the field. the browser fires &lt;code&gt;compositionstart&lt;/code&gt;, then &lt;code&gt;compositionupdate&lt;/code&gt; as you type, then &lt;code&gt;compositionend&lt;/code&gt; when you confirm.&lt;/p&gt;

&lt;p&gt;the problem: confirming an IME word uses the same physical key (Enter) that most apps use to "submit" or "execute."&lt;/p&gt;

&lt;p&gt;properly handling this requires checking &lt;code&gt;event.isComposing&lt;/code&gt; before acting on Enter, or waiting for &lt;code&gt;compositionend&lt;/code&gt; to fire first. it's one of those things that only surfaces when someone actually tests CJK input on a real Japanese keyboard session.&lt;/p&gt;

&lt;h2&gt;
  
  
  pattern 1: Enter during composition triggers the wrong action
&lt;/h2&gt;

&lt;p&gt;in any input wired to "send on Enter," pressing Enter to confirm a Japanese word also fires the send. you wanted to commit 確認 to the text field. instead you submitted an incomplete message, or ran an empty command.&lt;/p&gt;

&lt;p&gt;this is the most common form. i've run into it in chat UIs, terminal prompts, AI command bars, inline editors. the fix is always the same -- check &lt;code&gt;event.isComposing&lt;/code&gt; before acting -- but it requires knowing the bug exists. if your QA happens in English, it's invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  pattern 2: the IME candidate window collides with AI overlays
&lt;/h2&gt;

&lt;p&gt;AI coding tools add their own overlays: autocomplete popups, inline suggestions, slash command menus. these need to be positioned relative to cursor. on Windows or macOS with Japanese input active, the OS's own IME candidate window appears near the cursor too.&lt;/p&gt;

&lt;p&gt;when both exist, they stack. the AI suggestion panel and the kanji candidate list fight for the same screen real estate. which one wins depends on z-index and platform behavior. neither the tool nor the OS wins cleanly.&lt;/p&gt;

&lt;p&gt;this is harder to fix than the Enter problem, because there's no standard API to ask the OS where the IME window is sitting. it requires the tool to actively coordinate around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  why this keeps appearing in well-funded products
&lt;/h2&gt;

&lt;p&gt;i've submitted about two dozen i18n and CJK-related patches across open source projects. what i notice is consistent: it's not that these teams are careless. it's that CJK input almost never makes it into the release test matrix.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;compositionstart&lt;/code&gt; doesn't fire during English typing. the Enter-to-submit shortcut works fine on Latin keyboards. the test suite passes. the product ships. the bug is invisible until someone outside the core team uses the product in its actual CJK context.&lt;/p&gt;

&lt;p&gt;AI coding agents add another dimension here. they layer additional keyboard-listening logic on top of existing editors -- each layer is another place composition state can be dropped or misread. the more agentic the tool (intercepting keystrokes, watching for trigger phrases, managing context windows), the more surfaces there are for the bug to live.&lt;/p&gt;

&lt;h2&gt;
  
  
  the structural gap
&lt;/h2&gt;

&lt;p&gt;this isn't about one product being sloppy. it's a category pattern: tools built and tested primarily in English environments ship with CJK edge cases that only get caught when users in Japan, China, Korea, or Taiwan try to use them seriously in production.&lt;/p&gt;

&lt;p&gt;the fix for pattern 1 is genuinely simple:&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;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;isComposing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;handleSubmit&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;&lt;code&gt;compositionstart&lt;/code&gt; and &lt;code&gt;compositionend&lt;/code&gt; have been in the browser spec for years. this isn't obscure. it's just not tested.&lt;/p&gt;

&lt;p&gt;i don't expect this to change until CJK test coverage becomes a gate rather than an afterthought -- or until more people from these communities speak up about it inside the repos where it matters.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;field notes on the Japan-shaped holes in global software · &lt;a href="https://github.com/greymoth-jp" rel="noopener noreferrer"&gt;github.com/greymoth-jp&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>i18n</category>
      <category>japanese</category>
    </item>
  </channel>
</rss>
