3
votes

In a react-native application, I call an action sends data to firebase. but after the call, I get an error whose source seems to be with the way the snapshot listener is working. the add-msg action more or less looks like this:

create data:

const addMsg = (msg, convoIds) => {
    console.log('Firestore Write: (actions/conversation) => addMsg()');

    return firebase.firestore().collection('messages').add({
        time: firebase.firestore.FieldValue.serverTimestamp(),
        sender: msg.sender,
        receiverToken: msg.receiverToken,
        sessionId: msg.sessionId,
        read: false,
        charged: false,
        userConvos: [ convoIds.sender, convoIds.receiver ],
        content: {
            type: 'msg',
            data: msg.text
        }
    });
};

I also have a snapshot listener (that executes in componentDidMount) that populates a redux store with messages from a collection in firestore. the snapshot listener looks like:

export const getMessages = (convoId) => {
    const tmp = convoId == null ? '' : convoId;
    return (dispatch) => {
        console.log('Firestore Read (Listener): (actions/conversation) => getMessages()');
        return new Promise((resolve, reject) => {
            firebase
                .firestore()
                .collection('messages')
                .where('userConvos', 'array-contains', tmp)
                .orderBy('time')
                .onSnapshot((querySnapshot) => {
                    const messages = [];
                    querySnapshot.forEach((doc) => {
                        const msg = doc.data();
                        msg.docId = doc.id;
                        msg.time = doc.get('time').toMillis();

                        messages.push(msg);
                    });
                    dispatch({ type: types.LOAD_MSGS, payload: messages });
                    resolve();
                });
        });
    };
};

the corresponding reducer that populates a flatList in the same screen component which looks like:

const INITIAL_STATE = {
    messages: []
};
export default (state = INITIAL_STATE, action) => {
    switch (action.type) {
        case types.LOAD_MSGS:
            return {
                messages: action.payload
            };
        default:
            return { ...state };
    }
};

Problem: once the data sends, I immediately get an error TypeError: null is not an object (evaluating 'doc.get('time').toMillis'. if I reload the application, navigate back to the screen in question, the msg appears, and so does the time data. So my guess is something is happening with the delay in the promissory nature of firebase calls, and the delay causes a null initialization of the time value, but its long enough to crash the application.

Question: what is actually happening behind the scenes here and how can I prevent this error?

1
Is doc.get('time') null?Lajos Arpad
@LajosArpad when I access it in the screen component, it displays the timeJim
If I were you, I would, just before the line of msg.time = doc.get('time').toMillis(); do an experiment in the form of console.log(doc.get('time').toMillis()). You will see whether that's the thing which is null or not. This is a key information that you need in order to solve your problem.Lajos Arpad
@LajosArpad already have. all the times get loggedJim
Can you add a verbatim quote of the exact error message you get to the question?samthecodingman

1 Answers

2
votes

The problem you encounter is caused by the onSnapshot() listener firing from the local Firestore cache during a small window where the value of firebase.firestore.FieldValue.serverTimestamp() is considered pending and is treated as null by default. Once the server accepts your changes, it responds with all the new values for the timestamps and triggers your onSnapshot() listener again.

Without care, this may cause your app to 'flicker' as it stamps the data out twice.

To change the behaviour of pending timestamps, you can pass a SnapshotOptions object as the last argument to doc.data() and doc.get() as appropriate.

The following code, instructs the Firebase SDK to estimate the new timestamp values based on the local clock.

const estimateTimestamps = {
  serverTimestamps: 'estimate'
}

querySnapshot.forEach((doc) => {
  const msg = doc.data(); // here msg.time = null
  msg.docId = doc.id;
  msg.time = doc.get('time', estimateTimestamps).toMillis(); // update msg.time to set value (or estimate if not available)

  messages.push(msg);
});

If you want to show that your message is still being written to the database, you could check if msg.time is null just before estimating the timestamp.

const estimateTimestamps = {
  serverTimestamps: 'estimate'
}

querySnapshot.forEach((doc) => {
  const msg = doc.data(); // here msg.time = null when pending
  msg.docId = doc.id;
  msg.isPending = msg.time === null;
  msg.time = doc.get('time', estimateTimestamps).toMillis(); // update msg.time to set value (or estimate if not available)

  messages.push(msg);
});

If you want to ignore these intermediate 'local' events in favour of waiting for the server's full response, you would use:

.onSnapshot({includeMetadataChanges: true}, (querySnapshot) => {
  if (querySnapshot.metadata.fromCache && querySnapshot.metadata.hasPendingWrites) {
    return; // ignore cache snapshots where new data is being written
  }
  const messages = [];
  querySnapshot.forEach((doc) => {
    const msg = doc.data();
    msg.docId = doc.id;
    msg.time = doc.get('time', estimateTimestamps).toMillis();

    messages.push(msg);
  });
  dispatch({ type: types.LOAD_MSGS, payload: messages });
  resolve();
});

In the above code block, note that I also checked querySnapshot.metadata.hasPendingWrites before ignoring the event so that when you first load up your app, it will print out any cached information immediately. Without this, you will show an empty message list until the server responds. Most sites will print out any cached data while showing a throbber at the top of the page until the server responds with any new data.