Operating System
macOS 15.
Browser Version
Safari 18.3.1 (production event); reproduced with a Safari user agent in Chromium against a controlled __/auth/iframe child.
Firebase SDK Version
firebase 11.10.0 (@firebase/auth 1.10.8). packages/auth/src/platform_browser/iframe/iframe.ts is unchanged in this respect on main at the time of writing, so upgrading to 12.x / @firebase/auth 1.13.6 does not remove it.
Firebase SDK Product
Authentication
Describe your project's tooling
React 18 + Vite 5 web app, ES modules. Sentry browser SDK with the default globalHandlers integration, so window.onunhandledrejection is reported.
Describe the problem
On Safari, iOS and mobile browsers, getAuth() initialises the popup/redirect resolver proactively (_shouldInitProactively() = _isMobileBrowser() || _isSafari() || _isIOS()), which opens the hidden https://<authDomain>/__/auth/iframe through gapi. _openIframe is written as:
packages/auth/src/platform_browser/iframe/iframe.ts
return context.open(
{ where: document.body, url: getIframeUrl(auth), messageHandlersFilter: ..., attributes: ..., dontclear: true },
(iframe: gapi.iframes.Iframe) =>
new Promise(async (resolve, reject) => {
await iframe.restyle({ setHideOnLeave: false });
const networkError = _createError(auth, AuthErrorCode.NETWORK_REQUEST_FAILED);
let networkErrorTimer = setTimeout(() => { reject(networkError); }, PING_TIMEOUT.get());
function clearTimerAndResolve(): void { clearTimeout(networkErrorTimer); resolve(iframe); }
iframe.ping(clearTimerAndResolve).then(clearTimerAndResolve, () => { reject(networkError); });
})
);
The promise returned from that callback is expected to be adopted by Context.open(). It is not. Dumping the live gapi.iframes module (build gapi.lb.en.zhTT8Br0Ho8.O) gives, verbatim:
Context.prototype.open = function (a, b) {
...;
var c = new ao(a);
b = zo(this, c, b);
var d = new Nn(b);
...
c.U.waitForOnload && cn(c.uo(), function () { d.resolve(h) });
var h = this.openChild(a);
c.U.waitForOnload || d.resolve(h);
return d.promise
}
d is a deferred built around the callback. Firebase does not pass waitForOnload, so d.resolve(h) runs the callback with the Iframe object and resolves open()'s promise with h immediately. The value the callback returns, Firebase's new Promise(...), is never chained. From that moment the inner promise has no owner, and the setTimeout(... reject(networkError) ...) inside it can only produce an unhandled rejection.
Timing confirms open() resolves synchronously with respect to the iframe: in every run onAuthStateChanged fired ~2 ms after the iframe element was inserted (205 ms insert / 207 ms auth ready), and deliberately stalling the iframe document for 20 s still had auth ready at 224 ms. So the iframe's readiness never gates auth init, the only observable effect of this code path is the rejection.
The trigger is a child frame that answers the restyle RPC but not the ping RPC within PING_TIMEOUT (5 s for every web browser; Delay.isMobile is isMobileCordova() || isReactNative(), so the 15 s value only applies to Cordova/React Native). A child that answers neither parks the callback at await iframe.restyle(...) forever and produces nothing.
The production event: FirebaseError: Firebase: Error (auth/network-request-failed), mechanism: auto.browser.global_handlers.onunhandledrejection, macOS Safari 18.3.1, source-mapped to the _createError(auth, "network-request-failed") line inside the _openIframe callback, fired 42 s after page load with no user interaction in between (no popup, no redirect, no sign-in call). The application cannot catch it: it never called anything that returns this promise.
Related: #8034 (Unhandled Promise Rejection in Safari, closed 2025-02-10) is the same symptom class; the code path is still present.
Steps and code to reproduce the issue
Serve a page with a Safari user agent that loads Firebase Auth, and serve a __/auth/iframe child that answers _g_restyle but never answers _g_ping. The gapi RPC transport is postMessage, so the child only has to reply to one of the two.
Minimal child (served at the app's authDomain origin, or at any origin you point authDomain at for the test):
<script>
window.addEventListener('message', (e) => {
let data; try { data = JSON.parse(e.data); } catch { return; }
// Reply to restyle, stay silent on ping.
if (data && typeof data.f === 'string' && data.f.indexOf('_g_restyle') === 0) {
e.source.postMessage(JSON.stringify({ s: 'gapi.iframes', f: data.c, a: [{}] }), '*');
}
});
</script>
Page:
import { initializeApp } from 'firebase/app';
import { getAuth, onAuthStateChanged } from 'firebase/auth';
window.addEventListener('unhandledrejection', (e) => {
console.log('UNHANDLED', e.reason && e.reason.code, e.reason && e.reason.message);
});
const auth = getAuth(initializeApp({ /* any config; authDomain points at the child above */ }));
onAuthStateChanged(auth, () => console.log('auth ready'));
// No sign-in call. Nothing else.
Expected: either the 5 s ping timeout surfaces on a promise the caller owns (so an application can handle it, or so it is simply discarded with the rest of the proactive-init work), or the proactive path does not create a rejection at all when nobody asked for a popup.
Actual:
auth ready (t = 265 ms)
UNHANDLED auth/network-request-failed Firebase: Error (auth/network-request-failed). (t = 5262 ms)
Exactly the production signature, and with onAuthStateChanged already long since fired, i.e. the rejection is not connected to anything the application is waiting on.
Control cases, for scoping:
| child behaviour |
result |
| blank document, answers nothing |
no rejection (callback parks at await iframe.restyle) |
| document stalled 20 s |
no rejection; auth ready at 222 ms |
| request aborted |
no rejection |
| real Firebase-hosted iframe (both RPCs answered) |
no rejection |
answers restyle, not ping |
1 unhandled auth/network-request-failed at 5.26 s |
| Chrome UA (proactive init skipped) |
no iframe, no rejection |
Suggested fix
Own the deferred instead of returning a promise Context.open() discards:
return new Promise<gapi.iframes.Iframe>((resolve, reject) => {
context.open(
{ where: document.body, url: getIframeUrl(auth), messageHandlersFilter: ..., attributes: ..., dontclear: true },
(iframe: gapi.iframes.Iframe) => {
void (async () => {
await iframe.restyle({ setHideOnLeave: false });
const networkError = _createError(auth, AuthErrorCode.NETWORK_REQUEST_FAILED);
let networkErrorTimer: number | null = window.setTimeout(() => {
networkErrorTimer = null;
reject(networkError); // the OUTER deferred
}, PING_TIMEOUT.get());
const settle = (): void => {
if (networkErrorTimer !== null) { clearTimeout(networkErrorTimer); networkErrorTimer = null; }
resolve(iframe); // the OUTER deferred
};
iframe.ping(settle).then(settle, () => reject(networkError));
})().catch(reject);
}
);
});
Now _initializeWithPersistence's existing try/catch around resolver._initialize(auth) sees the timeout, and signInWithPopup / signInWithRedirect / getRedirectResult get a real rejection they can act on, instead of a rejection that reaches nobody.
Two smaller points that would also help, independently of the above:
await iframe.restyle(...) has no timeout, so a child that replies to nothing leaves the callback parked forever and initAndGetManager can never settle. A bound there (the same PING_TIMEOUT, or a separate one) would make the proactive path terminate in all cases.
_loadGapi's underlying loadJS still carries // TODO: consider adding timeout support & cancellation. Because proactive init awaits it before initializeCurrentUser, an apis.google.com/js/api.js request that hangs, or that returns a non-gapi 200, e.g. a captive portal, means onAuthStateChanged never fires at all on Safari/iOS/Android (measured: no auth event in a 45 s window; desktop Chrome, which skips the path, is ready in 4 ms). A blocked request is fine, since onerror fires. That is a separate defect and I am happy to file it separately if preferred.
Happy to open a PR if the maintainers agree on the shape.
Operating System
macOS 15.
Browser Version
Safari 18.3.1 (production event); reproduced with a Safari user agent in Chromium against a controlled
__/auth/iframechild.Firebase SDK Version
firebase11.10.0 (@firebase/auth1.10.8).packages/auth/src/platform_browser/iframe/iframe.tsis unchanged in this respect onmainat the time of writing, so upgrading to 12.x /@firebase/auth1.13.6 does not remove it.Firebase SDK Product
Authentication
Describe your project's tooling
React 18 + Vite 5 web app, ES modules. Sentry browser SDK with the default
globalHandlersintegration, sowindow.onunhandledrejectionis reported.Describe the problem
On Safari, iOS and mobile browsers,
getAuth()initialises the popup/redirect resolver proactively (_shouldInitProactively()=_isMobileBrowser() || _isSafari() || _isIOS()), which opens the hiddenhttps://<authDomain>/__/auth/iframethrough gapi._openIframeis written as:packages/auth/src/platform_browser/iframe/iframe.tsThe promise returned from that callback is expected to be adopted by
Context.open(). It is not. Dumping the livegapi.iframesmodule (buildgapi.lb.en.zhTT8Br0Ho8.O) gives, verbatim:dis a deferred built around the callback. Firebase does not passwaitForOnload, sod.resolve(h)runs the callback with theIframeobject and resolvesopen()'s promise withhimmediately. The value the callback returns, Firebase'snew Promise(...), is never chained. From that moment the inner promise has no owner, and thesetTimeout(... reject(networkError) ...)inside it can only produce an unhandled rejection.Timing confirms
open()resolves synchronously with respect to the iframe: in every runonAuthStateChangedfired ~2 ms after the iframe element was inserted (205 ms insert / 207 ms auth ready), and deliberately stalling the iframe document for 20 s still had auth ready at 224 ms. So the iframe's readiness never gates auth init, the only observable effect of this code path is the rejection.The trigger is a child frame that answers the
restyleRPC but not thepingRPC withinPING_TIMEOUT(5 s for every web browser;Delay.isMobileisisMobileCordova() || isReactNative(), so the 15 s value only applies to Cordova/React Native). A child that answers neither parks the callback atawait iframe.restyle(...)forever and produces nothing.The production event:
FirebaseError: Firebase: Error (auth/network-request-failed),mechanism: auto.browser.global_handlers.onunhandledrejection, macOS Safari 18.3.1, source-mapped to the_createError(auth, "network-request-failed")line inside the_openIframecallback, fired 42 s after page load with no user interaction in between (no popup, no redirect, no sign-in call). The application cannot catch it: it never called anything that returns this promise.Related: #8034 (
Unhandled Promise Rejection in Safari, closed 2025-02-10) is the same symptom class; the code path is still present.Steps and code to reproduce the issue
Serve a page with a Safari user agent that loads Firebase Auth, and serve a
__/auth/iframechild that answers_g_restylebut never answers_g_ping. The gapi RPC transport ispostMessage, so the child only has to reply to one of the two.Minimal child (served at the app's
authDomainorigin, or at any origin you pointauthDomainat for the test):Page:
Expected: either the 5 s ping timeout surfaces on a promise the caller owns (so an application can handle it, or so it is simply discarded with the rest of the proactive-init work), or the proactive path does not create a rejection at all when nobody asked for a popup.
Actual:
Exactly the production signature, and with
onAuthStateChangedalready long since fired, i.e. the rejection is not connected to anything the application is waiting on.Control cases, for scoping:
await iframe.restyle)restyle, notpingauth/network-request-failedat 5.26 sSuggested fix
Own the deferred instead of returning a promise
Context.open()discards:Now
_initializeWithPersistence's existingtry/catcharoundresolver._initialize(auth)sees the timeout, andsignInWithPopup/signInWithRedirect/getRedirectResultget a real rejection they can act on, instead of a rejection that reaches nobody.Two smaller points that would also help, independently of the above:
await iframe.restyle(...)has no timeout, so a child that replies to nothing leaves the callback parked forever andinitAndGetManagercan never settle. A bound there (the samePING_TIMEOUT, or a separate one) would make the proactive path terminate in all cases._loadGapi's underlyingloadJSstill carries// TODO: consider adding timeout support & cancellation. Because proactive init awaits it beforeinitializeCurrentUser, anapis.google.com/js/api.jsrequest that hangs, or that returns a non-gapi 200, e.g. a captive portal, meansonAuthStateChangednever fires at all on Safari/iOS/Android (measured: no auth event in a 45 s window; desktop Chrome, which skips the path, is ready in 4 ms). A blocked request is fine, sinceonerrorfires. That is a separate defect and I am happy to file it separately if preferred.Happy to open a PR if the maintainers agree on the shape.