日本語Streamlitアプリが「ブラウザの自動翻訳」でクラッシュする(NotFoundError: removeChild)

自分のコードは悪くない — 犯人はブラウザの自動翻訳(Streamlit)
mausu110

「自分のコードは悪くない」のに管理画面が落ちた

自宅サーバーで動かしている副業アイデアの自動生成パイプライン、その承認ダッシュボードをStreamlitで作っています。Pythonだけで管理画面が作れるので、へっぽこエンジニアの私にはありがたいツールです。

ところがある日、候補を「却下」するボタンを押した瞬間、画面が真っ赤なエラーで埋まりました。

しかも出てきたエラーがこれです。

NotFoundError: 'Node' に対して 'removeChild' を実行できませんでした:
削除対象のノードはこのノードの子ではありません。
  at Fc (http://.../static/js/react-dom.xxxx.js:8:25836)
  at Lc (http://.../static/js/react-dom.xxxx.js:8:27110)
  ...

スタックトレースがreact-dom(StreamlitのUIを裏で動かしているライブラリ)だけで完結していて、私の書いたPythonコードが1行も出てきません。「Streamlit本体のバグを踏んだか……」と一瞬あきらめかけました。

結論から言うと、犯人はChromeの自動翻訳機能でした。 アプリのコードにもStreamlitにも問題はありません。日本語で書いた日本語のアプリが、Chromeに「英語のページ」だと誤認されて日本語に翻訳され、その翻訳がDOMをいじった結果クラッシュしていた、という話です。

この記事では、原因にたどり着いた決め手と、実機で効果を確認した応急処置・恒久対策までをまとめます。同じ症状で悩んでいる方は、まず自分の画面の文言を疑ってみてください。

今回の環境と使ったもの

  • Streamlit(Python 3.11 venv上) … Pythonのスクリプトだけで管理画面が作れるフレームワーク。HTMLもJavaScriptも書かずにボタンや表が置けるのが最大の魅力です。今回の障害は、この手軽さの裏側にある「配信されるHTMLに手が届かない」という性質が効いてきます。
  • Google Chrome … 検証に使ったブラウザ。翻訳機能を持つブラウザなら他でも同種の問題は起こり得ます。
  • systemd … ダッシュボードを常駐サービスとして起動している仕組み。恒久対策では、この起動シーケンスにパッチ処理を挟み込みます。

症状と、原因にたどり着いた決め手

問題の画面と、症状の紛らわしさ

こちらが対象の管理画面です。

システム状況タブ
システム状況タブ

このStreamlit製の画面でボタンを押すとクラッシュしました。

候補一覧タブ
候補一覧タブ

厄介なのは、DBの更新自体は成功していたという点です。エラーが出るのは処理が終わったあとの再描画フェーズなので、「操作は通っているのにエラー画面が出る」という状態になります。

ここで「失敗したんだな」と早合点してもう一度ボタンを押すと、二重更新を招きます。私は幸い踏みとどまりましたが、これは結構こわいポイントでした。

原因を突き止めた決め手は「画面の文言がソースと違う」

スタックトレースをいくら眺めても、react-domの内部としか書いていないので何も分かりません。突破口になったのは、エラーが出た画面のスクリーンショットと、自分のソースコードの文字列を突き合わせたことでした。

ソースコードの文言画面に表示されていた文言
収益性知覚性
ステータス変更:サイズ変更
※現時点では承認しても…反省では承認しても…

日本語が、別の日本語に化けています。

「収益性」が「知覚性」って何だ、と思いますよね。これが翻訳エンジンが噛んでいる動かぬ証拠でした。人間の書き間違いなら、こんな置換の仕方はしません。

同義語ですらない不自然な置換、たとえば「現時点では」→「反省では」のような化け方を見かけたら、ほぼ確実に機械翻訳が介在しています。表示が微妙におかしいときは、まず翻訳を疑うサインだと覚えておくと早いです。

なぜ落ちるのか、そしてなぜ翻訳されてしまうのか

Reactは、自分が描画したDOMノードがどこにあるかを「仮想DOM」として記憶しています。いわば、棚のどこに何を置いたかを台帳に控えている状態です。

一方でGoogle翻訳は、元のテキストノードを<font>要素などでラップした別のノードに物理的に差し替えます。台帳を見ていない誰かが、勝手に棚の中身を入れ替えてしまうわけです。

この状態でボタンを押すとStreamlitがrerun(再実行・再描画)を行い、Reactは台帳どおりに元のノードをparent.removeChild(node)で片付けようとします。ところがそのノードはもう親の子ではないので、NotFoundErrorで落ちる、という筋書きです。

React + Google翻訳の組み合わせで古くから知られた問題ではあるのですが、日本語のアプリでこれが起きるとは普通は思わないのが最大の落とし穴でした。

なぜ日本語ページが翻訳されてしまうのか

ここが今回いちばん腑に落ちなかった部分です。中身は全部日本語なのに、なぜChromeは翻訳しようとするのか。

答えは、Streamlitが配信するHTMLが<html lang="en">固定だからでした。

$ grep -o '<html[^>]*>' .../site-packages/streamlit/static/index.html
<html lang="en">

lang属性は、そのページが何語で書かれているかをブラウザに伝えるものです。Chromeはこれを言語判定の材料に使うため、中身が全部日本語でも「これは英語のページだ」と判定し、日本語への翻訳を提案・実行してしまいます。

そしてユーザーが過去に一度でも「英語を常に翻訳」を選んでいると、以降は無言で自動翻訳が走ります。私の環境がまさにこれでした。翻訳バーを見た記憶すらなかったので、原因にたどり着くまで遠回りしたわけです。

なお、Streamlitには2026-08時点でこのlangを設定するオプションがありません。公式のconfig.tomlリファレンスを見ても、設定項目に言語やロケールに関するものは1つもありません(あるのはGlobal・Logger・Client・Runner・Server・Browser・Mapbox・Themeなどです)。UIの多言語化を求めるIssue #1353も、2020年に立ってから未解決のままです。つまりこの問題は、日本語(や英語以外)でStreamlitアプリを作った人なら誰でも踏みうるものです。

対策:まず応急処置、そのうえで恒久対策

まずは即効性のある方法です。components.htmlを使って、親ドキュメントに翻訳抑止の設定を注入します。

Streamlitのコンポーネントはiframe(ページの中に埋め込まれた別ページ)の中で動くため、本体のHTMLを触るにはwindow.parent.documentを経由する必要があります。

import streamlit.components.v1 as components

def disable_browser_translation() -> None:
    components.html(
        """
        <script>
          const doc = window.parent.document;
          doc.documentElement.lang = 'ja';
          doc.documentElement.setAttribute('translate', 'no');
          doc.documentElement.classList.add('notranslate');
          if (!doc.querySelector('meta[name="google"][content="notranslate"]')) {
            const meta = doc.createElement('meta');
            meta.name = 'google';
            meta.content = 'notranslate';
            doc.head.appendChild(meta);
          }
        </script>
        """,
        height=0,
    )

disable_browser_translation()   # set_page_config() の直後に呼ぶ

set_page_config()の直後に呼ぶのがポイントです。

効果: これだけでクラッシュは止まりました(実機確認済み)。translate="no"notranslateクラスによって、翻訳エンジンが本文に手を出さなくなるためです。

限界: このスクリプトが走るのはページ読み込みのです。Chromeの言語判定はそれより前に終わっているので、翻訳バー(バナー)自体は出続けます。実害はないのですが、使う人には「これ翻訳されてるんじゃ……」という不安を与えてしまいます。

対策2: index.htmllangを書き換える(恒久対策)

バーごと消したいなら、配信元のHTMLそのものを直します。最初から日本語ページだと分かれば、Chromeは翻訳の対象にすらしません。

# patch_streamlit_lang.py
import glob, sys

PATTERN = ".../site-packages/streamlit/static/index.html"
BEFORE = '<html lang="en">'
AFTER = '<html lang="ja" translate="no">'

def main() -> int:
    for path in glob.glob(PATTERN):
        with open(path, encoding="utf-8") as f:
            html = f.read()
        if AFTER in html:
            print(f"[patch_lang] 適用済み: {path}")
            continue
        if BEFORE not in html:
            print(f"[patch_lang] 想定文字列なし(仕様変更?): {path}", file=sys.stderr)
            continue
        with open(path, "w", encoding="utf-8") as f:
            f.write(html.replace(BEFORE, AFTER, 1))
        print(f"[patch_lang] lang=ja に書き換えました: {path}")
    return 0

if __name__ == "__main__":
    sys.exit(main())

BEFOREが見つからないときに黙って握りつぶさずstderrに出しているのは、Streamlit側の構造が変わったことに気付けるようにするためです。ただしダッシュボードの起動自体は止めません。翻訳バーが出るだけで機能は動くので、そこで落とすほどではないという判断です。

落とし穴: このパッチはアップグレードで消える

そう、site-packagesの配下を直接いじっているので、pip install -U streamlitした瞬間に元に戻ります。忘れた頃に障害が再発する典型パターンです。

そこで、systemdのExecStartPreを使って起動のたびに当て直すようにしました。上のスクリプトは冪等(べきとう。既に適用済みなら何もしない)に書いてあるので、毎回実行しても問題ありません。

[Service]
ExecStartPre=/path/to/venv/bin/python3 /path/to/patch_streamlit_lang.py
ExecStart=/path/to/venv/bin/streamlit run dashboard.py ...

実際の実行ログがこちらです。2回目以降はちゃんと素通りしています。

1回目: [patch_lang] lang=ja に書き換えました: .../streamlit/static/index.html
2回目: [patch_lang] 適用済み: .../streamlit/static/index.html

これで、Streamlitを上げても勝手に復活してくれます。

つまずいたポイントと、同じ罠を踏まないためのコツ

今回の調査で得た教訓を3つにまとめます。

1. スタックトレースがライブラリ内部で完結しているときは、アプリの外を疑う。
今回のように、真因がブラウザ・拡張機能・プロキシといった「自分のコードの外側」にあることがあります。ライブラリのバグを疑い始める前に、画面に表示されている内容が、自分の書いた文言と一致しているかを確認してください。私はここに気付くまでずいぶん遠回りしました。

2. lang属性は「アクセシビリティのおまけ」ではない。
機能そのものに影響します。多言語対応していないフレームワークを非英語圏で使うときは、まずここを確認するのがおすすめです。

3. site-packagesへのパッチは、当てた瞬間から負債になる。
いつ消えるか分かりません。冪等なスクリプトにして起動シーケンスに組み込んでおけば、アップグレード後も自動で復活します。「手で当てて終わり」にしないことが大事でした。

あと地味に効いたのが、症状が「エラーは出るが処理は成功している」だったことです。この手の症状に出会ったら、失敗したと決めつけて再実行する前に、まずDBの中身を確認してください。二重更新のほうがよほど後始末が面倒です。

まとめ

症状から真因までの流れを表にまとめておきます。

内容
症状Streamlitアプリでボタンを押すとNotFoundError: removeChild。ただしDB更新は成功している
真因Streamlitの<html lang="en">をChromeが信じて日本語ページを自動翻訳し、翻訳がDOMを差し替えるためReactの再描画が失敗する
見つけ方画面の文言とソースコードの文字列を突き合わせる。不自然な同義語置換があれば翻訳を疑う
応急処置components.htmltranslate="no" / notranslateを注入(クラッシュは止まるが、バーは出る)
恒久対策static/index.htmllangjaに書き換え、ExecStartPreで起動ごとに当て直す

「react-domの中で落ちている」と聞いた時点でフレームワークのバグだと思い込んでいたのですが、実際にはブラウザの親切機能が原因でした。エラーメッセージが指し示す場所と、本当の原因の場所は、必ずしも近くないという良い教訓になりました。

今後の展望としては、Streamlit本体にlangを設定するオプションが入ってくれるのがいちばんありがたいです。それまではExecStartPreのパッチで凌ぎます。同じように日本語でStreamlitアプリを作っている方は、何も起きていなくても一度index.htmlを覗いてみることをおすすめします。

ABOUT ME
やまさん
やまさん
へっぽこITエンジニア
ねずみさんを愛するインフラエンジニア。 とにかく最新が好きで、ココロオドル体験を日夜求め続けています! 直近だとドローンにハートを撃ち抜かれてます。 空を自由に飛びたいな♪
記事URLをコピーしました