Solution #1
First, try http://127.0.0.1:9099 instead of http://localhost:9099 (don't forget to set this as your emulator host in firebase.json).
Solution #2
On top of using solution #1, try rendering your app after everything Firebase-related has init. You can do this by creating listeners on the status of your emulator connections.
src/config/firebase.ts
import { initializeApp } from "firebase/app";
import { getAuth, connectAuthEmulator } from "firebase/auth";
const app = initializeApp({ /** ... */ });
const auth = getAuth(app);
if (NODE_ENV === "development") {
// Create listener logic
window.emulatorsEvaluated = false;
window.emulatorsEvaluatedListeners = [];
window.onEmulatorsEvaluated = (listener: () => void) => {
if (window.emulatorsEvaluated) {
listener();
} else {
window.emulatorsEvaluatedListeners.push(listener);
}
};
(async () => {
try {
// Use 127.0.0.1 instead of localhost
const authEmulatorUrl = "http://127.0.0.1:9099";
await fetch(authEmulatorUrl);
connectAuthEmulator(auth, authEmulatorUrl, {
disableWarnings: true,
});
console.info(" Firebase Auth: emulated");
} catch (e) {
console.info(" Firebase Auth: not emulated");
}
// Indicate that the emulators have been evaluated
window.emulatorsEvaluated = true;
window.emulatorsEvaluatedListeners.forEach(
(listener: () => void) => {
listener();
}
);
})()
}
export { auth };
src/index.tsx
import React from "react";
import { createRoot } from "react-dom/client";
import "./config/firebase";
const root = createRoot(document.getElementById("root")!);
const Root = () => ({
/** Some JSX */
});
if (NODE_ENV === "development") {
window.onEmulatorsEvaluated(() => {
root.render(<Root />);
});
} else {
root.render(<Root />);
}
Solution #3
Forcibly change the value of auth._canInitEmulator ((auth as unknown as any)._canInitEmulator for TS) to true. This can have some unexpected side-effects (see answer), because some requests might go to your cloud before the emulator kicks in. This can be mitigated with solution #2 (which should prevent requests from firing)
(auth as unknown as any)._canInitEmulator = true;
connectAuthEmulator(auth, authEmulatorUrl, {
disableWarnings: true,
});
Full Answer
Part of the issue here is that I was performing connectAuthEmulator in an async function, when the documentation clearly states it should be called immediately (and synchronously) after getAuth. I was aware of this, but I did not see any other alternative to the issue of connecting to the emulator only when it is available.
I dug into the source code for connectAuthEmulator (permalink) and found out that the error is thrown here:
_assert(
authInternal._canInitEmulator,
authInternal,
AuthErrorCode.EMULATOR_CONFIG_FAILED
);
authInternal._canInitEmulator was false when the error occurred. There is only one place where this property is set to false, in _performFetchWithErrorHandling, here (permalink). This is because it's the first time it performs an API request for the Auth service. So I concluded that Firebase disallows the use of emulators after the first request using auth is made. This is probably to prevent making some requests on your cloud, then switching over to an emulator. So my call to connectAuthEmulator was probably done after a request was already made. I couldn't figure out where though. Even with my app not being loaded (solution #2), the error would still occur.
My conclusion was right, as I later found this error.ts file which pretty much says this:
[AuthErrorCode.EMULATOR_CONFIG_FAILED]:
'Auth instance has already been used to make a network call. Auth can ' +
'no longer be configured to use the emulator. Try calling ' +
'"connectAuthEmulator()" sooner.',
This whole investigation could've been done earlier if FirebaseError had shown this message instead of just auth/emulator-config-failed. It is visible if you console.log(error.message), but I was not aware of that at the time.
For some reason, using the localhost IP instead fixed it instantly and I never had the error again. I added the solution #2 and #3 as another measure after.