<?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: Rapls</title>
    <description>The latest articles on DEV Community by Rapls (@rapls).</description>
    <link>https://dev.to/rapls</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3886801%2F1f380f23-3b41-4825-80fe-ba6efc0c6d3e.png</url>
      <title>DEV Community: Rapls</title>
      <link>https://dev.to/rapls</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rapls"/>
    <language>en</language>
    <item>
      <title>I Couldn't Read the Code I Wrote With AI Six Months Ago</title>
      <dc:creator>Rapls</dc:creator>
      <pubDate>Thu, 04 Jun 2026 07:14:48 +0000</pubDate>
      <link>https://dev.to/rapls/i-couldnt-read-the-code-i-wrote-with-ai-six-months-ago-18a7</link>
      <guid>https://dev.to/rapls/i-couldnt-read-the-code-i-wrote-with-ai-six-months-ago-18a7</guid>
      <description>&lt;p&gt;Last week I opened a project I had built almost a year ago. Not because of a bug. A friend mentioned wanting a self-hosted Slack alternative, and I remembered I had made one. So I opened the folder in VS Code, looked at the file tree, picked the file that seemed like a sensible starting point, and opened it.&lt;/p&gt;

&lt;p&gt;Five minutes later, I closed it.&lt;/p&gt;

&lt;p&gt;I had written this code myself. And I could not tell what it did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I had built
&lt;/h2&gt;

&lt;p&gt;Over the spring and summer of 2025, I built a Slack-style team communication tool. Channels, DMs, mentions, notifications, emoji reactions, file attachments, threads, unread badges. Most of what you touch in Slack on a normal day, I had implemented. It ran as a self-hosted theme on my own server.&lt;/p&gt;

&lt;p&gt;The reason was ordinary. A paid Slack plan felt like too much for a small outside community, and I was riding the high of AI-assisted coding at the time. "I can probably build this myself." I started without thinking too hard about it.&lt;/p&gt;

&lt;p&gt;In the first few weeks, channel lists and message posting came to life. I still remember how good that felt. Ask the AI, get code, paste it, watch it run, move to the next thing. The loop was fun, and somewhere in there adding features quietly became the goal in itself.&lt;/p&gt;

&lt;p&gt;Then I shipped it to a client for review, and it went nowhere. No feedback came back, time passed, other work piled up, and the project drifted to the back of my mind. I did not touch it for almost a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I saw when I opened it
&lt;/h2&gt;

&lt;p&gt;Let me be specific about what I found, because the specifics are the whole point.&lt;/p&gt;

&lt;p&gt;The first file I opened was the central controller. Dozens of backend files, more once you counted the JavaScript and CSS. I could not remember how I had arrived at this structure, or why.&lt;/p&gt;

&lt;p&gt;The functions had names I could no longer read. Abstract labels like &lt;code&gt;processMessage&lt;/code&gt; scattered everywhere, and I could not tell which one handled channel messages and which handled DMs without opening each one and reading the body.&lt;/p&gt;

&lt;p&gt;So I grepped &lt;code&gt;processMessage&lt;/code&gt;. It turned up in three files. The arguments looked similar, the bodies were subtly different. Past me had apparently meant "this one is for channels, this one for DMs, this one for threads." Reading it now, I could not see the difference. Three functions with the same name in three files, where the caller picking the wrong one breaks everything without a single error message.&lt;/p&gt;

&lt;p&gt;The helpers had multiplied the same way. &lt;code&gt;getUserNameById&lt;/code&gt;, &lt;code&gt;getUserNameFromCache&lt;/code&gt;, &lt;code&gt;getUserDisplayName&lt;/code&gt;, &lt;code&gt;formatUserName&lt;/code&gt;. I wrote every one of them. Tracing which called which, and where each was actually used, ate far more time than I expected.&lt;/p&gt;

&lt;p&gt;The comments came in two languages. A line like &lt;code&gt;// Handle the case where user is not authenticated&lt;/code&gt;, and right under it a comment in Japanese about refreshing the unread badge. I had not cared at the time. The more honest version: I accepted whatever comments the AI produced and never rewrote them. Every time, I weighed "rewrite this comment" against "add one more feature," and the feature won.&lt;/p&gt;

&lt;p&gt;Dead code sat everywhere. A function tagged &lt;code&gt;// TODO: remove after refactoring&lt;/code&gt;, still there, called from nowhere. The refactoring never came. A few files were nearly empty, leftovers from a split I planned and abandoned, half-written with the rest still &lt;code&gt;// TODO&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I checked the commit log to find out who wrote all of this. It was me. Every line.&lt;/p&gt;

&lt;p&gt;Five minutes, and I closed the editor.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I had been working
&lt;/h2&gt;

&lt;p&gt;At the time, something like 80% of the code came from AI. I rotated between a few assistants, a CLI coding agent, an in-editor completion tool, and a chat model, building Slack features one at a time.&lt;/p&gt;

&lt;p&gt;My day had a shape to it. Morning, coffee, open the editor, decide "today I implement unread counts for channels." Ask the AI how to do it Slack-style, skim the design it returned, say "go with this." The AI wrote the code. I copied and pasted. If it did not run, I pasted the error back, took the fix, and when it ran, moved on.&lt;/p&gt;

&lt;p&gt;I had a loose sense of which tool to use for what. Structural thinking went to the CLI agent, small in-file edits to inline completion, research and rubber-ducking to the chat model. There was no real rule behind it. It came down to my mood that day, or whichever tab was already open.&lt;/p&gt;

&lt;p&gt;The problem was that I ran at a speed my own understanding never caught up to.&lt;/p&gt;

&lt;p&gt;It runs, next. It runs, next.&lt;/p&gt;

&lt;p&gt;No spec. No design doc. The file layout was whatever occurred to me in the moment, function responsibilities stayed vague, and the feature count kept climbing. No tests at all. I sprinted in "just make something that works, fast" mode for about half a year.&lt;/p&gt;

&lt;p&gt;My judgment got lazier as I went. "I think I wrote this already," I would think, and then searching the old files felt slower than asking the AI to write it again, so I asked again. That is how the same logic ended up in three places. That is where the duplicate-definition mess was born.&lt;/p&gt;

&lt;p&gt;I still do not think using AI was the mistake. Without that speed, the tool would never have reached "done" at all. The mistake was quieter than that. While the AI wrote, I stopped acting as a reviewer and turned into a copy-paste middleman. I gave away the one job that was actually mine, deciding whether the code was any good.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI of early 2025 is not the AI of 2026
&lt;/h2&gt;

&lt;p&gt;One more thing worth being honest about.&lt;/p&gt;

&lt;p&gt;AI coding in 2025 had not matured into what it is now. The models I leaned on were strong for their moment, but a step below today's. The quality of the code, and the ability to hold a structure together across a project, were simply different.&lt;/p&gt;

&lt;p&gt;Ask for the same thing in slightly different words and you got completely different code. It did not remember the previous design. Ask it to refactor one function and it would rewrite two neighbors along the way, and I would lose time diffing to work out what had even changed. Context windows were shorter, so pasting a long file left the AI quietly working from the first half only. I did not notice. I talked to it as though it had read the whole file, and many of its answers assumed just the top of it. Some of today's dead code almost certainly comes from places I waved through on "it runs, so fine."&lt;/p&gt;

&lt;p&gt;Run a long project with an AI like that, and the code turns to spaghetti on schedule. I never adapted my style to the AI's quirks. Every new feature got a new session and a new file. "Start fresh" instead of "continue from last time," again and again, until the design had no through-line left.&lt;/p&gt;

&lt;p&gt;Today's agents hold long context and reason across files. Ask for a feature and they read the project first, reuse an existing function when one fits, and follow the existing naming when one does not. The design consistency I fought for a year ago is now carried, in part, by the tool itself.&lt;/p&gt;

&lt;p&gt;Code written with the AI of early 2025 still carries that era's limits, set in amber. I could hand it to a current model and say "clean this up," but the cleanup might quietly take the original behavior with it, so I have not pulled that trigger yet.&lt;/p&gt;

&lt;p&gt;None of this is the AI's fault. Running without understanding its quirks was mine. I only wish the version of me who saw "AI coding is fast" had also seen the bill that comes due later.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I am dealing with it now
&lt;/h2&gt;

&lt;p&gt;Honestly, I am not, not yet.&lt;/p&gt;

&lt;p&gt;There is too much source, and I am still working out where to even start reading. This is a long way from "jump in and fix it."&lt;/p&gt;

&lt;p&gt;Here is the plan.&lt;/p&gt;

&lt;p&gt;First, have a current AI read the whole codebase and give me a map, what each file does and where each function gets called, written back in something I can actually read. The starting prompt is roughly "list the five main files in this project and describe each one's responsibility in one short sentence." Having an AI explain code an AI wrote is an odd loop to stand in, but right now it is the most realistic move I have.&lt;/p&gt;

&lt;p&gt;Next, hunt down and delete the dead code. That part is greppable, and static analysis catches it fast, PHPStan or Psalm on the PHP side, ESLint rules on the JavaScript side, flagging unused methods and unreachable blocks. I never ran any of it back then, so step one is simply getting the project to pass through the tools at all.&lt;/p&gt;

&lt;p&gt;Then reorganize by feature. Right now channels, DMs, and threads share a file in some places and scatter across files in others. I want one feature per file or directory, &lt;code&gt;channel/&lt;/code&gt;, &lt;code&gt;dm/&lt;/code&gt;, &lt;code&gt;thread/&lt;/code&gt;, &lt;code&gt;notification/&lt;/code&gt;, &lt;code&gt;attachment/&lt;/code&gt;, holding only what each one needs.&lt;/p&gt;

&lt;p&gt;Only after that will I be ready to write a spec. Read the code, infer what past me was trying to do, and reconstruct the spec after the fact. This is the part I dread. "What did I build this for" is going to land on me more than once while I write it.&lt;/p&gt;

&lt;p&gt;No deadline. I will chip at it between paying work. It might take half a year, or I might decide to rebuild from scratch. Rebuilding turning out faster is a common enough ending in real life.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes to my future self
&lt;/h2&gt;

&lt;p&gt;I am writing this down now, because if I do not, I will do it all again.&lt;/p&gt;

&lt;p&gt;Write a spec for each feature, however small, what this channel can do, when this notification fires, in plain language. When "I think I wrote this already" shows up, go find it instead of regenerating it. Keep an AI session continuous within a feature instead of opening a fresh one every time. And read what the AI writes as a reviewer, not a middleman.&lt;/p&gt;

&lt;p&gt;That last one is really the whole thing.&lt;/p&gt;

&lt;p&gt;The speed was real, and I would use AI the same way again. I would just stay in the chair as the person who decides what is good, instead of handing that chair to the model and reducing myself to the one who copies and pastes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;A reworked English version of a &lt;a href="https://raplsworks.com/ai-generated-code-maintenance/" rel="noopener noreferrer"&gt;post I first wrote in Japanese&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>The WordPress AI Client silently dropped my chat history — and threw no error</title>
      <dc:creator>Rapls</dc:creator>
      <pubDate>Thu, 28 May 2026 23:28:55 +0000</pubDate>
      <link>https://dev.to/rapls/the-wordpress-ai-client-silently-dropped-my-chat-history-and-threw-no-error-189c</link>
      <guid>https://dev.to/rapls/the-wordpress-ai-client-silently-dropped-my-chat-history-and-threw-no-error-189c</guid>
      <description>&lt;p&gt;&lt;em&gt;Verified on WordPress 7.0 RC3, and re-confirmed on the 7.0 stable release. PHP 8.3.30. Because I implemented this against RC3, I'm writing with the assumption that the AI Client API may still move — but the behaviors described here (&lt;code&gt;with_history()&lt;/code&gt;'s type, the role enum, the decryption-notice path) were identical between RC3 and stable.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;WordPress 7.0 shipped with the AI Client built into core. If a site admin wires up a provider in Connectors, a plugin can reach the model behind it without carrying any API keys of its own.&lt;/p&gt;

&lt;p&gt;My chatbot plugin used to carry OpenAI, Claude, Gemini, and OpenRouter itself — encrypting each provider's key, picking models, normalizing responses. I wrote that whole layer by hand. The 7.0 AI Client was an offer to hand that layer to the platform. I wanted to take it: keys and models, out of the plugin, into Connectors.&lt;/p&gt;

&lt;p&gt;There was one tension. Most users are still on pre-7.0 WordPress. I could add a new provider, but I couldn't break a single line on older installs. The new path had to lie dormant on top of an AI Client that doesn't exist yet.&lt;/p&gt;

&lt;p&gt;The migration itself should have been just accepting the offer: detect, connect, stay asleep on old environments. Not hard, I thought.&lt;/p&gt;

&lt;p&gt;What actually ate my time was none of those. It was a single spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Passing history to &lt;code&gt;with_history()&lt;/code&gt; — that one spot
&lt;/h2&gt;

&lt;p&gt;It was passing the conversation history to &lt;code&gt;with_history()&lt;/code&gt;. That's where I fell, twice.&lt;/p&gt;

&lt;p&gt;The first fall was the loud one. The plugin keeps history as a plain ChatML-style array — &lt;code&gt;['role' =&amp;gt; 'user', 'content' =&amp;gt; '…']&lt;/code&gt;. I passed it straight through.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the plugin's internal history (a plain ChatML-style array)&lt;/span&gt;
&lt;span class="nv"&gt;$history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"What's the weather in Tokyo?"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'assistant'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"It's sunny."&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"And in Osaka?"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_ai_client_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s2"&gt;"And in Osaka?"&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;with_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$history&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// passing the array as ONE argument&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate_text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This doesn't work. The AI Client's &lt;code&gt;withHistory()&lt;/code&gt; is &lt;code&gt;withHistory( Message ...$messages )&lt;/code&gt; — a typed variadic that expects a sequence of &lt;code&gt;Message&lt;/code&gt; objects. Hand it one raw array and it tries to bind that &lt;code&gt;array&lt;/code&gt; to &lt;code&gt;$messages[0]&lt;/code&gt;, which raises a &lt;code&gt;TypeError&lt;/code&gt; (array given, Message expected).&lt;/p&gt;

&lt;p&gt;I half-expected the WordPress wrapper to catch this. It doesn't. &lt;code&gt;WP_AI_Client_Prompt_Builder::__call&lt;/code&gt; only catches &lt;code&gt;Exception&lt;/code&gt;. A &lt;code&gt;TypeError&lt;/code&gt; is an &lt;code&gt;Error&lt;/code&gt;, not an &lt;code&gt;Exception&lt;/code&gt;, so it sails right past the catch and blows up loudly. Which, it turns out, was the kind of failure I should be grateful for. You notice it immediately.&lt;/p&gt;

&lt;p&gt;The second fall was the quiet one.&lt;/p&gt;

&lt;p&gt;Having crashed loudly, I wrote code that builds &lt;code&gt;Message&lt;/code&gt; objects correctly: fold roles to &lt;code&gt;user&lt;/code&gt;/&lt;code&gt;model&lt;/code&gt;, wrap each text in a &lt;code&gt;MessagePart&lt;/code&gt;, put that in an array, pass it to &lt;code&gt;UserMessage&lt;/code&gt;/&lt;code&gt;ModelMessage&lt;/code&gt;, and finally spread it into &lt;code&gt;with_history()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;WordPress\AiClient\Messages\DTO\UserMessage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;WordPress\AiClient\Messages\DTO\ModelMessage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;WordPress\AiClient\Messages\DTO\MessagePart&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;apply_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$history&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$history&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$turn&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$turn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'content'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&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="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// skip empty turns&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nv"&gt;$part&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MessagePart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// a single string =&amp;gt; a text part&lt;/span&gt;

        &lt;span class="c1"&gt;// fold everything that isn't "user" into "model" (two-value coercion)&lt;/span&gt;
        &lt;span class="nv"&gt;$messages&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$turn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;  &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$part&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ModelMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$part&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$messages&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;apply_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$history&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_ai_client_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$latest_user_text&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;with_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nv"&gt;$messages&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// spread into the variadic&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate_text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(For readability I'm using &lt;code&gt;use&lt;/code&gt; here. The production code references &lt;code&gt;MessagePart&lt;/code&gt; etc. by string class name behind &lt;code&gt;class_exists()&lt;/code&gt; — it actually checks all three of &lt;code&gt;MessagePart&lt;/code&gt; / &lt;code&gt;UserMessage&lt;/code&gt; / &lt;code&gt;ModelMessage&lt;/code&gt; — so the file doesn't fatal on pre-7.0 sites where the DTO classes don't exist. That guard is exactly what sets up the real trap.)&lt;/p&gt;

&lt;p&gt;One honest note about the role folding. Collapsing everything non-&lt;code&gt;user&lt;/code&gt; into &lt;code&gt;model&lt;/code&gt; isn't sloppiness; it's a bet leaning on an assumption. The AI Client's role enum has only two values, &lt;code&gt;user&lt;/code&gt; and &lt;code&gt;model&lt;/code&gt;. System goes through a separate path (&lt;code&gt;using_system_instruction&lt;/code&gt;), and system messages are split off upstream — so what reaches &lt;code&gt;apply_history()&lt;/code&gt; is effectively just &lt;code&gt;user&lt;/code&gt; and &lt;code&gt;assistant&lt;/code&gt;/&lt;code&gt;bot&lt;/code&gt;. The two-value coercion holds. But it holds &lt;em&gt;because of&lt;/em&gt; that assumption. If an input ever violates it, a system turn silently becomes a &lt;code&gt;model&lt;/code&gt; turn. Safe today — but the safety lives in the precondition, not in the code.&lt;/p&gt;

&lt;p&gt;And the real trap was past this point.&lt;/p&gt;

&lt;p&gt;I had the marshalling right. Build the &lt;code&gt;Message&lt;/code&gt;, wrap it, spread it. The first-fall &lt;code&gt;TypeError&lt;/code&gt; was gone. Relaxing there was how I walked into the second fall.&lt;/p&gt;

&lt;p&gt;The production code wraps that same marshalling in a &lt;code&gt;try/catch&lt;/code&gt;. The reason is pre-7.0 compatibility: old WordPress doesn't have the AI Client DTO classes, so if anything throws mid-build, I don't want to fatal the whole site. So I check &lt;code&gt;class_exists()&lt;/code&gt; for the DTOs, wrap the whole construction in &lt;code&gt;try/catch(\Throwable)&lt;/code&gt;, and if anything goes wrong, abandon the history entirely and move on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// production structure (simplified)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;class_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$message_part_class&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;span class="c1"&gt;// …log only when WP_DEBUG…&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;// return without building history&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// …build MessagePart / UserMessage / ModelMessage and call with_history() …&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nc"&gt;\Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// …log only when WP_DEBUG…&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;// return without building history&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The design is sound. Better to drop the context once and still answer than to crash on an old install. That was the call. The problem is that this defense never tells anyone it gave up.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;apply_history()&lt;/code&gt; returns &lt;code&gt;void&lt;/code&gt;. When it takes the skip path, &lt;code&gt;with_history()&lt;/code&gt; is never called. What's left on the builder is the system instruction plus the single latest turn that went into &lt;code&gt;wp_ai_client_prompt()&lt;/code&gt;. &lt;code&gt;generate_text()&lt;/code&gt; returns 200 like nothing happened. The conversation looks fine. It just doesn't remember two turns ago. No error log. No exception. The response is valid. The only thing missing is the context.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbk3cr9ofoa5vbvvditlo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbk3cr9ofoa5vbvvditlo.png" alt="With the defensive path taken, with_history() is never called and a 200 returns with only the latest turn. No signal of failure is emitted." width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the thing that actually fires in production isn't the &lt;code&gt;class_exists()&lt;/code&gt; branch. Once &lt;code&gt;wp_ai_client_prompt()&lt;/code&gt; exists, the AI Client is loaded, so the DTOs autoload fine. &lt;code&gt;class_exists()&lt;/code&gt; going false is the latent trap — for when the SDK moves a namespace or changes structure down the line. The branch that actually drops history is &lt;code&gt;try/catch(\Throwable)&lt;/code&gt;: if the SDK's signature shifts even a little (the &lt;code&gt;UserMessage&lt;/code&gt; argument shape, the &lt;code&gt;with_history&lt;/code&gt; type, the role enum), the marshalling you wrote correctly throws, gets caught here, and the history quietly disappears.&lt;/p&gt;

&lt;p&gt;Here's the clincher on the silence. Both skip paths log only inside &lt;code&gt;if ( defined('WP_DEBUG') &amp;amp;&amp;amp; WP_DEBUG )&lt;/code&gt;. Production has &lt;code&gt;WP_DEBUG&lt;/code&gt; off. So the defensive code does write a log — and the log itself goes silent in production. The safety net swallows the failure, and the alarm that was supposed to announce it is switched off behind a flag. The well-meaning defense muted itself twice. The failure happens. The signal that it happened goes nowhere.&lt;/p&gt;

&lt;p&gt;The difference between the first fall and the second was, in the end, the guard itself. The unguarded minimal repro crashes with a &lt;code&gt;TypeError&lt;/code&gt; and you notice immediately. The guarded production code swallows the same failure and calmly returns a response with the context stripped out. Crashing would have been the kinder behavior.&lt;/p&gt;

&lt;p&gt;How I actually noticed this — I no longer have the exact record. But my verification notes from the time had "multi-turn conversation returns a reply that accounts for earlier turns" standing as its own checklist item. This is the kind of break that doesn't show up in unit tests and only surfaces when you talk to it across several turns by hand, so I suspect I hit it somewhere in that manual check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The keyless provider broke an assumption the whole plugin was built on
&lt;/h2&gt;

&lt;p&gt;Past the silence of &lt;code&gt;with_history()&lt;/code&gt;, a completely different kind of problem was waiting. Not about types. The AI Client broke an assumption the entire plugin held without anyone ever writing it down: &lt;em&gt;every provider has an API key inside the plugin.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wpai&lt;/code&gt; hands its key off to Connectors. There is no &lt;code&gt;wpai&lt;/code&gt; API key inside the plugin. That one fact surfaced from places I never expected. From the shallow end:&lt;/p&gt;

&lt;h3&gt;
  
  
  The shallowest hole: the settings schema
&lt;/h3&gt;

&lt;p&gt;First it was just teaching the schema a new provider. Without &lt;code&gt;wpai&lt;/code&gt; in the allowlist, &lt;code&gt;in_array&lt;/code&gt; rejects it and the selection resets (an invalid value falls back to the previously saved value, or &lt;code&gt;openai&lt;/code&gt; if none). Add &lt;code&gt;wpai_model&lt;/code&gt; to &lt;code&gt;sanitize&lt;/code&gt;, give it an empty default. The dull kind of work that breaks if you forget it.&lt;/p&gt;

&lt;p&gt;But there's something to notice here. Every other provider has both &lt;code&gt;_api_key&lt;/code&gt; and &lt;code&gt;_model&lt;/code&gt;. &lt;code&gt;wpai&lt;/code&gt; has only &lt;code&gt;_model&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openai     :  openai_api_key      +  openai_model
openrouter :  openrouter_api_key  +  openrouter_model
wpai       :  (no key field)       +  wpai_model
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;wpai_api_key&lt;/code&gt; anywhere. Being keyless is visible as a &lt;em&gt;hole&lt;/em&gt; in the settings schema — the quietest tear in the "every provider has a key field" assumption. At this point I didn't realize it was the omen of the two below.&lt;/p&gt;

&lt;h3&gt;
  
  
  The middle: the REST pre-check
&lt;/h3&gt;

&lt;p&gt;The chat REST pre-check decrypts the active provider's key before the call and, if it's empty, returns 400 &lt;code&gt;api_key_missing&lt;/code&gt; and bails early. "Confirm the key exists before calling." Reasonable — no point shipping a request to the model without a key.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wpai&lt;/code&gt; doesn't fit that. Having no key is normal for it, so I wrapped the whole pre-check in a name-based exempt: not a capability test, just &lt;code&gt;!== 'wpai'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What happened there wasn't only an exemption — it was a move of the availability check. Other providers verify availability "before the call, by key." &lt;code&gt;wpai&lt;/code&gt; verifies it "at call time, by Connector availability," delegated to &lt;code&gt;is_supported_for_text_generation()&lt;/code&gt;. The same "is this usable?" question shifted from a key-existence check to a call-time capability check. An assumption learned an exception.&lt;/p&gt;

&lt;h3&gt;
  
  
  The deepest: the decryption-failure notice
&lt;/h3&gt;

&lt;p&gt;This is the heart of the chapter. An admin notice, "Failed to decrypt the API key," was misfiring for &lt;code&gt;wpai&lt;/code&gt; users. At first I thought it was a gap in my &lt;code&gt;wpai&lt;/code&gt; handling. It wasn't. &lt;code&gt;wpai&lt;/code&gt; only dragged into the light something that had been broken for far longer.&lt;/p&gt;

&lt;p&gt;The misfire was a three-step chain. A migration routine that runs every time the settings page loads (&lt;code&gt;maybe_migrate_legacy_keys()&lt;/code&gt;) sweeps every provider's key. Inside that loop, it called a decrypt wrapper that raises a global transient on failure. And the notice's display check looked only at that transient plus a capability — and displayed unconditionally. It never asked which provider failed, or whether that provider was the active one.&lt;/p&gt;

&lt;p&gt;So: a user who switched to OpenAI still has an old Claude key sitting around that can no longer be decrypted after a salt change. They open the settings page. The migration touches that dead key, a global alarm goes up. They're running OpenAI just fine, but they catch a "decryption failed" because of a Claude key they abandoned. The alarm is ringing correctly. It's just pointing at the wrong thing. The signal is emitted — but the truth it points to is the wrong truth.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn26yxd1aih1e5theetn8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn26yxd1aih1e5theetn8.png" alt="The active provider is OpenAI, yet a dead Claude key touched by the migration raises a global alarm and the notice fires unconditionally." width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's what clicked. This misfire was never a bug &lt;code&gt;wpai&lt;/code&gt; introduced. It became possible the moment the design allowed multiple providers' keys to be stored at once — one active provider, but any old key could raise the global alarm. Nobody hit that path, so nobody noticed. Adding a keyless provider, and finally asking "whose key is this, and is it the active one?", was what surfaced it.&lt;/p&gt;

&lt;p&gt;So I didn't fix it by adding a &lt;code&gt;wpai&lt;/code&gt; exception. I generalized the display check into a four-gate test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// notice display check: "is the ACTIVE provider's own key actually the problem?"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'wpai'&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;// (1) keyless =&amp;gt; plugin-side key is irrelevant&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="s1"&gt;'s *_api_key is empty)              return;  // (2) never set
if (active'&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="n"&gt;own&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="n"&gt;decrypts&lt;/span&gt; &lt;span class="n"&gt;fine&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;// (3) failure belongs to some other unused provider&lt;/span&gt;
&lt;span class="c1"&gt;// (4) only here: the active provider's own key is genuinely broken&lt;/span&gt;
&lt;span class="nf"&gt;show_notice&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;=== 'wpai'&lt;/code&gt; isn't a tacked-on exception. It's the first branch of the question "is the active provider's own key actually the problem?" The ordering itself says so.&lt;/p&gt;

&lt;p&gt;The takeaway, I think, is this. Adding a new abstraction doesn't just cost you N new exceptions. Sometimes it surfaces the fact that an invariant was never holding in the first place. &lt;code&gt;wpai&lt;/code&gt; broke one of my two unspoken assumptions — "every provider has a key" — and taught me the other one, "a decryption failure means the one key is broken," had been false all along.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The assumption that "every provider has an API key" was false from the start.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Retrying once on the &lt;code&gt;temperature&lt;/code&gt; 400
&lt;/h2&gt;

&lt;p&gt;That was the heavy stuff. The last one is a humbler move, worth recording.&lt;/p&gt;

&lt;p&gt;When the model behind Connectors is GPT-5 or an o-series, it won't accept a custom &lt;code&gt;temperature&lt;/code&gt; and returns 400. Those models have a fixed temperature, so the value you specify gets rejected. Known behavior.&lt;/p&gt;

&lt;p&gt;The handling is grubby. If the first generation returns an error, &lt;em&gt;and&lt;/em&gt; the error body contains the word &lt;code&gt;temperature&lt;/code&gt;, &lt;em&gt;and&lt;/em&gt; I actually specified a custom temperature — only when all three line up, I strip &lt;code&gt;temperature&lt;/code&gt; and send once more.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// first attempt: with temperature&lt;/span&gt;
&lt;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;build_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate_text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// only if the error body contains "temperature" AND I set a temperature: drop it and retry, once&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;is_wp_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;stripos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$text&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_error_message&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'temperature'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'temperature'&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;span class="nv"&gt;$text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;build_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$options&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generate_text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// no loop, no recursion, no flag. "exactly once" is guaranteed by the structure.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The humbleness is in two places.&lt;/p&gt;

&lt;p&gt;One: the decision is a substring match on a human-readable message, not a structured code. &lt;code&gt;stripos&lt;/code&gt; just fishes for the word &lt;code&gt;temperature&lt;/code&gt;. It bets on the wording of the message, not a stable contract like an error code. The third &lt;code&gt;isset&lt;/code&gt; guard pulls its weight — if a &lt;code&gt;temperature&lt;/code&gt; error shows up when I never set one, it won't waste a retry.&lt;/p&gt;

&lt;p&gt;Two: there's nothing watching to enforce "exactly once." No loop, no recursion, no retry flag. The retry is a single &lt;code&gt;if&lt;/code&gt; block, and the strip is &lt;code&gt;build_prompt()&lt;/code&gt; with &lt;code&gt;$skip_temperature = true&lt;/code&gt;, which omits the &lt;code&gt;using_temperature()&lt;/code&gt; call entirely — not resetting to 1.0, just dropping the parameter and deferring to the model default. Straight-line code, so there's nothing to bound.&lt;/p&gt;

&lt;p&gt;But, to be honest: because this is a substring match on the error body, the day the SDK rewords or localizes that message, it stops working silently. A signal that arrives today, gone one day without telling anyone. It's a bet on a known, sufficiently stable quirk — made knowingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note to my next self
&lt;/h2&gt;

&lt;p&gt;The three sections are different stories — a story about types, about invariants, about a substring match. But re-reading them, the same shape runs underneath. In each one, the breakage happened. What went wrong was that the signal of it had come apart from the truth.&lt;/p&gt;

&lt;p&gt;Pass a raw array to &lt;code&gt;with_history()&lt;/code&gt; and it crashes loudly. But wrap it in a compatibility defense and the failure starts quietly dropping the history, and the log that would say so never reaches anyone behind &lt;code&gt;WP_DEBUG&lt;/code&gt;. The signal is &lt;em&gt;absent&lt;/em&gt;. The decryption notice is the inverse: the alarm rings fine — it just points at an old key you don't even use anymore. The signal points at &lt;em&gt;the wrong truth&lt;/em&gt;. The &lt;code&gt;temperature&lt;/code&gt; substring match works today and, the day the SDK rewords the message, stops working without a word. The signal &lt;em&gt;vanishes&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;In every case, the breakage didn't get communicated correctly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F12k2u5trxacoqy6iga05.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F12k2u5trxacoqy6iga05.png" alt="Absent, misdirected, vanishing. The three failures connect at one point: the breakage isn't communicated correctly." width="799" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To write this, I went looking for how I noticed all this at the time. I re-read the code, traced the commit log, checked the records of my work sessions and the verification notes I'd left. Nothing. The moment of noticing isn't recorded anywhere. What remained was the way I'd written the defensive comments and where I'd placed the &lt;code&gt;try/catch&lt;/code&gt; — the marks left in the code, and only those.&lt;/p&gt;

&lt;p&gt;What carried my past self's decisions to my future self wasn't memory. It was the code.&lt;/p&gt;

&lt;p&gt;If that's true, then the defensive code and comments I write today are a note to a self who will have forgotten all of it. Not a resolution — just a fact. So:&lt;/p&gt;

&lt;p&gt;Give the safety net an alarm that actually rings. Don't hide the log behind &lt;code&gt;WP_DEBUG&lt;/code&gt;. When you fall back to a default — dropping history, folding a role, swinging at a string — leave a mark so the fallback can be heard. What breaks quietly also robs you of the chance to fix it quietly.&lt;/p&gt;

&lt;p&gt;The next self to open this will probably remember none of it. The only thing that remembers is the code.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raplsworks.com/wp-ai-client-history-marshalling/" rel="noopener noreferrer"&gt;raplsworks.com&lt;/a&gt;. The broader read on the WP AI Client API and the migration plan for this plugin is in the &lt;a href="https://raplsworks.com/wp-ai-client-wordpress-7-0/" rel="noopener noreferrer"&gt;companion piece&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building "あ" -&gt; "雨": Japanese Suggest Without IME Access</title>
      <dc:creator>Rapls</dc:creator>
      <pubDate>Sun, 19 Apr 2026 03:55:46 +0000</pubDate>
      <link>https://dev.to/rapls/building-a-yu-japanese-suggest-without-ime-access-ggi</link>
      <guid>https://dev.to/rapls/building-a-yu-japanese-suggest-without-ime-access-ggi</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This article was originally published on &lt;a href="https://raplsworks.com/hiragana-kanji-suggest-javascript/" rel="noopener noreferrer"&gt;my blog (Rapls Works)&lt;/a&gt; in Japanese. I've translated and adapted it for dev.to readers, with extra context for non-Japanese audiences.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You've probably seen this: type &lt;code&gt;あ&lt;/code&gt; into Google's Japanese search box, and you instantly see kanji suggestions — &lt;code&gt;雨&lt;/code&gt; (rain), &lt;code&gt;赤&lt;/code&gt; (red), &lt;code&gt;青&lt;/code&gt; (blue), &lt;code&gt;秋&lt;/code&gt; (autumn). Nice UX. So I tried to build it on my own site.&lt;/p&gt;

&lt;p&gt;My first instinct: &lt;em&gt;"I'll just read the IME's conversion candidates from JavaScript."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Turns out, that's &lt;strong&gt;impossible by design&lt;/strong&gt;. Browsers deliberately hide IME internals from the DOM, for some very good security reasons we'll get into.&lt;/p&gt;

&lt;p&gt;So I had to route around it. &lt;strong&gt;The trick is to maintain your own &lt;code&gt;{kanji, reading}&lt;/code&gt; dictionary and do a prefix search against the reading field in hiragana.&lt;/strong&gt; Instead of piggybacking on the IME, you ignore it and build your own lookup.&lt;/p&gt;

&lt;p&gt;Bonus: the techniques in this post apply to Chinese, Korean, and Vietnamese input too — anywhere the browser's composition API is involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IME candidates are unreachable from JavaScript&lt;/strong&gt; for privacy reasons&lt;/li&gt;
&lt;li&gt;Build your own reading dictionary and do &lt;code&gt;startsWith&lt;/code&gt; prefix search&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;compositionstart&lt;/code&gt; / &lt;code&gt;compositionend&lt;/code&gt; to gate searches during IME composition&lt;/li&gt;
&lt;li&gt;Debounce (~150ms) to avoid flicker during consecutive confirmations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tested on Chrome 147 / macOS Tahoe 26.4 / ES6+ (April 2026).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why You Can't Read IME Candidates
&lt;/h2&gt;

&lt;p&gt;Short answer: &lt;strong&gt;IMEs run at the OS level, and browsers intentionally don't expose their internal state to JavaScript. This is a security and privacy decision.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Think about it. If a website could read your IME's suggestion list, it would see what you're about to type &lt;em&gt;before you confirm it&lt;/em&gt;. Worse, IMEs have learning features — they remember family names, addresses, and employer names you've typed before. Leaking that to websites would be a huge privacy problem.&lt;/p&gt;

&lt;p&gt;What browsers give you instead: just two signals — &lt;em&gt;whether composition is in progress&lt;/em&gt;, and &lt;em&gt;whether it's finished&lt;/em&gt;. Nothing about the candidates themselves.&lt;/p&gt;

&lt;p&gt;So you need a different approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mental Shift: "Conversion" → "Search"
&lt;/h2&gt;

&lt;p&gt;Forget the IME. Maintain a dictionary of &lt;code&gt;{text, reading}&lt;/code&gt; pairs yourself, and do prefix matching on the reading.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Dictionary:
  { text: "雨",   reading: "あめ" }   // rain
  { text: "赤",   reading: "あか" }   // red
  { text: "青",   reading: "あお" }   // blue
  { text: "秋",   reading: "あき" }   // autumn

Input "あ"   → all readings starting with "あ" match → 雨, 赤, 青, 秋
Input "あめ" → only readings starting with "あめ" → 雨
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Japanese words are typed from the first character onward, so prefix search fits naturally. The candidate list narrows as the user types — exactly how Google's suggestions feel.&lt;/p&gt;

&lt;p&gt;Three pieces to build: dictionary, search logic, UI. Let's go.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;input&lt;/code&gt; Event Misfire Problem
&lt;/h2&gt;

&lt;p&gt;If you're coming from building English autocomplete, you're used to just listening to &lt;code&gt;input&lt;/code&gt;. That doesn't work here.&lt;/p&gt;

&lt;p&gt;When typing &lt;code&gt;あめ&lt;/code&gt; via the IME, &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;m&lt;/code&gt;, &lt;code&gt;e&lt;/code&gt; keypresses fire the &lt;code&gt;input&lt;/code&gt; event &lt;strong&gt;three times before confirmation&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Press "a"     → input fires (field: "あ")    ← not confirmed
2. Press "m"     → input fires (field: "あm")   ← not confirmed
3. Press "e"     → input fires (field: "あめ")  ← not confirmed
4. Press Enter   → input fires (field: "あめ")  ← confirmed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you search on every &lt;code&gt;input&lt;/code&gt;, you'll flash candidates at &lt;code&gt;あ&lt;/code&gt;, clear them at &lt;code&gt;あm&lt;/code&gt;, flash them again at &lt;code&gt;あめ&lt;/code&gt;, then settle at &lt;code&gt;あめ&lt;/code&gt;. The UI jitters horribly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: &lt;code&gt;compositionstart&lt;/code&gt; / &lt;code&gt;compositionend&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Browsers expose three events for IME composition state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;compositionstart&lt;/code&gt; — IME composition begins&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;compositionupdate&lt;/code&gt; — composition text changes (e.g., romaji → hiragana)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;compositionend&lt;/code&gt; — composition finishes (user confirms)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The right place to trigger search is &lt;strong&gt;&lt;code&gt;compositionend&lt;/code&gt;&lt;/strong&gt;. Search only on confirmation, and the misfire problem is gone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cross-Browser Caveat
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;e.isComposing&lt;/code&gt; on the &lt;code&gt;input&lt;/code&gt; event can be unreliable, particularly on older Safari and iOS. Keep a manual flag as a backup:&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;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;searchInput&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;isComposing&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="c1"&gt;// Composition start → set flag&lt;/span&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;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;isComposing&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="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Composition end → clear flag → search&lt;/span&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;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;isComposing&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="nf"&gt;performSearch&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;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;// Input event → skip if composing&lt;/span&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;input&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;isComposing&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="c1"&gt;// noop during composition&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;performSearch&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;target&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="c1"&gt;// non-IME input (e.g., English)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The double check &lt;code&gt;e.isComposing || isComposing&lt;/code&gt; absorbs browser differences. Works on Chrome, Firefox, Safari, Edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debounce: Stop Flicker on Consecutive Confirmations
&lt;/h2&gt;

&lt;p&gt;We've fixed composition. Next problem: &lt;strong&gt;consecutive confirmations&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Typing 天気予報 (weather forecast) typically happens as two IME confirmations: &lt;code&gt;てんき&lt;/code&gt; then &lt;code&gt;よほう&lt;/code&gt;. If we search on each, the &lt;code&gt;てんき&lt;/code&gt; results flash and disappear. Useless flicker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debounce&lt;/strong&gt;: wait for input to stop changing for ~150ms before running the callback. While the user keeps typing, reset the timer. When they settle, run once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;func&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;timeoutId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&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;timeoutId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeoutId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;timeoutId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&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;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;delay&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;debouncedSearch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;performSearch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;100–300ms is typical. 150ms hits the sweet spot for local searches (dictionary in memory) — essentially instant to the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prefix Search
&lt;/h2&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;searchByReading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&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;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;q&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;// Match on reading OR on the text itself&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dictionary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&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;return&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
           &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Exact matches first, then prefer shorter readings&lt;/span&gt;
  &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;aExact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reading&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;q&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;bExact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reading&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;q&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;aExact&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;bExact&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="mi"&gt;1&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;aExact&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;bExact&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&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;We match both reading and text so &lt;code&gt;アメリカ&lt;/code&gt; (America, in katakana) matches on both &lt;code&gt;あめりか&lt;/code&gt; (hiragana) and &lt;code&gt;アメリカ&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sort by exact match first, then prefer shorter readings (more specific). Cap at 10 — too many options hurts usability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't Skip HTML Escaping
&lt;/h2&gt;

&lt;p&gt;Injecting dictionary entries into the DOM without escaping opens an XSS hole. Even if your dictionary is 100% under your control today, you'll likely add user-submitted entries or API data later. Escape from day one.&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;escapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&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;div&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&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;div&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// When inserting into DOM&lt;/span&gt;
&lt;span class="nx"&gt;li&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&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;textContent&lt;/code&gt; + reading back via &lt;code&gt;innerHTML&lt;/code&gt; auto-escapes &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt;, etc. Boring but essential.&lt;/p&gt;

&lt;h2&gt;
  
  
  All Together
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// === Dictionary ===&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dictionary&lt;/span&gt; &lt;span class="o"&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;text&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="na"&gt;reading&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&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="na"&gt;reading&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&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="na"&gt;reading&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&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="na"&gt;reading&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&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="na"&gt;reading&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&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="na"&gt;reading&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&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="na"&gt;reading&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&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="na"&gt;reading&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;// ... customize for your domain&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// === Utilities ===&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;func&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;timeoutId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&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;timeoutId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeoutId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;timeoutId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;delay&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;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;escapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&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;div&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&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;div&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// === Search ===&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;searchByReading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&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;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;q&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dictionary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;aExact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reading&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;q&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;bExact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reading&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;q&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;aExact&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;bExact&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="mi"&gt;1&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;aExact&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;bExact&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// === Main ===&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;searchInput&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;isComposing&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;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;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;isComposing&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="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="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;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;isComposing&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="nf"&gt;updateSuggestions&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;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateSuggestions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&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;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;searchByReading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Results:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// → next step: render UI&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;debouncedUpdate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updateSuggestions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&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="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;input&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;isComposing&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="nf"&gt;debouncedUpdate&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;target&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;h3&gt;
  
  
  Verify the Behavior
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Typing &lt;code&gt;あめ&lt;/code&gt; via IME and confirming:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Press "a"      → compositionstart → isComposing = true
2. input event    → skipped (isComposing is true)
3. Press "m", "e" → same, skipped
4. Press Enter    → compositionend → isComposing = false → updateSuggestions('あめ')
5. Results: [{ text: '雨', reading: 'あめ' }, { text: 'アメリカ', reading: 'あめりか' }]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Typing &lt;code&gt;test&lt;/code&gt; without IME:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. No compositionstart → isComposing stays false
2. input event → debouncedUpdate called
3. 150ms later → updateSuggestions runs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both paths work correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Works for CJK, Not Just Japanese
&lt;/h2&gt;

&lt;p&gt;One thing worth calling out: this same pattern works for Chinese, Korean, and Vietnamese too. The composition events are defined at the browser level, not specific to Japanese. If you're building a multilingual input UX, the same &lt;code&gt;compositionstart&lt;/code&gt; / &lt;code&gt;compositionend&lt;/code&gt; logic covers all CJK-family input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next: The UI Layer
&lt;/h2&gt;

&lt;p&gt;At this point we have the &lt;strong&gt;brain&lt;/strong&gt; — IME handling + search logic. The &lt;strong&gt;body&lt;/strong&gt; — dropdown UI, keyboard navigation (↑/↓/Enter/Escape), focus management, click handling — is coming in the follow-up.&lt;/p&gt;

&lt;p&gt;The sequel will also cover server-side integration, external API patterns, and performance tuning (trie structures, Web Workers for &amp;gt;10k entry dictionaries).&lt;/p&gt;

&lt;p&gt;👉 Part 2 (originally in Japanese, code blocks in English): &lt;a href="https://raplsworks.com/hiragana-kanji-suggest-advanced/" rel="noopener noreferrer"&gt;Japanese Suggest — Full Version: Keyboard Operations, blur Pitfalls, API Integration&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Related deep-dive — I also wrote about a weird macOS bug where the first character of Japanese input sometimes commits as English. It's a good look at how IME composition interacts with OS-level input handling:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://raplsworks.com/macos-nihongo-1mojime-eiji/" rel="noopener noreferrer"&gt;The "First Character Stuck in English" Problem on macOS&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"あ → 雨" is solved with prefix search on readings, not IME conversion.&lt;/strong&gt; IME candidates are off-limits to browsers, so maintain your own dictionary and use &lt;code&gt;startsWith&lt;/code&gt; for matching.&lt;/p&gt;

&lt;p&gt;The Japanese-specific challenge is coexisting with the IME. Use &lt;code&gt;compositionstart&lt;/code&gt; / &lt;code&gt;compositionend&lt;/code&gt; to gate searches, and add debouncing to prevent flicker. Put those together and you have autocomplete that feels native to Japanese input.&lt;/p&gt;




&lt;p&gt;Have you built Japanese or CJK autocomplete before? I'd love to hear about your approach — especially if you've tackled this with a larger dictionary (Trie/WFST structures, etc.) or with a server-side lookup. Drop it in the comments.&lt;/p&gt;

&lt;p&gt;This post was originally published at &lt;a href="https://raplsworks.com/" rel="noopener noreferrer"&gt;Rapls Works&lt;/a&gt;. If you're into WordPress plugin development, Japanese i18n, or edge-case IME behavior, there's more over there.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>i18n</category>
    </item>
  </channel>
</rss>
