Skip to content

User hasn't closed app for a couple of days results in INTERNAL ASSERTION FAILED: Unexpected state #8383

Open
@TimmNL

Description

@TimmNL

Operating System

MacOS 14.5

Browser Version

Safari, Chrome, Firefox

Firebase SDK Version

10.12.4

Firebase SDK Product:

AppCheck, Auth, Firestore, Functions

Describe your project's tooling

React with Typescript, Tanstack Query, Vitest and Vite

Describe the problem

I'm trying to create a web-app with offline support, in which the logged in user can create, update and delete todo's. Currently I have implemented firebase auth, firestore, firebase appCheck and firebase functions to trigger some cloud functions when the user is online.

The problem I'm having is that users complain that when they have the web-app opened for a couple of days, they eventually get the following error:
INTERNAL ASSERTION FAILED: Unexpected state.
Scherm­afbeelding 2024-07-22 om 17 06 04

When the user reloads the page, all data is fetched again and everything works fine.

After a little research I found that sometimes the user isn't logged in anymore (firebase/auth's currentUser is null), until they reload the app. It doesn't matter if the user is online or offline, the reload logs the user back in.

To solve this I added some extra code to my auth-handler, which resulted in not being able to share the state between different tabs of the same browser. I'd like to have that functionality back and also a fix of some sort for users that have lost the connection to the app until they reload the app.

Steps and code to reproduce issue

In my app I use singeltons for the app, database and auth to make sure they stay alive, even when the user isn't actively using them. The calls are all done through tanstack query.

My initialization of the firestore app looks like this:

// init-app.ts
import { deleteApp, FirebaseApp, initializeApp } from 'firebase/app';

let app: FirebaseApp | undefined;

export const initApp = () => {
  if (!app) {
    app = initializeApp({
      apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
      authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
      projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
      storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
      messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
      appId: import.meta.env.VITE_FIREBASE_APP_ID,
      measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID,
    });
  }

  return app;
};

export const clearApp = async () => {
  if (app) {
    await deleteApp(app);
  }
  app = undefined;
};

initialization of firebase auth:

import {
  Auth,
  browserLocalPersistence,
  connectAuthEmulator,
  getAuth,
  onAuthStateChanged,
  setPersistence,
} from 'firebase/auth';

import { initAppCheck } from './app-check';
import { initApp } from './init-app';

let auth: Auth | undefined;

export const getAuthentication = async () => {
  if (!auth) {
    import.meta.env.MODE !== 'development' && initAppCheck(initApp());
    auth = getAuth(initApp());

    if (
      import.meta.env.MODE === 'development' &&
      import.meta.env.VITE_FIREBASE_EMULATOR_URL
    ) {
      connectAuthEmulator(
        auth,
        `http://${import.meta.env.VITE_FIREBASE_EMULATOR_URL}:9099`,
      );
    }

    await setPersistence(auth, browserLocalPersistence);

    // todo: this part is not realy preferred since it breaks local persistence over multiple tabs
    onAuthStateChanged(auth, async (user) => {
      if (import.meta.env.MODE === 'development') {
        // eslint-disable-next-line no-console -- only show on dev
        console.log('user state changed', user?.uid);
      }

      await user?.getIdToken(true);

      if (import.meta.env.MODE === 'development') {
        // eslint-disable-next-line no-console -- only show on dev
        console.log('user state changed - getIdToken called', user?.uid);
      }
    });
  }

  return auth;
};

export const clearAuth = () => {
  auth = undefined;
};

initializing firestore database:

import {
  connectFirestoreEmulator,
  disableNetwork,
  enableNetwork,
  Firestore,
  initializeFirestore,
  persistentLocalCache,
  persistentMultipleTabManager,
} from 'firebase/firestore';

import { getAnalytics } from './get-analytics';
import { getPerformanceMonitoring } from './get-performance-monitoring';
import { initApp } from './init-app';

let database: Firestore | undefined;

export const getDatabase = () => {
  if (!database) {
    database = initializeFirestore(initApp(), {
      localCache: persistentLocalCache(
        /*settings*/ { tabManager: persistentMultipleTabManager() },
      ),
    });

    if (
      import.meta.env.MODE === 'development' &&
      import.meta.env.VITE_FIREBASE_EMULATOR_URL
    ) {
      connectFirestoreEmulator(
        database,
        import.meta.env.VITE_FIREBASE_EMULATOR_URL,
        8080,
      );
    }

    // todo: this is added to try to help firestore notice that it should use the local or online database
    const handleOnline = () => {
      if (database) {
        enableNetwork(database);
      }
    };

    const handleOffline = () => {
      if (database) {
        disableNetwork(database);
      }
    };

    const handleVisibilityChange = () => {
      if (database) {
        document.visibilityState === 'visible'
          ? enableNetwork(database)
          : disableNetwork(database);
      }
    };

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    window.addEventListener('visibilitychange', handleVisibilityChange);

    getAnalytics(initApp());
    getPerformanceMonitoring(initApp());
  }

  return database;
};

export const clearDatabase = () => {
  database = undefined;
};

Creating a query

// get-task-by-id.ts
import { doc } from 'firebase/firestore';
import { ID } from 'shared/types/id';

import { taskConverter } from './converters/task';
import { CollectionOptions } from './helpers';
import { getDatabase } from './helpers/get-database';

export const getTaskById = async (id: ID) => {
  return doc(getDatabase(), CollectionOptions.Tasks, id).withConverter(
    taskConverter,
  );
};

Tanstack query:

// use-task-by-id.ts
import { useQuery } from '@tanstack/react-query';
import { createSubscriptionDoc } from 'shared/lib/@tanstack-query';
import { getTaskById } from './get-task-by-id';
import { ID } from 'shared/types/id';

export const queryKey = 'task-by-id';

export const useTaskByIdQuery = (uid: ID = '', id: ID = '') =>
  useQuery({
    queryKey: [queryKey, uid, id],
    queryFn: createSubscriptionDoc(() => getTaskById(id)),
    enabled: !!uid && !!id,
  });

Place where the error comes shows up:

import { QueryFunction } from '@tanstack/react-query';
import {
  DocumentReference,
  DocumentSnapshot,
  FirestoreError,
  getDocFromCache,
  onSnapshot,
} from 'firebase/firestore';
import { ID } from 'shared/types/id';

import { queryClient } from './query-client';
import { addSubscription } from './subscriptions';

export const createSubscriptionDoc = <T extends { id: ID }>(
  doc: () => Promise<DocumentReference<T>> | DocumentReference<T>,
): QueryFunction<T | undefined, string[]> => {
  let firstRun = true;
  let latestSnapshotData: T;

  return (context) => {
    return new Promise(async (resolve, reject) => {
      const docRef = await doc();

      const onSuccess = (response: DocumentSnapshot<T>) => {
        latestSnapshotData = response.data() as T;

        let data = latestSnapshotData;

        if (firstRun) {
          resolve(data);
          firstRun = false;
          return;
        }

        queryClient.setQueryData(context.queryKey, data);
      };

      const onError = (error: FirestoreError) => { // <-- this is executed and is where the error comes from
        if (firstRun) {
          reject(error);
          firstRun = false;
          return;
        }

        queryClient.invalidateQueries({ queryKey: context.queryKey });
      };

      getDocFromCache(docRef)
        .then(onSuccess)
        .catch(() => {
          // this is just cache, do nothing with this error
        });

      addSubscription(onSnapshot(docRef, onSuccess, onError));
    });
  };
};

As seen in the code above, I make use of the onSnapshot function to get all the updates.

I wonder if I'm the only one with this issue, I could not find an issue related to mine.

Thanks for the help!

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions