FirebaseExtended / reactfire

Hooks, Context Providers, and Components that make it easy to interact with Firebase.
https://firebaseopensource.com/projects/firebaseextended/reactfire/
MIT License
3.54k stars 403 forks source link

Firestore offline data access not working #443

Open constantin-ungureanu-github opened 3 years ago

constantin-ungureanu-github commented 3 years ago

Following the guides for using reactfire, I have tried enable offline access to data https://github.com/FirebaseExtended/reactfire/blob/main/docs/use.md#access-data-offline .

However, this is not working, and I get next exception:

Unhandled Rejection (FirebaseError): Firestore has already been started and persistence can no longer be enabled. You can only enable persistence before calling any other methods on a Firestore object.
....

The line causing this issue is here:

await enableIndexedDbPersistence(db);

Removing previous line and the problem is solved, however, by doing so the offline access is bypassed.

Version info

Latest versions for react, firebase, reactfire.

Test case

In order to reproduce this issue, I have created 2 GitHub repositories

  1. https://github.com/constantin-ungureanu-github/test

    • simple react app of the Burito quickstart example, where I also tried to use the offline data access.
  2. https://github.com/constantin-ungureanu-github/test-pwa-typescript

    • simple react app where I copied the examples from reactfire source code (non-concurrent ones), and the result is the same, enableIndexedDbPersistence produces the same exception.

Steps to reproduce

Simply run the react examples described above. npm install npm start

Expected behaviour

Guide for data offline access to work as described.

Actual behaviour

Enabling offline access is not working, exception is thrown.

epodol commented 3 years ago

This is probably also related.


  const { status: useInitFirestoreStatus, data: firestore } = useInitFirestore(
    async (firebaseApp) => {
      const firestoreInit = getFirestore(firebaseApp);
      if (isDev) connectFirestoreEmulator(firestoreInit, 'localhost', 8080);
      return firestoreInit;
    }
  );

Screenshot from 2021-09-02 07-17-42

epodol commented 3 years ago

It looks like the code is being run after every other useInit... completes.

jhuleatt commented 3 years ago

@epodol I think your issue may be different. getFirestore initializes Firestore with defaults. Do you see that behavior if you use initializeFirestore instead? (I believe it's the only product that behaves this way)

epodol commented 3 years ago

Yes, this still occurs with initializeFirestore. With a console.log inside the callback, it gets called once for each of the init callbacks defined.

epodol commented 3 years ago

Correction, my prior problem has been resolved by adding { suspense: false } to each of the useInit calls.

However, when enabling offline persistence, I get a similar effect to the one described, except for me, the page load loads forever:

  const { status: useInitFirestoreStatus, data: firestore } = useInitFirestore(
    async (firebaseApp) => {
      const firestoreInit = initializeFirestore(firebaseApp, {});
      await enableIndexedDbPersistence(firestoreInit);
      if (isDev) connectFirestoreEmulator(firestoreInit, 'localhost', 8080);
      return firestoreInit;
    },
    { suspense: false }
  );
jhuleatt commented 3 years ago

Interesting, thanks for the follow up @epodol. Having to add { suspense: false } to each call sounds like a bug (the useInit hooks should pick that up from Context).

I'll look into both of these this week.

epodol commented 3 years ago

Interesting, thanks for the follow up @epodol. Having to add { suspense: false } to each call sounds like a bug (the useInit hooks should pick that up from Context).

I'll look into both of these this week.

@jhuleatt Sorry, to clarify, I am using suspense globally, but I must not have been using it correctly with the useInit hooks.

jhuleatt commented 3 years ago

I'm investigating this in #451. I wrote a test that confirms that each useInitSdk hook is called, but it's passing at the moment, so not too helpful. Will continue trying to reproduce the issue with @constantin-ungureanu-github's samples.

Gladkov-Art commented 3 years ago

Hi 👋

I'm having the same issue in my tiny pet project. Hope my gist could help to reproduce: https://gist.github.com/Gladkov-Art/74e3e2073f0fc0db5e772c9ef02c22e2

    "reactfire": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/reactfire/-/reactfire-4.2.0.tgz",
      "integrity": "sha512-OfZyp2TNLZKxpejYqT/SSJCNjtAU3qyVhWbcfQZMG/LForown67QBk7X4Yn6O6vnoQ/RHyGvgtgnFL2rXbjb5Q==",
      "requires": {
        "rxfire": "^6.0.2",
        "rxjs": "^6.6.3 || ^7.0.1"
      }
    },

"node_modules/firebase": {
      "version": "9.0.0",
      "resolved": "https://registry.npmjs.org/firebase/-/firebase-9.0.0.tgz",
      "integrity": "sha512-atgnuvELhU9D5w9moChnyCb6GRbOCqk54/kHN0J4kdLJBncpcb2culIJ7nlSHILMcW9MNMiNKDJ07RwXVyqFFA==",
      "dependencies": {
        "@firebase/analytics": "0.7.0",
        "@firebase/analytics-compat": "0.1.0",
        "@firebase/app": "0.7.0",
        "@firebase/app-check": "0.4.0",
        "@firebase/app-check-compat": "0.1.0",
        "@firebase/app-compat": "0.1.0",
        "@firebase/app-types": "0.7.0",
        "@firebase/auth": "0.17.0",
        "@firebase/auth-compat": "0.1.0",
        "@firebase/database": "0.12.0",
        "@firebase/database-compat": "0.1.0",
        "@firebase/firestore": "3.0.0",
        "@firebase/firestore-compat": "0.1.0",
        "@firebase/functions": "0.7.0",
        "@firebase/functions-compat": "0.1.0",
        "@firebase/installations": "0.5.0",
        "@firebase/messaging": "0.9.0",
        "@firebase/messaging-compat": "0.1.0",
        "@firebase/performance": "0.5.0",
        "@firebase/performance-compat": "0.1.0",
        "@firebase/polyfill": "0.3.36",
        "@firebase/remote-config": "0.2.0",
        "@firebase/remote-config-compat": "0.1.0",
        "@firebase/storage": "0.8.0",
        "@firebase/storage-compat": "0.1.0",
        "@firebase/util": "1.3.0"
      }
    },

Update: it seems that issue is reproducible only in local development environment...

PritamSangani commented 3 years ago

@jhuleatt - any luck in finding a solution for this issue? I am not able to get offline persistence working and I am getting the same exception as OP

jerryeechan commented 2 years ago

Connect to emulator before enable Persistence works

const { status, data: firestore } = useInitFirestore(async (firebaseApp) => {
    const db = initializeFirestore(firebaseApp, {});
    connectFirestoreEmulator(db, "localhost", 8080);
    await enableIndexedDbPersistence(db);
    return db;
  });
stonesong commented 1 year ago

I have the same exception too (on reactfire@4.2.2) and connecting to emulator before enabling persistence does not work

stonesong commented 1 year ago

Finally managed to find a workaround which is:

const { status, data: firestoreInstance } = useInitFirestore(async (firebaseApp) => {
    const fsSettings = (process.env.NODE_ENV === 'development') ? { host: 'localhost:5080', ssl: false } : {};
    const db = initializeFirestore(firebaseApp, fsSettings);
    try {
      await enableIndexedDbPersistence(db);
    } catch (err) {
      console.log(err)
    }
    return db;
  });
voidrot commented 1 year ago

It looks like persistence is being enabled, it's just being called multiple times hence the error we are seeing.

with the below snippet (much excluded) persistence is enabled on load shown by the log message however you will still see 2 messages as such

failed-precondition
App.tsx:## Firestore has already been started and persistence can no longer be enabled. You can only enable persistence before calling any other methods on a Firestore object.
import { enableIndexedDbPersistence, initializeFirestore } from 'firebase/firestore'
import { FirestoreProvider, useInitFirestore } from 'reactfire'

function App() {
  const { data: firestoreInstance } = useInitFirestore(async (firebaseApp) => {
    const db = initializeFirestore(firebaseApp, {})
    try {
      await enableIndexedDbPersistence(db)
      console.log('indexdb enabled')
    } catch (error) {
      console.log(error.code)
      console.log(error.message)
    }
    return db
  })

  return (
    <FirestoreProvider sdk={firestoreInstance}>
     <App />
    </FirestoreProvider>
  )
}

export default App
jbaldassari commented 1 year ago

One of the causes of the double initialization is the use of <React.StrictMode> which causes components to be mounted twice in dev mode, so if you are initializing firestore when a component mounts or inside a hook it will almost certainly happen twice (locally anyway).

I haven't found a good way to get useInitFirestore working with strict mode. To avoid the issues with the double-render I'm using a custom hook with a global variable that tracks whether the initialization has already occurred. Here is the content of the hook (useInitializeFirestore.ts):

import {useEffect, useState} from 'react';
import {FirebaseError} from 'firebase/app';
import {Firestore, enableMultiTabIndexedDbPersistence, initializeFirestore} from 'firebase/firestore';
import {useFirebaseApp} from 'reactfire';

// Use a global to ensure that we only initialize firestore once when running with React.StrictMode:
let FirestorePromise: Promise<Firestore>;

interface UseInitializeFirestore {
  error?: FirebaseError;
  firestore?: Firestore;
}

export function useInitializeFirestore(): UseInitializeFirestore {
  const firebase = useFirebaseApp();
  const [firestore, setFirestore] = useState<Firestore>();
  const [error, setError] = useState<FirebaseError>();

  const configureFirestore = async (): Promise<Firestore> => {
    const fs = initializeFirestore(firebase, {});
    try {
      await enableMultiTabIndexedDbPersistence(fs);
    } catch (error) {
      setError(error as FirebaseError);
    }
    return fs;
  };

  if (!FirestorePromise) {
    FirestorePromise = configureFirestore();
  }

  useEffect(() => {
    const waitForFirestore = async () => {
      setFirestore(await FirestorePromise);
    };
    waitForFirestore();
  }, [setFirestore]);

  return {error, firestore};
}

Now call the hook from a component (note that ErrorCard and FullScreenLoading are components specific to my project for displaying errors and a loading spinner):

type WithFirestoreProps = {
  children: React.ReactNode
};
const WithFirestore: React.FC<WithFirestoreProps> = ({children}: WithFirestoreProps) => {
  const {error, firestore} = useInitializeFirestore();

  if (error) {
    return <ErrorCard error={error} />;
  }

  if (!firestore) {
    return <FullScreenLoading />;
  }

  return <FirestoreProvider sdk={firestore}>{children}</FirestoreProvider>;
};

Hope this helps someone else.

bruceharrison1984 commented 1 year ago

I got this working with the following:

// utils/firestore.ts
export const getInitializedFirestore = () => {
  const firestore = getFirestore();

  // only enable emulator if in dev mode, and we haven't already enabled it
  if (
    process.env.NODE_ENV === 'development' &&
    (firestore as any)._getSettings().host === 'firestore.googleapis.com'
  ) {
    connectFirestoreEmulator(getFirestore(), '127.0.0.1', 8080);
    console.log('firestore emulator attached');
  }

  // only enable indexeddb if the firestore client hasn't been fully initialized
  if (!(firestore as any)._firestoreClient) {
    enableMultiTabIndexedDbPersistence(firestore);
    console.log('persistence enabled');
  }
  return firestore;
};

Replace any calls to getFirestore() with getInitializedFirestore(). This is also HMR and page refresh safe, so dev mode is pleasant, and it can safely be deployed without having to touch anything.

I don't know why the Firebase SDK doesn't make these properties visible, but this lets you check the status without keeping track of global variables.

wmadden commented 1 year ago

Looks like part of this problem is the <FirebaseAppProvider> rerenders at least twice with different values for app (different object identity). Since all the other SDK instances are memoized on the app object identity, this causes e.g. useInitFirestore() to rerun each time the FirebaseAppProvider's app instance changes