Description
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.
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!