All Articles

Cloud FirestoreのonSnapshotで購読中にログアウト時にSecurity Ruleでエラーが出る時の対処方法

フロントエンドの勉強が捗って楽しい今日このごろですが、解決に時間がかかった問題で、

onSnapshot 起因のエラーの解決方法がネットを探してもピンポイントの記事が見つからなかったので

世の中の誰か、または数年後の自分に向けて、解決方法をまとめておきます。

環境

システム概要

  • React Hooks + Redux +Redux Thunks
  • Cloud Firestore (Firebase)
  • onSnapshot にて記事を購読する
  • 記事の購読にはログインが必要(セキュリティールールで設定)※1

セキュリティルール(※1)

match /blogs/{blogID} {
     allow read: if isAuthenticated();
}

// ユーザーが認証済みかどうか
function isAuthenticated() {
   return request.auth != null;
}

エラー内容 & 原因

コンソールに表示されるエラー内容

実害はないものの、かなりかっこが悪い。

index.js:1 Uncaught Error in onSnapshot: FirebaseError: Missing or insufficient permissions.
    at new FirestoreError (http://localhost:3000/static/js/1.chunk.js:30030:24)
    at JsonProtoSerializer.fromRpcStatus (http://localhost:3000/static/js/1.chunk.js:48391:12)
    at JsonProtoSerializer.fromWatchChange (http://localhost:3000/static/js/1.chunk.js:48944:38)
    at PersistentListenStream.onMessage (http://localhost:3000/static/js/1.chunk.js:44915:39)
    at http://localhost:3000/static/js/1.chunk.js:44826:22
    at http://localhost:3000/static/js/1.chunk.js:44876:18
    at http://localhost:3000/static/js/1.chunk.js:31618:14

どの、onSnaphot で起きているのかまではエラーの内容から調べることは出来ません。 自分は、泥臭いですが、リクエストのセキュリティルールをひとつずつ解除していき原因箇所を特定しました。

少し話がそれますが、 セキュリティルールは Jest を使って単体テストが可能です。

Firestore はセキリティルールの該当箇所を教えてくれないので、単体テスト+debug()の活用はマストでしょう。開発当初は単体テストを書いておりませんでしたが、原因箇所の特定に非常に時間がかかったため書くようにしました。

2018 年当時は自分は前に Firestore を使っていたわけではなかったのですが、単体テストができなかったので、セキュリティルールを設定するのは Try&Error が必要で大変だったようです。先人の方お疲れさまです!

単体テスト環境も作るのに苦労したので、また Blog にする予定です。

今回単体テストが通過したとはいえ、今回のようなタイミングによる Permission エラーはやはり実際に動かしてみて気づくことがあるので、そこはしょうがないかなというところです。

エラーの原因

onSnapshot で記事を購読中にログアウトすることで、権限が失われ、Uncaught Error in onSnapshot: FirebaseError: Missing or insufficient permissions. が表示される。

オフィシャルの リスナーのデタッチ にある、unsubscribe すれば事象は解決しそうです。

var unsubscribe = db.collection("cities")
    .onSnapshot(function (){
      // Respond to data
      // ...
    });

// Later ...

// Stop listening to changes
unsubscribe();

↑ オフィシャルより転記

修正方法

unsubscribe すれば解決しそうなことはわかりましたが、それをどのように実現したらよいのかが、今回の記事のメインとなります。

比較したほうが理解しやすいので、エラーが表示されてい時のソースと修正後のソースをそれぞれ載せます。

修正前のソース

  • Action
export const subscribeBlogs = (): AppThunk<void> => async (dispatch: Dispatch<Action>) => {
  try {
    const colRef = fireStore.collectionGroup(Path.blogs);
    const unsubscribe = colRef
      .onSnapshot(snapshot => {
        ... (省略)
      });
  } catch (err) {
    console.error(err);
  }
};
  • Component (データ取得部分を抜粋)
import { useDispatch } from 'react-redux';
const Blogs: React.FC = () => {
   // ... (省略)
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(subscribeBlogs())
  }, [dispatch]);

  return (
    <div className="container">
    // ... (省略)
    </div>
  )
}

課題その1

オフィシャルの例のように画面側で直接 onSnapshot しているわけではないので、画面側から Action に書かれている onSnapshot を unsubscribe 出来るようにする。

この解決方法として以下のページを見つけました。 How to unsubscribe from collection changes in Firestore

アプローチとしては、

  • useEffect の return () => {} で unsubscribe する
  • 画面側で用意した変数 unsubscribe にマッピンするために、Action の引数に setter 関数を渡す。

確かに、React では関数を引数として渡すアプローチがよく使われているのでしっくりきます。

// Action

export const subscribe = (setUnsubscribe) => {
  return (dispatch) => {
    const items = db.collection('items')

    const unsubscribe = items.onSnapshot((snapshot) => {
      const data = snapshot.docs.map((item) =>  item.data())
      dispatch({ type: 'DO_SOMETHING', payload: data })
    });

    setUnsubscribe(unsubcsribe);
  }
}

// Component

useEffect(() => {
  let unsubscribe = () => {};
  subscribe((func) => { unsubscribe = func });
  return () => unsubscribe();
}, [])

Action に setter を渡す形に書き換える(修正後)

  • Action
export type DetachSetter = (_: () => void) => void;  // ←追加

export const subscribeBlogs = (
  setUnsubscribe: DetachSetter  // ←追加 Setter関数を引数で受け取る
): AppThunk<void> => async (dispatch: Dispatch<Action>) => {
  try {
    const colRef = fireStore.collectionGroup(Path.blogs);
    const unsubscribe = colRef
      .onSnapshot(snapshot => {
        ... (省略)
      });
    setUnsubscribe(unsubscribe); // ←追加 Setter関数に登録
  } catch (err) {
    console.error(err);
  }
};

関数渡しは、 () => void でよく定義するのですが、今回は Setter の形で定義したいので、 (_: () => void) => void_:をつけるのがポイントですね。

  • Component
import { useDispatch } from 'react-redux';
const Blogs: React.FC = () => {
   // ... (省略)
  const dispatch = useDispatch();

  useEffect(() => {
    let unsubscribe = () => {}; // Actionのunsubscribeの入れ物
    dispatch(
      subscribeBlogs(func => {
        unsubscribe = func; // ← Actionから受け取ったunsubscribeをここで画面側のunsubscribeにつめる
      })
    );
    return () => {
      unsubscribe(); // ← 追加
    };
  }, [dispatch]);

  return (
    <div className="container">
    // ... (省略)
    </div>
  )
}

課題その2

課題 1 を対応して、Component 側から unsubscribe することができるようになりましたが、

useEffect の return ()=> {} はどうやって実行させるかが今回の課題になります。

自分のプロダクトの場合、画面の URL が変わった時に、return ()=> {}が実行されるのですが、

ログアウト用のページを設けていないため、実行されません。

そこで、今回は、直接ログアウトするのではなく、ログアウト用のダミーページを用意してそのページに遷移したらログアウトするようにする。

ダミーなのでページコンテンツは不要です。

  • Logout Action Component
import { useHistory } from 'react-router-dom';
const NavBar: React.FC = () => {
  const logoutHandler = () => {
    history.push('/logout'); // ←ログアウト専用ページに遷移
  };
  return (
    <div>
       <Button onClick={logoutHandler}>
           Logout
       </Button>
    </div>
  )
}
  • Logout Dummy Component
const Logout: React.FC = () => {
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(logout());
  }, [dispatch]);
  return <div></div>; // ←ページ表示直後にログアウトするのでコンテンツは不要
};

export default Logout;

まとめ

Component から直接 Action を触れないので、どうしてもハンドリングが必要になったら、Setter 関数渡しで実現することができるとう知見を得ることができました。このような知見は実際に開発していかないと得られないものなので貴重ですね。

参考