かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

React Query, GraphQL Request で Firebase の token を使った GraphQL リクエストをするメモ

firebase Auth の JWT (id token) を header に乗せて GraphQL のクエリを発行するメモ

React-Query + GraphQL request で JWT token を使う方法

環境

  • graphql 16.6.0
  • graphql-request 6.0.0
  • @tanstack/react-query 4.29.3
import { useQuery } from 'react-query';
import { GraphQLClient } from 'graphql-request';

function MyComponent() {
  const client = new GraphQLClient(GRAPHQL_ENDPOINT, { headers: {
    authorization: `Bearer ${token}`,
  } });
  const { data, isLoading, error } = useQuery(
    [QUERY_KWY],
    () => client.request(MyQueryDocument, query_variables)
  );
  // ...
}

Firebase Auth で得られる JWT (id token) の有効期限は 1時間

Firebase ID トークンの有効期間は短く、1 時間で期限切れとなります。
cf. ユーザー セッションの管理  |  Firebase Authentication

Firebase Auth でログイン完了時に getIdToken(auth.currentUser) で JWT が取得できる。ここで取得された JWT を state に保持してクエリ発行時に header に乗せる方法法が考えられるが、JTW token の有効期限が 1時間なのでログイン後 1時間以上経過していると JTW token が expired になってしまう。

上記の問題を解決するには GprahQL のクエリ発行時に新しい JWT token を取得するの良さそう

getIdToken( forceRefresh?: boolean ) : Promise<string>
Returns a JSON Web Token (JWT) used to identify the user to a Firebase service.
Returns the current token if it has not expired. Otherwise, this will refresh the token and return a new one.
cf. User | JavaScript SDK  |  Firebase JavaScript API reference

Frebase を使っている場合は getIdToken() を使えばログインが維持されてれば新しい token が取得できる

Frebase の getIdToken は非同期処理なのでどこで実行させるか

React-Query は hooks なので GraphQL Client が非同期で token を取得するのを待つことができない

🙅 このような書き方はでききない

function MyComponent() {
  const token = await getIdToken(auth.currentUser);
  const client = new GraphQLClient(GRAPHQL_ENDPOINT, { headers: {
    authorization: `Bearer ${token}`,
  } });
  const { data, isLoading, error } = useQuery(
    [QUERY_KWY],
    () => client.request(MyQueryDocument, query_variables)
  );
  // ...
}

🙆 useQuery の第二引数 (Query Function) 内で token を取得するようにすればOK

A query function can be literally any function that returns a promise. The promise that is returned should either resolve the data or throw an error.
cf. Query Functions | TanStack Query Docs

uswQuery の第二引数 (Query Function) は Promise を返す関数であれば良いので、この中で getIdToken して GraphQLClient を作成してしまえば良い

const buildGraphQlClient = async () => {
  const token = await getIdToken(auth.currentUser);
  return new GraphQLClient(GRAPHQL_ENDPOINT, { headers: {
    authorization: `Bearer ${token}`,
  } });
};

function MyComponent() {
  const { data, isLoading, error } = useQuery(
    [QUERY_KWY],
    async () => {
      const client = await buildGraphQlClient();
      return client.request(MyQueryDocument, query_variables)
    }
  );
  // ...
}

これで 非同期で JWT token を取得し、graphql-request で GraphQLClient を作成して react query を使ってリクエストを発行することができました!

Apollo client はリクエスト発行時に自動的にクライアントを組み立てる仕組みを持っている

The Apollo Link library helps you customize the flow of data between Apollo Client and your GraphQL server. You can define your client's network behavior as a chain of link objects that execute in a sequence
cf. Apollo Link overview - Apollo GraphQL Docs

ざっくり言えば ApolloClient の link に設定している関数が GraphQL のリクエストを発行する際に実行されるというイメージ

import { ApolloProvider , ApolloClient, InMemoryCache, createHttpLink, ApolloLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";

const createApolloClient = () => {
  const httpLink = createHttpLink({
    uri: GRAPHQL_ENDPOINT,
  });
  
  const authLink = setContext(async (_, { headers }) => {
    const user = auth.currentUser;
    headers: {
        ...headers,
        authorization: user ? `Bearer ${await getIdToken(user, true)}` : "",
      },
  });

  return new ApolloClient({
    link: authLink().concat(httpLink),
    cache: new InMemoryCache(),
  });
};

type ApiProviderProps = { children: ReactNode };
export function ApiProvider({ children }: ApiProviderProps): JSX.Element => {
  const client = createApolloClient();
  return <ApolloProvider client={client}>{children}</ApolloProvider>;
}

client の動的なビルドが機能として提供されているので client を作成して ApolloProvider 保持しておけば使い回せる

所感

GraphQL について経験が浅いので、ライブラリの挙動とかしらなくて非同期で取得できる token をどうやって hooks で使うのか迷ってしまった。
期限付きの JWT を使う機会が多いだろうから Apollo Client についてはクエリを発行する際に client をビルドする機能が提供されていた。一方でシンプルなライブラリである GraphQL Request を使う場合は自分でその部分を実装する必要がある。
Apollo はライブラリに秘匿されているので仕組みを理解してなくてもよしなに動作させることができるが、JWT が expire になるならリクエスト前に新しい token 取得したほうが良いよね?とか気づきづらいかもしれない。個人的に自分はまだレベルが低いので明示的にコードに書く方が処理の流れの見通しが良くて好きでした

おわり ₍ ᐢ. ◡ .ᐢ ₎


[参考]

React firebase でログインした user を Recoil の atom に保存するとエラーになる

表題のまま。Firebase Authentication を使ってログインした際に返される user を Recoil の atom に保存すると、signout や再度 signin が実行された際にエラーになる

環境
  • react 18.2.0
  • recoil 0.7.7
  • firebase 9.20.0

😱 TypeError: Cannot assign to read only property 'currentUser' of object '#<AuthImpl>'

下記の例では Firebase で Google ログインを監視して useEffect 内で Recoil の userAtom に firebase の onAuthStateChanged から渡される User を保存しています

import { FC, useEffect } from 'react';
import { atom, useRecoilState } from 'recoil';
import { initializeApp } from 'firebase/app';
import { User, getAuth, GoogleAuthProvider, signInWithPopup, signOut } from 'firebase/auth';

const auth = getAuth(initializeApp(FIREBASE_CONFIG));
const provider = new GoogleAuthProvider();

type UserType = User | null | undefined;
const userAtom = atom<UserType>({
  key: 'Auth/User',
  default: undefined,
});

const AuthComponent: FC = () => {
  const [user, setUser] = useRecoilState(userAtom);

  const handleSignIn = async () => {
    try {
      await signInWithPopup(auth, provider);
    } catch (error) {
      console.log(error);
    }
  }
  
  const handleSignOut = async () => {
    try {
      await signOut(auth);
    } catch(error) {
      console.log(error);
    }
  }
 
  useEffect(() => {
    const unsubscribed = onAuthStateChanged(auth, (user) => {
      console.log({ user }); // user は User | null
      setUser(user);
    });

    return () => unsubscribed();
  }, []);

  return (
    <div>
      { user && <div>{user.displayName}</div> }
      { user ? <button onClick={handleSignIn}>Sign in</button> : <button onClick={handleSignOut}>Sign out</button> }
    </div>
  );
}

Google ログインが完了した後に Sign out ボタンを押して signOut(auth) でログアウトしようとすると下記のようなエラーになりログアウトができなくなっています
=> TypeError: Cannot assign to read only property 'currentUser' of object '#<AuthImpl>'

原因: Recoil の atom と firebase の auth の相性が悪い

Firebase から返される user は User 型のオブジェクトです。オブジェクトは参照なので Firebase の auth ライブラリはログインしてある user オブジェクトを操作しているようです。一方 Recoil の atom は値がオブジェクトの際は Object.freeze() で外から値を変更できなくする設計になっているようで、user オブジェクトをそのまま渡すと firebase のライブラリが user オブジェクトを変更できなくなりエラーが発生してしまうようでした

これは Recoil が hooks を介さずに状態が変更されるのは望ましくないという設計思想に基づく仕様だと思うので firebase auth のように暗黙的にオブジェクトの状態を変更していくライブラリと相性が良くないということだと思いました

解決方法

  1. Deep copy 又は 必要な値だけを取り出して atom に保存する
  2. React の useContext 又は jotai を使う

1. Deep copy 又は 必要な値だけを取り出して atom に保存する

structuredClone で user を DeepCopy してしまう、又は user から必要な値のみを取り出して保存すれば Recoil に保存される atom が firebase が触る user と切り離されるので問題がなくなる (個人的には必要な情報だけ保存すればいいと思う)

必要なデータだけ取り出して保持するサンプル

// 保持する user atom の interface を作成する
interface IUser {
  displayName: string;
  email: string;
  photoUrl?: string;
}
type UserType = IUser | null | undefined;

const AuthComponent: FC = () => {
  // 略
  useEffect(() => {
    const unsubscribed = onAuthStateChanged(auth, (user) => {
      console.log({ user }); // user は User | null
      if (!user) {
        setUser(null);
        return;
      }
      // 必要なデータだけ取り出して atom に保存
      const { displayName, email, photoUrl } = user;
      setUser({displayName, email, photoUrl});
    });

    return () => unsubscribed();
  }, []);
}

2. React の useContext 又は jotai を使う

React の useContext やそれを利用している jotai は保持する state に対して Object.freeze を行わないので、firebase auth の返す user をそのまま保存しても問題ありませんでした。

所感

Recoil の状態は hooks からしか更新できないという思想は状態を正確に把握しておけるという意味において正しいと感じます。その代わりに思想の異なるライブラリの返すオブジェクトなどとはどうしても相性が悪くなってしまうと思いました。
Recoil を使うなら暗黙的に参照で変更されるようなオブジェクト・配列はそのまま atom に乗せないように気をつける必要がありそうです。
一方で React の Context や jotai は保持している オブジェクトや配列の中身が参照先から変更される可能性があり、それを state の変更として感知できない。ということを念頭においておく必要がありそうです


[参考]

Mac 勝手に日本語入力に切り替わる問題

Mac でブラウザの検索フォームやエディターにフォーカスする度に勝手に日本語入力に切り替わってしまう問題が発生したのを解決したメモ

環境

症状

  1. 半角英数で入力をしている状態
  2. ブラウザやエディターなど別のアプリのテキスト入力可能な部分にフォーカス
  3. 入力ソースが勝手に日本語になってしまう

解決方法

  1. システム環境設定 > キーボード > 入力ソース
  2. 書類ごとに入力ソースを自動的に切り替える のチェックを外す

チェックが入っていると自動的に入力ソースが変更されてしまう

どうやらアプリやタブごとに最後の入力ソースを記憶してアプリ・入力エリアにフォーカスが当たるごとに自動的に入力ソースを切り替えてくれる機能のようです。
入力をよしなにしてくれてるサポート機能なのだと思うのですが、いろんな画面をあちこち同時に触るので基本的に半角英数に戻す癖がついていてるので、フォーカスを変えたときに予期しない入力になってしまいイラっとしてしまう原因になっていました… (いつ追加された機能なのか定かではなく、ONにした記憶もないのですが、いらんことせんでほしい…

おわり