diff --git a/src/app-check/app-check.spec.ts b/src/app-check/app-check.spec.ts index dd88f1e13..861b18dbc 100644 --- a/src/app-check/app-check.spec.ts +++ b/src/app-check/app-check.spec.ts @@ -1,7 +1,7 @@ -import { PLATFORM_ID } from '@angular/core'; +import { PLATFORM_ID, PendingTasks } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { FirebaseApp, getApp, initializeApp, provideFirebaseApp } from '@angular/fire/app'; -import { APP_CHECK_ON_SERVER, AppCheck, CustomProvider, initializeAppCheck, provideAppCheck } from '@angular/fire/app-check'; +import { FirebaseApp, deleteApp, getApp, initializeApp, provideFirebaseApp } from '@angular/fire/app'; +import { APP_CHECK_ON_SERVER, AppCheck, CustomProvider, initializeAppCheck, onTokenChanged, provideAppCheck } from '@angular/fire/app-check'; import { Auth, connectAuthEmulator, getAuth, provideAuth } from '@angular/fire/auth'; import { COMMON_CONFIG } from '../test-config'; import { rando } from '../utils'; @@ -109,4 +109,39 @@ describe('AppCheck', () => { expect(appCheck.app).toEqual(TestBed.inject(FirebaseApp)); }); + describe('zone wrapping', () => { + let appCheck: AppCheck; + let addTask: jasmine.Spy; + let cleanups: (() => void)[]; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)), + provideTestAppCheck(), + ], + }); + appCheck = TestBed.inject(AppCheck); + addTask = spyOn(TestBed.inject(PendingTasks), 'add').and.callThrough(); + cleanups = []; + }); + + afterEach(() => { + // Listeners re-enter this test's injector, so none may outlive the TestBed. + cleanups.forEach(cleanup => cleanup()); + }); + + it('onTokenChanged does not hold the application unstable', () => { + const unsubscribe = TestBed.runInInjectionContext(() => onTokenChanged(appCheck, () => undefined)); + cleanups.push(unsubscribe); + expect(addTask).not.toHaveBeenCalled(); + }); + + it('a blocking call in the same context does register a task', async () => { + await TestBed.runInInjectionContext(() => deleteApp(initializeApp(COMMON_CONFIG, rando()))); + expect(addTask).toHaveBeenCalled(); + }); + + }); + }); diff --git a/src/app-check/firebase.ts b/src/app-check/firebase.ts index bfaefcc29..e7928dafb 100644 --- a/src/app-check/firebase.ts +++ b/src/app-check/firebase.ts @@ -12,5 +12,5 @@ import { export const getLimitedUseToken = ɵzoneWrap(_getLimitedUseToken, true, 2); export const getToken = ɵzoneWrap(_getToken, true); export const initializeAppCheck = ɵzoneWrap(_initializeAppCheck, true); -export const onTokenChanged = ɵzoneWrap(_onTokenChanged, true); +export const onTokenChanged = ɵzoneWrap(_onTokenChanged, false, 1); export const setTokenAutoRefreshEnabled = ɵzoneWrap(_setTokenAutoRefreshEnabled, true); diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 62af613f8..d5262aa9c 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,5 +1,6 @@ +import { PendingTasks } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { FirebaseApp, initializeApp, provideFirebaseApp } from '@angular/fire/app'; +import { FirebaseApp, deleteApp, initializeApp, onLog, provideFirebaseApp } from '@angular/fire/app'; import { COMMON_CONFIG } from '../test-config'; import { rando } from '../utils'; @@ -30,4 +31,28 @@ describe('FirebaseApp', () => { }); + describe('zone wrapping', () => { + let addTask: jasmine.Spy; + + beforeEach(() => { + addTask = spyOn(TestBed.inject(PendingTasks), 'add').and.callThrough(); + }); + + afterEach(() => { + // The handler onLog installs re-enters this test's injector, so it must not outlive the TestBed. + TestBed.runInInjectionContext(() => onLog(null)); + }); + + it('onLog does not hold the application unstable', () => { + TestBed.runInInjectionContext(() => onLog(() => undefined)); + expect(addTask).not.toHaveBeenCalled(); + }); + + it('a blocking call in the same context does register a task', async () => { + await TestBed.runInInjectionContext(() => deleteApp(initializeApp(COMMON_CONFIG, rando()))); + expect(addTask).toHaveBeenCalled(); + }); + + }); + }); diff --git a/src/app/firebase.ts b/src/app/firebase.ts index 7d9487193..a28f8f453 100644 --- a/src/app/firebase.ts +++ b/src/app/firebase.ts @@ -17,6 +17,6 @@ export const getApp = ɵzoneWrap(_getApp, true); export const getApps = ɵzoneWrap(_getApps, true); export const initializeApp = ɵzoneWrap(_initializeApp, true); export const initializeServerApp = ɵzoneWrap(_initializeServerApp, true); -export const onLog = ɵzoneWrap(_onLog, true); +export const onLog = ɵzoneWrap(_onLog, false, 1); export const registerVersion = ɵzoneWrap(_registerVersion, true); export const setLogLevel = ɵzoneWrap(_setLogLevel, true); diff --git a/src/database/database.spec.ts b/src/database/database.spec.ts index 003464a4f..608461b82 100644 --- a/src/database/database.spec.ts +++ b/src/database/database.spec.ts @@ -1,7 +1,29 @@ +import { PendingTasks } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { FirebaseApp, getApp, initializeApp, provideFirebaseApp } from '@angular/fire/app'; -import { Database, connectDatabaseEmulator, getDatabase, provideDatabase } from '@angular/fire/database'; -import { COMMON_CONFIG } from '../test-config'; +import { + DataSnapshot, + Database, + ListenEvent, + Query, + Unsubscribe, + auditTrail, + connectDatabaseEmulator, + enableLogging, + fromRef, + getDatabase, + list, + off, + onChildAdded, + onChildChanged, + onChildMoved, + onChildRemoved, + onValue, + provideDatabase, + ref, + stateChanges, +} from '@angular/fire/database'; +import { COMMON_CONFIG, databaseEmulatorPort } from '../test-config'; import { rando } from '../utils'; describe('Database', () => { @@ -19,7 +41,7 @@ describe('Database', () => { provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)), provideDatabase(() => { providedDatabase = getDatabase(getApp(appName)); - connectDatabaseEmulator(providedDatabase, 'localhost', 9002); + connectDatabaseEmulator(providedDatabase, 'localhost', databaseEmulatorPort); return providedDatabase; }), ], @@ -34,6 +56,86 @@ describe('Database', () => { expect(database.app).toEqual(app); }); + describe('zone wrapping', () => { + let addTask: jasmine.Spy; + let cleanups: (() => void)[]; + + beforeEach(() => { + addTask = spyOn(TestBed.inject(PendingTasks), 'add').and.callThrough(); + cleanups = []; + }); + + afterEach(() => { + // Listeners re-enter this test's injector, so none may outlive the TestBed. + cleanups.forEach(cleanup => cleanup()); + }); + + type ChildListener = (query: Query, callback: (snapshot: DataSnapshot) => void) => Unsubscribe; + const childListeners: [string, ChildListener][] = [ + ['onChildAdded', onChildAdded], + ['onChildChanged', onChildChanged], + ['onChildMoved', onChildMoved], + ['onChildRemoved', onChildRemoved], + ]; + childListeners.forEach(([name, listen]) => { + it(`${name} does not hold the application unstable`, () => { + const unsubscribe = TestBed.runInInjectionContext(() => listen(ref(database, rando()), () => undefined)); + cleanups.push(unsubscribe); + expect(addTask).not.toHaveBeenCalled(); + }); + }); + + it('off does not hold the application unstable', () => { + TestBed.runInInjectionContext(() => off(ref(database, rando()), 'value', () => undefined)); + expect(addTask).not.toHaveBeenCalled(); + }); + + it('enableLogging does not hold the application unstable', () => { + TestBed.runInInjectionContext(() => enableLogging(() => undefined)); + cleanups.push(() => TestBed.runInInjectionContext(() => enableLogging(false))); + expect(addTask).not.toHaveBeenCalled(); + }); + + [ListenEvent.added, ListenEvent.changed, ListenEvent.moved, ListenEvent.removed].forEach(event => { + it(`fromRef on ${event} does not hold the application unstable`, () => { + const subscription = TestBed.runInInjectionContext(() => fromRef(ref(database, rando()), event)).subscribe(); + cleanups.push(() => subscription.unsubscribe()); + expect(addTask).not.toHaveBeenCalled(); + }); + }); + + it('fromRef on the value event registers a pending task', () => { + const subscription = TestBed.runInInjectionContext(() => fromRef(ref(database, rando()), ListenEvent.value)).subscribe(); + cleanups.push(() => subscription.unsubscribe()); + expect(addTask).toHaveBeenCalledTimes(1); + }); + + it('stateChanges does not hold the application unstable', () => { + const subscription = TestBed.runInInjectionContext(() => stateChanges(ref(database, rando()))).subscribe(); + cleanups.push(() => subscription.unsubscribe()); + expect(addTask).not.toHaveBeenCalled(); + }); + + it('auditTrail does not hold the application unstable', () => { + const subscription = TestBed.runInInjectionContext(() => auditTrail(ref(database, rando()))).subscribe(); + cleanups.push(() => subscription.unsubscribe()); + expect(addTask).not.toHaveBeenCalled(); + }); + + it('onValue in the same context does register a task', () => { + const unsubscribe = TestBed.runInInjectionContext(() => onValue(ref(database, rando()), () => undefined)); + cleanups.push(unsubscribe); + expect(addTask).toHaveBeenCalledTimes(1); + }); + + it('list in the same context does register a task', () => { + const subscription = TestBed.runInInjectionContext(() => list(ref(database, rando()))).subscribe(); + cleanups.push(() => subscription.unsubscribe()); + expect(addTask).toHaveBeenCalledTimes(1); + }); + + }); + }); }); diff --git a/src/database/firebase.ts b/src/database/firebase.ts index 738794e8e..756f41588 100644 --- a/src/database/firebase.ts +++ b/src/database/firebase.ts @@ -44,7 +44,7 @@ import { export const child = ɵzoneWrap(_child, true, 2); export const connectDatabaseEmulator = ɵzoneWrap(_connectDatabaseEmulator, true); -export const enableLogging = ɵzoneWrap(_enableLogging, true); +export const enableLogging = ɵzoneWrap(_enableLogging, false, 1); export const endAt = ɵzoneWrap(_endAt, true, 2); export const endBefore = ɵzoneWrap(_endBefore, true, 2); export const equalTo = ɵzoneWrap(_equalTo, true, 2); @@ -57,11 +57,11 @@ export const goOnline = ɵzoneWrap(_goOnline, true); export const increment = ɵzoneWrap(_increment, true, 2); export const limitToFirst = ɵzoneWrap(_limitToFirst, true, 2); export const limitToLast = ɵzoneWrap(_limitToLast, true, 2); -export const off = ɵzoneWrap(_off, true); -export const onChildAdded = ɵzoneWrap(_onChildAdded, true); -export const onChildChanged = ɵzoneWrap(_onChildChanged, true); -export const onChildMoved = ɵzoneWrap(_onChildMoved, true); -export const onChildRemoved = ɵzoneWrap(_onChildRemoved, true); +export const off = ɵzoneWrap(_off, false, 1); +export const onChildAdded = ɵzoneWrap(_onChildAdded, false, 1); +export const onChildChanged = ɵzoneWrap(_onChildChanged, false, 1); +export const onChildMoved = ɵzoneWrap(_onChildMoved, false, 1); +export const onChildRemoved = ɵzoneWrap(_onChildRemoved, false, 1); export const onDisconnect = ɵzoneWrap(_onDisconnect, true); export const onValue = ɵzoneWrap(_onValue, true); export const orderByChild = ɵzoneWrap(_orderByChild, true, 2); diff --git a/src/database/overrides.ts b/src/database/overrides.ts new file mode 100644 index 000000000..18904433c --- /dev/null +++ b/src/database/overrides.ts @@ -0,0 +1,9 @@ +import { LogLevel, ɵzoneWrap } from '@angular/fire'; +import { ListenEvent, fromRef as _fromRef } from 'rxfire/database'; + +const fromRefUntilFirst = ɵzoneWrap(_fromRef, true, LogLevel.WARN); +const fromRefWithoutWaiting = ɵzoneWrap(_fromRef, false, LogLevel.WARN); + +// Only the value event fires on attach whatever the node holds. A child event may never fire. +export const fromRef: typeof _fromRef = (ref, event) => + event === ListenEvent.value ? fromRefUntilFirst(ref, event) : fromRefWithoutWaiting(ref, event); diff --git a/src/database/rxfire.ts b/src/database/rxfire.ts index b34588af7..13951e90e 100644 --- a/src/database/rxfire.ts +++ b/src/database/rxfire.ts @@ -3,7 +3,6 @@ import { ɵzoneWrap } from '@angular/fire'; import { auditTrail as _auditTrail, changeToData as _changeToData, - fromRef as _fromRef, list as _list, listVal as _listVal, object as _object, @@ -16,11 +15,14 @@ export { ListenerMethods } from 'rxfire/database'; -export const auditTrail = ɵzoneWrap(_auditTrail, true); +export { + fromRef +} from './overrides'; + +export const auditTrail = ɵzoneWrap(_auditTrail, false, 1); export const changeToData = ɵzoneWrap(_changeToData, true); -export const fromRef = ɵzoneWrap(_fromRef, true); export const list = ɵzoneWrap(_list, true); export const listVal = ɵzoneWrap(_listVal, true); export const object = ɵzoneWrap(_object, true); export const objectVal = ɵzoneWrap(_objectVal, true); -export const stateChanges = ɵzoneWrap(_stateChanges, true); +export const stateChanges = ɵzoneWrap(_stateChanges, false, 1); diff --git a/src/remote-config/firebase.ts b/src/remote-config/firebase.ts index 4b5540b2f..bc343a991 100644 --- a/src/remote-config/firebase.ts +++ b/src/remote-config/firebase.ts @@ -28,7 +28,7 @@ export const getNumber = ɵzoneWrap(_getNumber, true); export const getRemoteConfig = ɵzoneWrap(_getRemoteConfig, true); export const getString = ɵzoneWrap(_getString, true); export const getValue = ɵzoneWrap(_getValue, true); -export const isSupported = ɵzoneWrap(_isSupported, true); +export const isSupported = ɵzoneWrap(_isSupported, false); export const onConfigUpdate = ɵzoneWrap(_onConfigUpdate, true); export const setCustomSignals = ɵzoneWrap(_setCustomSignals, true); export const setLogLevel = ɵzoneWrap(_setLogLevel, true); diff --git a/src/remote-config/remote-config.spec.ts b/src/remote-config/remote-config.spec.ts index fc2842062..ec3dcd775 100644 --- a/src/remote-config/remote-config.spec.ts +++ b/src/remote-config/remote-config.spec.ts @@ -1,6 +1,7 @@ +import { PendingTasks } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { FirebaseApp, getApp, initializeApp, provideFirebaseApp } from '@angular/fire/app'; -import { RemoteConfig, getRemoteConfig, provideRemoteConfig } from '@angular/fire/remote-config'; +import { RemoteConfig, getRemoteConfig, isSupported, provideRemoteConfig } from '@angular/fire/remote-config'; import { COMMON_CONFIG } from '../test-config'; import { rando } from '../utils'; @@ -33,6 +34,13 @@ describe('RemoteConfig', () => { expect(remoteConfig.app).toEqual(app); }); + it('isSupported does not hold the application unstable', async () => { + const addTask = spyOn(TestBed.inject(PendingTasks), 'add').and.callThrough(); + const supported = TestBed.runInInjectionContext(isSupported); + expect(addTask).not.toHaveBeenCalled(); + expect(typeof await supported).toBe('boolean'); + }); + }); }); diff --git a/tools/build.ts b/tools/build.ts index 36f11be9e..e3c611a3f 100644 --- a/tools/build.ts +++ b/tools/build.ts @@ -161,9 +161,14 @@ ${exportedZoneWrappedFns} setUserId: { logLevel: LogLevel.VERBOSE }, setUserProperties: { logLevel: LogLevel.VERBOSE }, }), - reexport('app', 'firebase', 'firebase/app', tsKeys()), + reexport('app', 'firebase', 'firebase/app', tsKeys(), { + // The callback runs only when an SDK logs: `blockUntilFirst: false` otherwise `ApplicationRef.isStable` may never become `true`. + onLog: { blockUntilFirst: false, logLevel: LogLevel.WARN }, + }), reexport('app-check', 'firebase', 'firebase/app-check', tsKeys(), { getLimitedUseToken: { logLevel: LogLevel.VERBOSE }, + // The callback runs only once a token is obtained, which a first visit may never request: `blockUntilFirst: false` otherwise `ApplicationRef.isStable` may never become `true`. + onTokenChanged: { blockUntilFirst: false, logLevel: LogLevel.WARN }, }), reexport('auth', 'rxfire', 'rxfire/auth', tsKeys()), reexport('auth', 'firebase', 'firebase/auth', tsKeys(), { @@ -218,15 +223,32 @@ ${exportedZoneWrappedFns} verifyBeforeUpdateEmail: { logLevel: LogLevel.VERBOSE }, verifyPasswordResetCode: { logLevel: LogLevel.VERBOSE }, }), - reexport('database', 'rxfire', 'rxfire/database', tsKeys()), + reexport('database', 'rxfire', 'rxfire/database', tsKeys(), { + // These emit only on child events, which an empty node never produces: `blockUntilFirst: false` otherwise `ApplicationRef.isStable` may never become `true`. + auditTrail: { blockUntilFirst: false, logLevel: LogLevel.WARN }, + stateChanges: { blockUntilFirst: false, logLevel: LogLevel.WARN }, + + // Hand-written in `src/database/overrides.ts`, which determines `blockUntilFirst` per event. + fromRef: { override: true }, + }), reexport('database', 'firebase', 'firebase/database', tsKeys(), { child: { logLevel: LogLevel.VERBOSE }, + // The logger callback runs only when a live connection logs: `blockUntilFirst: false` otherwise `ApplicationRef.isStable` may never become `true`. + enableLogging: { blockUntilFirst: false, logLevel: LogLevel.WARN }, + endAt: { logLevel: LogLevel.VERBOSE }, endBefore: { logLevel: LogLevel.VERBOSE }, equalTo: { logLevel: LogLevel.VERBOSE }, increment: { logLevel: LogLevel.VERBOSE }, limitToFirst: { logLevel: LogLevel.VERBOSE }, limitToLast: { logLevel: LogLevel.VERBOSE }, + // The detach call never runs the callback it is given, and a child listener's callback first runs on a later change, or never on an empty node: `blockUntilFirst: false` otherwise `ApplicationRef.isStable` may never become `true`. + off: { blockUntilFirst: false, logLevel: LogLevel.WARN }, + onChildAdded: { blockUntilFirst: false, logLevel: LogLevel.WARN }, + onChildChanged: { blockUntilFirst: false, logLevel: LogLevel.WARN }, + onChildMoved: { blockUntilFirst: false, logLevel: LogLevel.WARN }, + onChildRemoved: { blockUntilFirst: false, logLevel: LogLevel.WARN }, + orderByChild: { logLevel: LogLevel.VERBOSE }, orderByKey: { logLevel: LogLevel.VERBOSE }, orderByPriority: { logLevel: LogLevel.VERBOSE }, @@ -278,7 +300,9 @@ ${exportedZoneWrappedFns} getBoolean: { exportName: 'getBooleanChanges' }, getAll: { exportName: 'getAllChanges' }, }), - reexport('remote-config', 'firebase', 'firebase/remote-config', tsKeys()), + reexport('remote-config', 'firebase', 'firebase/remote-config', tsKeys(), { + isSupported: { blockUntilFirst: false }, + }), reexport('storage', 'rxfire', 'rxfire/storage', tsKeys(), { getDownloadURL: null, getMetadata: null,