React コンポーネントの改修で意図せず大域変数を参照してしまった事象の考察と対策

事象

以下のような React コンポーネントがあった。

interface SomeComponentProps {
  close: () => void;
}

export const SomeComponent: FC<SomeComponentProps> = ({ close }) => {
  return (
    <>
      <Foo close={close} />
      <Bar />
      ...
    </>
  );
};

機能の改修によって Fooclose を渡す必要がなくなり、その結果 close への参照がなくなったので props から close を削除した。

export const SomeComponent: FC = () => {
  return (
    <>
      <Foo />
      <Bar />
      ...
    </>
  );
};

ところが、参照がなくなったというのは思い込みで、実は他の箇所から参照されているのを見落としていた。

export const SomeComponent: FC = () => {
  return (
    <>
      <Foo />
      <Bar />
      ...
      <Baz close={close} />
    </>
  );
};

このような場合、大抵は未定義の変数を参照しようとしてエラーに気づくことができるだろう。ところが運の悪いことに、今回の事象では大域変数であるところの Window.close を参照する形になってしまい、エラーにならず様々な目をすり抜けてしまった。また、TypeScript を用いたコードベースだが、当該箇所はシグネチャー的にも問題なく代入できてしまったために型のチェックもすり抜けてしまった。

Window.closewindow.close()1 の形で呼び出すことが多いかもしれないが、単に close で参照できてしまう。これは、Window がグローバルオブジェクトであり、そのプロパティがグローバルスコープに追加されているため。

https://developer.mozilla.org/en-US/docs/Glossary/Global_object

In a web browser, any code which the script doesn't specifically start up as a background task has a Window as its global object. ... The properties of the global object are automatically added to the global scope.

結果として、本来意図した「閉じる」挙動の代わりに、何も起きないかあるいは稀にブラウザのタブやウィンドウが閉じられてしまう事態になった2

考察

これはどうすれば防げただろうか。また、繰り返さないためにはどうすればいいだろうか。

  • テストを書く
  • 大域変数への参照をエラーとする
  • 大域変数の名前と衝突しないように局所変数命名する

テストを書く

真っ先に思いつくべきはこれだろう。具体的に何をすべきかも自明だ。

そもそもテストがなかった理由については、当該コンポーネントが実装された当時とにかくスケジュールに追われており、実装者もレビュワーもテストへの意識が疎かになっていたであろうことが挙げられる。そして、技術的負債として認識されつつも工数を捻出する機会がなく今に至る。とはいえ、改修時に無いものは無いと認識してテストを追加するよう意識すべきだったという反省がある。

また、単純な機能ほどテストを書く意識が薄れてしまいがちだが、リグレッションを避ける意味での重要性を再確認するのには良い機会だった。

他にも色々と思うところはあるが本筋から逸れるので割愛。

大域変数への参照をエラーとする

テストを書くのはそれとして、不注意によるミスは生じうるもの。そこで、いっそのこと紛らわしい大域変数を参照できないようにしてしまえばいいのではないかという発想に至った。

その方針で調べたところ、TypeScript の issue の中で ESLint の no-restricted-globals ルールを用いる解決策が提示されていた。これなら必要に応じて間違いやすいものだけを選んだり、documentlocation などよく使うものを除いて禁止するなど融通が利いていい。今回のような事象を未然に防ぐものとしては十分なので、早速採用することにした。

筆者の場合は特に何も除外せず // eslint-disable-next-line などで部分的に例外を作るアプローチを採用した。理由は以下。

  • 大域変数への参照が多くないこと
  • 何を「間違いやすい」とするかは主観によるところが大きく、線引きが面倒
  • 不都合が生じてから見直せばいい

大域変数の名前と衝突しないように局所変数命名する

ところで、React の props の命名において、イベントハンドラーは on を接頭辞にするのが通例だ (e.g. onClose)。客観的事実として、そのような命名規則を遵守させるためのルールが eslint-plugin-react に存在する (react/jsx-handler-names)。問題となったコードベースにおいても基本的にはそのような命名が心がけられているのだが、あくまで指針レベルであり静的解析に組み込んだりはしていない。今回の事象に関してはこのルールを組み込んでいれば免れたかもしれない。

と、ここまでは props のプロパティ名に限った話。

今回のケースからは逸脱するが、根源を辿れば祖先にあたるコンポーネントの中で関数を定義しているはずで、その識別子の命名は props の命名とはまた別の話になる。とはいえ、これも react/jsx-handler-names のルールの通り handle を接頭辞にするのが通例で、当該ルールを組み込めば問題にならないだろう。

export const Parent: FC = () => {
  const handleClose = useCallback(() => { ... }, []);

  return <Child onClose={handleClose} />;
};

ただし、グローバルスコープには onload のように on から始まるものも潜んでいるので油断はできない3。これらが小文字のみであるのに対して props は camelCase での命名が一般的ではあるが、人間である以上は不注意で取り違えることもあるだろう。ただし、props に限った話ではこれまた前掲の react/jsx-handler-namescamelCase の命名を強制するようにできているため、これを導入すれば問題にならないかもしれない4

今回の対策としては前述の no-restricted-globals で事足りるし、修正すべき箇所が多いこともあって直近の導入は見送った。とはいえ、命名規則の観点においては指針が存在する以上は仕組みでカバーされる方が好ましいので、折を見て導入を検討したい。

感想

これまでも openclose を間違えて参照したことはあれど未然に気づけていたが、不注意が重なって今回の事態に至った。リスクと認識してはいただけに悔しいが、結果として学びから対策に繋げられたので良かったのではないか。

付録

環境及びバージョン等

さほどバージョンは重要でないはずだが一応。あくまで文中の記述の根拠として補助的に提示しているものであり、正確さを保証するものではない。


  1. 念のため、Window と window は表記揺れではない。
  2. Window.open によって開かれた場合を除けば基本的には何も起きなかったはず。

    https://developer.mozilla.org/en-US/docs/Web/API/Window/close

    This method can only be called on windows that were opened by a script using the Window.open() method.

  3. その他のものについては TypeScript の lib.dom.d.ts に定義される GlobalEventHandlers が参考になるだろう。
  4. そこまで考慮して設計されているのではないかと思うが、実際のところは知らないし調べてもいない。

ブラウザウィンドウの画面共有でターミナルを映す

背景

業務において画面を共有しながら会議や会話をする機会が多々ある。世間的にもリモートワークの普及に伴ってそういった機会が増えたのではなかろうか。

画面共有に際しては対象の領域を決める必要があるが、多くの場合はスクリーン全体かウィンドウの 2 種類から選ぶことになるだろう。 個人的にはサイズを調節して見やすくする意図があったり、散らかった画面を共有するのに気が引けたりと様々な理由でもってウィンドウ単位の共有を選択することが多い。

大抵はブラウザのウィンドウで事足りるのだが、まれにターミナルでコマンドを実行する様子を共有したくなることがある。その度に共有を停止して別のウィンドウを選択して…といった手間は地味に面倒なものだ。

ttyd を使ってブラウザでシェルを開く

ウェブでターミナル (TTY) を共有することを目的としたコマンドラインツールの 1 つに ttyd というものがある。これは homebrew-core 経由で提供されている。

brew install ttyd

以下のコマンドを叩けば規定のブラウザでシェルが開く。

ttyd -B "$SHELL"

ttyd 自体はサーバーとして機能し、WebSocket を介してターミナルを開放している。同時にクライアント向けの静的ファイルをサーブしており、それをブラウザで読み込んでターミナルにアクセスする形。 クライアントは Xterm.js がベースになっており、後述のオプションから設定を弄ることができる。

今回はコマンドとして現在のシェル ($SHELL) を指定しているが、シェルに限らず任意のコマンドが指定できる。

カスタマイズ

主題である画面共有での利用を主な目的として個人的によく使っているオプションを紹介する。

ttyd -B -i lo0 -t 'macOptionIsMeta=true' -t 'fontFamily=MesloLGM Nerd Font' -t 'theme={"background": "#000000"}' "$SHELL"

-B はサーバーを立ち上げると同時に規定のブラウザでページを開くためのオプション。

-i lo0 はループバックインターフェースにバインドするよう指定している。

-t はクライアント側の設定を操作するためのオプション。主に Xterm.js のオプションを上書きするのに使う。

macOptionIsMeta=truemacOS において option キーを meta キーとして扱うための指定である。 VS Code の Integrated Terminal にも Mac Option Is Meta という設定項目があるが、それと同じもの。 例えば Option + Fƒ が入力されたりするが、そうでなく key binding として機能するのが望ましい場合には必須。

fontFamily はその名の通りフォント指定のためのオプション。筆者は starship のプロンプトに合わせてカスタムフォントを導入しているのでそれを指定している。 環境によってはブラウザから対象のフォントを読み込めるかどうかに注意するとよい。

theme はテーマに関するオプション。JSON でオブジェクトを渡す形になる。Xterm.js の ITheme インターフェースを参照。

詳細については各種ドキュメントを参照されたい。

まとめ

ブラウザウィンドウを共有したまま必要に応じてサッとシェルを共有できて非常に便利。

付録

環境及びバージョン等