私が歌川です

@utgwkk が書いている

イベントハンドラを設定した要素内にreact-modalのモーダルを置くとイベントハンドラが反応する

tl;dr

イベントハンドラを設定した要素内にreact-modalのモーダルを置かないようにするのが手っ取り早そう。

イベントハンドラを設定したコンポーネント

以下の Clickable コンポーネントは、divに click イベントのハンドラを設定しており、かつchildrenを取るコンポーネントである。divをクリックするとコンソールにログを出力する。

import { FC, MouseEventHandler, useCallback } from "react";

export const Clickable: FC = ({ children }) => {
  const handleMouseDown: MouseEventHandler = useCallback((e) => {
    console.log(`clicked!!!! ${new Date()}`);
  }, []);

  return (
    <div onMouseDown={handleMouseDown}>
      {children}
    </div>
  );
};

モーダル、外に置くか? 中に置くか?

react-modalのモーダルを <Clickable> の外に置くか、それとも中に置くか、について考える。

外に置く

モーダルを <Clickable> の外に置くとこういう感じになる。モーダルを開くボタンだけが <Clickable> の中にある。とくに気にかけることもない。

<div>
  <h2>along with modal</h2>
  <Clickable>
    This area is clickable!!!!!
    <p>
      <button onClick={() => setIsOpen(true)}>open modal</button>
    </p>
  </Clickable>
  <Modal isOpen={isOpen} onRequestClose={handleRequestClose}>
    <div>
      <h2>Modal</h2>
      <button onClick={handleRequestClose}>close</button>
    </div>
  </Modal>
</div>

中に置く

一方で、モーダルを <Clickable> の中に置くとどうなるか? この場合もモーダルを開くことはできるし、モーダルが前面に出てくるのだが、モーダル内をクリックすると <Clickable> のイベントハンドラが反応してしまう。これは多くの場合は望ましい挙動ではないと思う。

<div>
  <h2>contains modal</h2>
  <Clickable>
    This area is clickable!!!!!
    <p>
      <button onClick={() => setIsOpen(true)}>open modal</button>
    </p>
    <Modal isOpen={isOpen} onRequestClose={handleRequestClose}>
      <div>
        <h2>Modal</h2>
        <button onClick={handleRequestClose}>close</button>
      </div>
    </Modal>
  </Clickable>
</div>

どうするか

モーダル内で、モーダルを設置した要素の親のイベントハンドラが呼ばれてほしくないなら、モーダルを外に出したほうがよい。

感想

普通はイベントハンドラを設定した要素の子にモーダルを設置することはないかもしれないけど、モーダルを動的に生成する場合に油断すると引っかかると思う。実際にこの現象に遭遇して、DOM treeの上ではreact-modalのモーダルは完全にroot要素の外にあるけど、Reactが管理するtreeの上では必ずしもそうなっていない*1のがデバッグを難しくしていた。CSSの pointer-events プロパティで解決できるかもしれない、と思っていろいろ試してみたけど解決できなかった。

サンプルコード

github.com

2021/8/2 追記

Reactのポータルのドキュメントを読んでいたところ、まさにこの現象に該当することがドキュメントに書いてあった。

ポータルは DOM ツリーのどこにでも存在できますが、他のあらゆる点では通常の React の子要素と変わらずに振る舞います。コンテクスト (context) のような機能は、たとえ子要素がポータルであろうと全く同じように動きます。というのも、DOM ツリー上の位置にかかわらず、ポータルは依然として React のツリー内にいるからです。

これにはイベントのバブリングも含まれます。ポータルの内部で発火したイベントは React のツリー内の祖先へと伝播します。たとえそれが DOM ツリー上では祖先でなくともです。

ja.reactjs.org

ということは、react-modalのオーバーレイに対して event.stopPropagation() するだけのイベントハンドラを設定しても回避できるかもしれない。

*1:React Developer Toolsで確認できる

RelayでGraphQL multipart request specificationに沿ったファイルアップロードを行う

はじめに

2021/7/17現在、GraphQL APIを通じてファイルをアップロードする方法は規定されていない。そのため、GraphQL APIでファイルアップロードを行いたい場合は何らかの工夫を行う必要がある。

GraphQL APIリクエストの変数中にファイルをエンコードして送信することで、ファイルをアップロードするという目的は達成できそうに見える。が、この方法では巨大なJSONをリクエストボディとして送信する必要があり、効率がよくない。また、各種APIサーバーがファイルアップロードのためにメモリを効率的に利用する実装*1を行っていても、その恩恵を受けづらい。

GraphQL multipart request specificationという仕様に則ると、リクエストの変数中にファイルをエンコードして送信する方法と比べて、効率的にファイルをアップロードすることができる。仕様の解説やAPIサーバー側の実装方法については以下の記事が詳しいので、この記事では割愛する。

blog.agile.esm.co.jp

この記事では、RelayでGraphQL multipart request specificationに沿ってファイルをアップロードする方法を解説する。クライアントの実装言語としてTypeScriptを用いる。

目次

スキーマ

この記事中でのスキーマは以下。Upload scalarがアップロードするファイルを表している。

type Mutation {
  uploadArtwork(input: UploadArtworkInput!): UploadArtworkPayload
}

scalar Upload

input UploadArtworkInput {
  ...

  files: [Upload!]!
}

type UploadArtworkPayload {
  artwork: Artwork
}

ファイルをアップロードするmutationを実行する

以下は2つのファイルをアップロードするmutationのコード例である。通常のmutationと同様に commitMutation 関数を使ってファイルをアップロードできるが、気をつけるべきポイントがいくつかある。

import { commitMutation } from "react-relay";

commitMutation(environment, {
  mutation: graphql`
    mutation UploadArtworkMutation($input: UploadArtworkInput!) {
      uploadArtwork(input: $input) {
        artwork {
          id
        }
      }
    }
  `,
  variables: {
    input: {
      files: [null, null],
      ...
    },
  },
  uploadables: {
    "variables.input.files.0": ...,
    "variables.input.files.1": ...,
  },
});

Upload scalarには null を渡す

mutationの入力のうち Upload 型のフィールドには、アップロードするファイルを表すオブジェクト (FileBlob など) ではなく null を渡す必要がある。ではどうやってファイルをアップロードするのかというと、後述する uploadables を使って渡す。

relay-compilerでファイルを生成する際には --customScalars.Upload null オプションを指定しておくと間違えなくて済む。

relay-compiler --schema ./schema.graphql --src src --language typescript --customScalars.Upload null

uploadables を渡す

mutationの入力中のキーに対応するアップロードするファイルのマッピングを commitMutation 関数の第2引数の uploadables フィールドに渡す必要がある。UploadableMap 型の定義は以下。

export type Uploadable = File | Blob;
export interface UploadableMap {
    [key: string]: Uploadable;
}

マッピングのキーはobject-path形式である。今回の例なら variables.input.files.0variables.input.files.1 が該当する。

複数ファイルのアップロードに対応するなら、FileList から UploadableMap に変換するヘルパー関数を作っておくと取り回しがよいと思う。

const makeUploadablesFromFileList = (
  objectPath: string,
  files: FileList
): UploadableMap =>
  Object.fromEntries<File>(
    Array.from(files, (file, i) => [`${objectPath}.${i}`, file])
  );

const uploadables = makeUploadablesFromFileList('variables.input.files', files);
// {
//   "variables.input.files.0": ...,
//   "variables.input.files.1": ...,
// }

ネットワークレイヤを実装する

Relayでは、GraphQL APIへのリクエストを行う層が抽象化されている。実装がXMLHttpRequestでもfetch APIでもよいし、なんなら単なるJavaScriptのオブジェクト操作でもよい。

今回は簡単のため、公式ドキュメントに則ってfetch APIを使うことにする。

relay.dev

ファイルアップロードを行う場合は以下のようにネットワークレイヤを実装する必要がある。

multipart form形式でHTTPリクエストを行う

通常のGraphQL APIリクエストはリクエストボディとしてJSONを送信すればよい。GraphQL multipart request specificationではmultipart form形式でリクエストを送信する必要がある。

これは単に FormData を送ればよい。

const formData = new FormData();

operations パラメータでGraphQLクエリや変数を送信する

通常のGraphQL APIリクエストでは {"query": "GraphQLクエリ", "variables": {変数}} という形式のJSONを送信すればよかった。GraphQL multipart request specificationでは operations というリクエストパラメータで、通常のGraphQL APIリクエストと同様のJSONを送信する必要がある。

formData.append(
  "operations",
  JSON.stringify({ query: params.text!, variables })
);

map パラメータで変数中のキーを送信する

map リクエストパラメータで変数中のファイルアップロードに対応するパスを送信する必要がある。

uploadable マッピングから作ると以下のようなコードになる。"variables.input.files.0": ["variables.input.files.0"] のようなフィールドを持つJSONを送信することになる。このマッピングを使って、先述した Upload 型のフィールドの null をファイルで埋めていくイメージらしい。

const map: Record<string, string[]> = {};
Object.keys(uploadables).forEach((key) => {
  if (Object.prototype.hasOwnProperty.call(uploadables, key)) {
    formData.append(key, uploadables[key]);
  }
});
formData.append("map", JSON.stringify(map));

ファイルをアップロードする

あとは FormData オブジェクトでファイルをアップロードする方法に従ってアップロードしていけばよい。 map パラメータを送信するより前にファイルを送信してはいけない*2ので、2回 Object.keys(...).forEach(...) のループを回している。

Object.keys(uploadables).forEach((key) => {
  if (Object.prototype.hasOwnProperty.call(uploadables, key)) {
    formData.append(key, uploadables[key]);
  }
});

body = formData;

完成したコード

完成したネットワークレイヤのコードは以下。通常のGraphQLクエリを発行する場合とファイルアップロードを行う場合とで分岐している。

const fetchRelay: FetchFunction = async (
  params,
  variables,
  cacheConfig,
  uploadables?
) => {
  const headers = new Headers({ Accept: "application/json" });

  let body: string | FormData;
  if (uploadables) {
    // ファイルアップロードの場合
    const formData = new FormData();
    formData.append(
      "operations",
      JSON.stringify({ query: params.text, variables })
    );

    const map: Record<string, string[]> = {};
    Object.keys(uploadables).forEach((key) => {
      if (Object.prototype.hasOwnProperty.call(uploadables, key)) {
        map[key] = [key];
      }
    });
    formData.append("map", JSON.stringify(map));

    Object.keys(uploadables).forEach((key) => {
      if (Object.prototype.hasOwnProperty.call(uploadables, key)) {
        formData.append(key, uploadables[key]);
      }
    });

    body = formData;
  } else {
    // 通常のクエリの場合
    headers.append("Content-Type", "application/json");

    body = JSON.stringify({
      query: params.text,
      variables,
    });
  }

  const response = await fetch("http://localhost:3000/api/graphql", {
    method: "POST",
    headers,
    body,
  });

  return await response.json();
};

サンプルコード

実際にRelayでGraphQL multipart request specに従ってファイルアップロードを行うサンプルアプリケーションを作って公開した。簡単なファイルアップローダーで、最低限の機能を備えている。記事中のスキーマとは異なるけど実装の簡略化のためにそうなっている。アップロードされたファイルの情報をオンメモリに記録しているので、アプリケーションを再起動すると吹っ飛ぶ。

github.com

おわりに

この記事では、RelayでGraphQL multipart request specificationに沿ったファイルアップロードを行う際の実装方法を解説した。また、GraphQL API経由でファイルアップロードを行うサンプルアプリケーションを公開した。

この記事を読んでも分からなかったところについては、実際にコードを動かしたり弄ったりして確かめられるとよさそう。

*1:アップロードされた巨大なファイルの内容を直ちにメモリに確保するのではなく、一時ファイルに退避するなど

*2:サーバーの実装によっては順序が違ってもよい場合があるけど、specでは順序が決まっているので従うべき

create-react-app --template typesctiptの直後に初手で入れる設定

はじめに

Reactでアプリケーションを作るときは、だいたい create-react-app --template typesctipt してから開発を始める。ゼロコンフィグでReactアプリケーションを書けて便利だけど、もうちょっと手を入れておくと快適に開発できるようになる。

趣味の個人開発で create-react-app --template typescript した直後にやっている設定を紹介する。集団開発だとまた変わってくる項目もありそう。

バージョン情報

  • create-react-app 4.0.3

設定していること

ESLint

package.jsonの eslintConfig を以下のように書き換える*1

@typescript-eslint/explicit-module-boundary-types は、exportする関数の型を明示すべきというルール。型推論とエディタの補完でなんとかなっているので無効にしている。コードの規模や開発メンバーの人数によっては、明示したほうがスムーズにいく場合もあるかもしれない。

ほかにも有効にしておくとよいルールがあるかもしれないけど、今のところ手癖で回避できているので、あまり設定項目を増やしていない。

ESLintとprettierをくっつける動きもありそうだけど、やってない。VSCodeの設定でコードの見た目が勝手にフォーマットされるようにしている (後述) し、強制したいならhuskyとか導入したらいいと思う。

  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest",
      "plugin:@typescript-eslint/recommended"
    ],
    "rules": {
      "@typescript-eslint/explicit-module-boundary-types": "off"
    }
  },
2021/7/14 18:24 追記

create-react-app --template typesctiptの直後に初手で入れる設定 - 私が歌川です

あれ?「react-hooks/exhaustive-deps」は特に何もしなくてもデフォルトで有効になってない?(react-scripts、v4.0.3で確認

2021/07/14 18:10
b.hatena.ne.jp

手癖で足していたけど、改めて手元で確認してみると "plugins": ["react-hooks"]rules の設定はなくてもデフォルトで有効になっていた。

ご指摘ありがとうございます。記事中でも明示的に設定しないように修正しました。id:Rishatang

prettier

インポート順のソートが自動で行われてほしいので、@trivago/prettier-plugin-sort-importsを導入する。

$ yarn add -D @trivago/prettier-plugin-sort-imports

package.jsonに prettier というキーで以下を追加する。

  "prettier": {
    "importOrder": [
      "^[./]"
    ],
    "importOrderSeparation": true
  },

このように設定することで、以下のようにインポートが分けてソートされる。

  • 依存ライブラリのインポート
  • 異なるディレクトリ以下のファイルのインポート
  • 同一ディレクトリ以下のファイルのインポート

.eslintignore, .prettierignore

自動生成したファイルをlintしなくてよいし、整形されたら困るので対象外にする。

.eslintignore.prettierignore を以下の内容で保存する。relayが自動生成するファイルをignoreしている。

__generated__/

editorconfig

最終的にはprettierでフォーマットするけど、新規作成したファイルが4スペースインデントになったりしてイライラすることが多いので、前もってeditorconfigで制御しておく。

リポジトリルートに .editorconfig を以下の内容で設置している。

root = true

[*.{ts,tsx}]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

VSCodeの設定ファイル

.vscode/settings.json に以下の設定を書く。Format on Saveを有効にしているだけである。

{
    "[typescript]": {
        "editor.formatOnSave": true
    },
    "[typescriptreact]": {
        "editor.formatOnSave": true
    }
}

設定していないこと

husky, lint-stagedでのコミット時コード整形

huskyやlint-stagedを使うと、git commit のタイミングでstagedな差分を整形できるけど、やっていない。個人開発の範囲だと、VSCodeのFormat on SaveやAuto Fixで事足りている。

VSCodeの推奨拡張機能の設定

チームで開発するなら .vscode/extensions.json を設置してもよいかもしれない。editorconfig, ESLint, prettierを使うための拡張機能をおすすめする設定を書くなら以下のようになる。

{
    "recommendations": [
        "editorconfig.editorconfig",
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode"
    ]
}

tsconfig.json の設定

create-react-app --template typescript で出力されたものをそのまま使っていて、特に困っていない。

おわりに

create-react-app --template typescript した直後に導入している設定を紹介してきた。

毎回初期化後に既存のリポジトリからコピペして設定しているけど、ひと通り設定済のものをcreate-react-appのテンプレートとしてアップロードしておけば、コピペしなくて済むようになって便利な気がする。

*1:ここから読み取れるように、package.jsonにどんどん書き足していく派である。設定項目が増えたらファイルを分けてもいいと思う。

perlcriticのポリシーの一覧を見れるようにしたい

metacpanのAPIを使う作戦 (断念)

metacpanにはAPIがあるので、これを活用できないか考えます。

curl 'https://fastapi.metacpan.org/v1/module/_search?q=distribution:Perl-Critic&size=5000' というコマンドを実行することで、metacpanからPerl-Criticディストリビューションに含まれるモジュールの一覧を取得できます。pod というフィールドが含まれているのでこれが使えそうです。

が、この pod フィールドには、連続する空白文字をスペースに変換したPODの文字列が含まれています*1。テキストエディタの拡張機能などから使うぶんにはこれでもよいかもしれませんが、一覧として見るにはちょっと使いづらそうです。

手元にインストールしたモジュールのPODを抽出する作戦

metacpanのAPIを叩かなくても、手元にインストール済のperlcriticのポリシーのPODを抽出すればよいことに気づきました。 Pod-Markdownディストリビューションに含まれる pod2markdown コマンドでMarkdownに変換しておけば取り回しがよさそうです。

Perl::CriticとPod::Markdownを手元にインストールしたあと、以下のようなスクリプトを回すことで、perlcriticのポリシー一覧のドキュメントを生成できます。Perl::Critic::Policy以下のモジュールを取得するためにいきなり find コマンドを使ったり、Pod::Markdownのインスタンスを作らずに文字列操作でゴチャゴチャやったりしています。

# generate-perlcritic-policies.pl
use strict;
use warnings;
use utf8;
use feature 'say';

sub filename_to_package {
    my $filename = shift;
    my ($pkg) = $filename =~ m{lib/perl5/([0-9a-zA-Z_\/]+)\.pm$};
    $pkg =~ s{/}{::}g;
    $pkg;
}

# モジュールのパスは手元の環境に合わせて書き換える
my @policy_files = split /\n/, `find ~/perl5/lib/perl5/Perl/Critic/Policy -type f -name '*.pm'`;

for my $policy_file (@policy_files) {
    my $markdown = `pod2markdown $policy_file`;
    $markdown =~ s/^#/##/mg;

    my $pkg = filename_to_package($policy_file);
    say "# [$pkg](https://metacpan.org/pod/$pkg)";
    say "";
    say $markdown;
}
$ perl generate-perlcritic-policies.pl > policies.md

こうして生成したMarkdownファイルを適当なところに置いておくとよさそうです。

退勤して、ラーメンを食べてベッドに倒れ込んだら猛烈な眠気が襲ってきた。朝の5時ぐらいに目覚めてインターネットをしていたのと、仕事の疲れがあったのとが重なったのだろう。


冷感シートが届いたので試してみたところ、たしかにちょっと冷える気がする。が、汗はしっかりかくのでもうちょっと調整が必要そう。