diff --git a/docs/remote-config/usage/index.md b/docs/remote-config/usage/index.md index 77662c6746..366ea1f94b 100644 --- a/docs/remote-config/usage/index.md +++ b/docs/remote-config/usage/index.md @@ -189,8 +189,7 @@ in applications that attach one or more listeners for them. ### Known Issues -1. **_Handle errors / retry in callback_** During testing here in react-native-firebase, we frequently received the "config_update_not_fetched" error when performing updates and fetching them rapidly. This may not occur in normal usage but be sure to include error handling code in your callback. If this error is raised, you should be able to fetch and activate the new config template with retries after a timeout. Tracked as https://github.com/firebase/firebase-ios-sdk/issues/11462 and a fix is anticipated in firebase-ios-sdk 10.12.0 -1. **_iOS web socket will never close_** During testing here in react-native-firebase, we identified a problem in firebase-ios-sdk where native listeners are not removed when you attempt to unsubscribe them, resulting in more update events than expected. As a temporary workaround to avoid the issue, we create a native listener on iOS the first time you subscribe to realtime update events, and we never remove the listener, even if you unsubscribe it. That means the web socket will never close once opened. This is tracked as https://github.com/firebase/firebase-ios-sdk/issues/11458 and a fix is anticipated in firebase-ios-sdk 10.12.0 +1. **_Other platform requires a `fetchAndActivate` before updates come through_** During testing here in react-native-firebase and the sister project `FlutterFire`, we noticed that the firebase-js-sdk does not seem to send realtime update events through unless you have previously called `fetchAndActivate` Here is an example of how to use the feature, with comments emphasizing the key points to know: @@ -198,19 +197,16 @@ Here is an example of how to use the feature, with comments emphasizing the key // Add a config update listener where appropriate, perhaps in app startup, or a specific app area. // Multiple listeners are supported, so listeners may be screen-specific and only handle certain keys // depending on application requirements -let remoteConfigListenerUnsubscriber = remoteConfig().onConfigUpdated((event, error) => { - if (error !== undefined) { - console.log('remote-config listener subscription error: ' + JSON.stringify(error)); - } else { - // Updated keys are keys that are added, removed, or changed value, metadata, or source - // Note: A key is considered updated if it is different then the activated config. - // If the new config is never activated, the same keys will remain in the set of - // of updated keys passed to the callback on every config update - console.log('remote-config updated keys: ' + JSON.stringify(event)); +let remoteConfigListenerUnsubscriber = onConfigUpdate(getRemoteConfig(), { + next: (update) => { + console.log('remote-config keys updated: ' + Array.from(update.getUpdatedKeys())); // If you use realtime updates, the SDK fetches the new config for you. // However, you must activate the new config so it is in effect - remoteConfig().activate(); + activate(getRemoteConfig()); + } + error: (error) => { + console.log('remote-config listener subscription error: ' + error); } }); diff --git a/packages/remote-config/e2e/config.e2e.js b/packages/remote-config/e2e/config.e2e.js index ee35ff1c89..c5f41ba875 100644 --- a/packages/remote-config/e2e/config.e2e.js +++ b/packages/remote-config/e2e/config.e2e.js @@ -698,246 +698,460 @@ describe('remoteConfig()', function () { }); }); - describe('onConfigUpdated parameter verification', function () { - it('throws an error if no callback provided', async function () { - const { getRemoteConfig, onConfigUpdated } = remoteConfigModular; - try { - onConfigUpdated(getRemoteConfig()); - throw new Error('Did not reject'); - } catch (error) { - error.message.should.containEql( - "'listenerOrObserver' expected a function or an object with 'next' function.", - ); - } + describe('onConfigUpdate', function () { + describe('onConfigUpdate parameter verification', function () { + it('throws an error if no callback provided', async function () { + const { getRemoteConfig, onConfigUpdate } = remoteConfigModular; + try { + onConfigUpdate(getRemoteConfig()); + throw new Error('Did not reject'); + } catch (error) { + error.message.should.containEql( + "'observer' expected an object with 'next' and 'error' functions.", + ); + } + }); }); - }); - describe('onConfigUpdated on un-supported platforms', function () { - if (!Platform.other) { - // Supported on non-other, tests are in the following describe block - return; - } + describe('onConfigUpdate callback works', function () { + let unsubscribers = []; + const timestamp = '_pls_rm_if_day_old_' + Date.now(); + + // We may get more than one callback if other e2e suites run parallel. + // But we have a specific parameter values, and we should only get one callback + // where the updatedKeys have a given parameter value. + // This verifier factory checks for that specific param name in updatedKeys, not a call count. + const getVerifier = function (paramName) { + return spy => { + if (!spy.called) return false; + for (let i = 0; i < spy.callCount; i++) { + const callbackParam = spy.getCall(i).args[0]; + if (callbackParam && callbackParam.getUpdatedKeys().has(paramName)) { + return true; + } + } - it('returns a descriptive error message if called', async function () { - const { getRemoteConfig, onConfigUpdated } = remoteConfigModular; - try { - onConfigUpdated(getRemoteConfig(), () => {}); - throw new Error('Did not reject'); - } catch (error) { - error.code.should.equal('unsupported'); - error.message.should.containEql('Not supported by the Firebase Javascript SDK'); - } + return false; + }; + }; + + before(async function () { + // configure a listener so any new templates are fetched and cached locally + const { fetchAndActivate, getAll, getRemoteConfig, onConfigUpdate } = remoteConfigModular; + const unsubscribe = onConfigUpdate(getRemoteConfig(), { + next: () => {}, + error: () => {}, + }); + + // activate to make sure all values are in effect, + // thus realtime updates only shows our testing work + await fetchAndActivate(getRemoteConfig()); + + // Check for any test param > 1hr old from busted test runs + const originalValues = getAll(getRemoteConfig()); + const staleDeletes = []; + Object.keys(originalValues).forEach(param => { + if (param.includes('_pls_rm_if_day_old_')) { + let paramMillis = Number.parseInt(param.slice(param.lastIndexOf('_') + 1), 10); + if (paramMillis < Date.now() - 1000 * 60 * 60) { + staleDeletes.push(param); + } + } + }); + + // If there is orphaned data, delete it on the server and let that settle + if (staleDeletes.length > 0) { + const response = await FirebaseHelpers.updateRemoteConfigTemplate({ + operations: { + delete: staleDeletes, + }, + }); + should(response.result !== undefined).equal(true, 'response result not defined'); + await Utils.sleep(1000); + } + + await fetchAndActivate(getRemoteConfig()); + unsubscribe(); + }); + + after(async function () { + // clean up our own test data after the whole suite runs + const response = await FirebaseHelpers.updateRemoteConfigTemplate({ + operations: { + delete: ['rttest1' + timestamp, 'rttest2' + timestamp, 'rttest3' + timestamp], + }, + }); + should(response.result !== undefined).equal(true, 'response result not defined'); + // console.error('after updateTemplate version: ' + response.result.templateVersion); + }); + + afterEach(async function () { + // make sure all our callbacks are unsubscribed after each test - convenient + for (let i = 0; i < unsubscribers.length; i++) { + unsubscribers[i](); + } + unsubscribers = []; + }); + + it('adds a listener and receives updates', async function () { + // Configure our listener + const { fetchAndActivate, getRemoteConfig, onConfigUpdate } = remoteConfigModular; + const config = getRemoteConfig(); + await fetchAndActivate(config); + const callback = sinon.spy(); + const unsubscribe = onConfigUpdate(config, { + next: update => callback(update), + error: () => {}, + }); + unsubscribers.push(unsubscribe); + // Update the template using our cloud function, so our listeners are called + let response = await FirebaseHelpers.updateRemoteConfigTemplate({ + operations: { + add: [{ name: 'rttest1' + timestamp, value: timestamp }], + }, + }); + should(response.result !== undefined).equal(true, 'response result not defined'); + + // Assert: we were called exactly once with expected update event contents + await Utils.spyToBeCalledWithVerifierAsync( + callback, + getVerifier('rttest1' + timestamp), + 60000, + ); + unsubscribe(); + }); + + it('manages multiple listeners', async function () { + const { fetchAndActivate, getRemoteConfig, onConfigUpdate } = remoteConfigModular; + const config = getRemoteConfig(); + + // activate the current config so the "updated" list starts empty + await fetchAndActivate(config); + + // Set up our listeners + const callback1 = sinon.spy(); + const unsubscribe1 = onConfigUpdate(config, { + next: update => callback1(update), + error: () => {}, + }); + unsubscribers.push(unsubscribe1); + const callback2 = sinon.spy(); + const unsubscribe2 = onConfigUpdate(config, { + next: update => callback2(update), + error: () => {}, + }); + unsubscribers.push(unsubscribe2); + const callback3 = sinon.spy(); + const unsubscribe3 = onConfigUpdate(config, { + next: update => callback3(update), + error: () => {}, + }); + unsubscribers.push(unsubscribe3); + + // Trigger an update that should call them all + let response = await FirebaseHelpers.updateRemoteConfigTemplate({ + operations: { + add: [{ name: 'rttest1' + timestamp, value: Date.now() + '' }], + }, + }); + should(response.result !== undefined).equal(true, 'response result not defined'); + + // Assert all were called with expected values + await Utils.spyToBeCalledWithVerifierAsync( + callback1, + getVerifier('rttest1' + timestamp), + 60000, + ); + await Utils.spyToBeCalledWithVerifierAsync( + callback2, + getVerifier('rttest1' + timestamp), + 60000, + ); + await Utils.spyToBeCalledWithVerifierAsync( + callback3, + getVerifier('rttest1' + timestamp), + 60000, + ); + + // Unsubscribe second listener and repeat, this time expecting no call on second listener + unsubscribe2(); + const callback2Count = callback2.callCount; + + // Trigger update that should call listener 1 and 3 for these values + response = await FirebaseHelpers.updateRemoteConfigTemplate({ + operations: { + add: [{ name: 'rttest2' + timestamp, value: Date.now() + '' }], + }, + }); + should(response.result !== undefined).equal(true, 'response result not defined'); + + // Assert first and third were called with expected values + await Utils.spyToBeCalledWithVerifierAsync( + callback1, + getVerifier('rttest2' + timestamp), + 60000, + ); + await Utils.spyToBeCalledWithVerifierAsync( + callback3, + getVerifier('rttest2' + timestamp), + 60000, + ); + + // callback2 should not have been called again - same call count expected + should(callback2.callCount).equal(callback2Count); + + // Unsubscribe remaining listeners + unsubscribe1(); + unsubscribe3(); + const callback1Count = callback1.callCount; + const callback3Count = callback3.callCount; + + // Trigger an update that should call no listeners + response = await FirebaseHelpers.updateRemoteConfigTemplate({ + operations: { + add: [{ name: 'rttest3' + timestamp, value: Date.now() + '' }], + }, + }); + should(response.result !== undefined).equal(true, 'response result not defined'); + // Give the servers plenty of time to call us + await Utils.sleep(20000); + should(callback1.callCount).equal(callback1Count); + should(callback2.callCount).equal(callback2Count); + should(callback3.callCount).equal(callback3Count); + }); + + // - react-native reload + // - make sure native count is zero + // - add a listener, assert native count one + // - rnReload via detox, assert native count is zero + it('handles react-native reload', async function () { + // TODO implement rnReload test + // console.log('checking listener functionality across javascript layer reload'); + }); }); }); - describe('onConfigUpdated on supported platforms', function () { - if (Platform.other) { - // Not supported on Web - return; - } + // deprecated API prior to official web support + describe('onConfigUpdated', function () { + describe('onConfigUpdated parameter verification', function () { + it('throws an error if no callback provided', async function () { + const { getRemoteConfig, onConfigUpdated } = remoteConfigModular; + try { + onConfigUpdated(getRemoteConfig()); + throw new Error('Did not reject'); + } catch (error) { + error.message.should.containEql( + "'listenerOrObserver' expected a function or an object with 'next' function.", + ); + } + }); + }); - let unsubscribers = []; - const timestamp = '_pls_rm_if_day_old_' + Date.now(); - - // We may get more than one callback if other e2e suites run parallel. - // But we have a specific parameter values, and we should only get one callback - // where the updatedKeys have a given parameter value. - // This verifier factory checks for that specific param name in updatedKeys, not a call count. - const getVerifier = function (paramName) { - return spy => { - if (!spy.called) return false; - for (let i = 0; i < spy.callCount; i++) { - const callbackEvent = spy.getCall(i).args[0]; - if ( - callbackEvent && - callbackEvent.updatedKeys && - callbackEvent.updatedKeys.includes(paramName) - ) { - return true; + describe('onConfigUpdated deprecated API', function () { + let unsubscribers = []; + const timestamp = '_pls_rm_if_day_old_' + Date.now(); + + // We may get more than one callback if other e2e suites run parallel. + // But we have a specific parameter values, and we should only get one callback + // where the updatedKeys have a given parameter value. + // This verifier factory checks for that specific param name in updatedKeys, not a call count. + const getVerifier = function (paramName) { + return spy => { + if (!spy.called) return false; + for (let i = 0; i < spy.callCount; i++) { + const callbackEvent = spy.getCall(i).args[0]; + if ( + callbackEvent && + callbackEvent.updatedKeys && + callbackEvent.updatedKeys.includes(paramName) + ) { + return true; + } } - } - return false; + return false; + }; }; - }; - - before(async function () { - // configure a listener so any new templates are fetched and cached locally - const { fetchAndActivate, getAll, getRemoteConfig, onConfigUpdated } = remoteConfigModular; - const unsubscribe = onConfigUpdated(getRemoteConfig(), () => {}); - - // activate to make sure all values are in effect, - // thus realtime updates only shows our testing work - await fetchAndActivate(getRemoteConfig()); - - // Check for any test param > 1hr old from busted test runs - const originalValues = getAll(getRemoteConfig()); - const staleDeletes = []; - Object.keys(originalValues).forEach(param => { - if (param.includes('_pls_rm_if_day_old_')) { - let paramMillis = Number.parseInt(param.slice(param.lastIndexOf('_') + 1), 10); - if (paramMillis < Date.now() - 1000 * 60 * 60) { - staleDeletes.push(param); + + before(async function () { + // configure a listener so any new templates are fetched and cached locally + const { fetchAndActivate, getAll, getRemoteConfig, onConfigUpdated } = + remoteConfigModular; + const unsubscribe = onConfigUpdated(getRemoteConfig(), () => {}); + + // activate to make sure all values are in effect, + // thus realtime updates only shows our testing work + await fetchAndActivate(getRemoteConfig()); + + // Check for any test param > 1hr old from busted test runs + const originalValues = getAll(getRemoteConfig()); + const staleDeletes = []; + Object.keys(originalValues).forEach(param => { + if (param.includes('_pls_rm_if_day_old_')) { + let paramMillis = Number.parseInt(param.slice(param.lastIndexOf('_') + 1), 10); + if (paramMillis < Date.now() - 1000 * 60 * 60) { + staleDeletes.push(param); + } } + }); + + // If there is orphaned data, delete it on the server and let that settle + if (staleDeletes.length > 0) { + const response = await FirebaseHelpers.updateRemoteConfigTemplate({ + operations: { + delete: staleDeletes, + }, + }); + should(response.result !== undefined).equal(true, 'response result not defined'); + await Utils.sleep(1000); } + + await fetchAndActivate(getRemoteConfig()); + unsubscribe(); }); - // If there is orphaned data, delete it on the server and let that settle - if (staleDeletes.length > 0) { + after(async function () { + // clean up our own test data after the whole suite runs const response = await FirebaseHelpers.updateRemoteConfigTemplate({ operations: { - delete: staleDeletes, + delete: ['rttest1' + timestamp, 'rttest2' + timestamp, 'rttest3' + timestamp], }, }); should(response.result !== undefined).equal(true, 'response result not defined'); - await Utils.sleep(1000); - } + // console.error('after updateTemplate version: ' + response.result.templateVersion); + }); - await fetchAndActivate(getRemoteConfig()); - unsubscribe(); - }); + afterEach(async function () { + // make sure all our callbacks are unsubscribed after each test - convenient + for (let i = 0; i < unsubscribers.length; i++) { + unsubscribers[i](); + } + unsubscribers = []; + }); + + it('adds a listener and receives updates', async function () { + // Configure our listener + const { fetchAndActivate, getRemoteConfig, onConfigUpdated } = remoteConfigModular; + const config = getRemoteConfig(); + await fetchAndActivate(config); + const callback = sinon.spy(); + const unsubscribe = onConfigUpdated(config, (event, error) => callback(event, error)); + unsubscribers.push(unsubscribe); + // Update the template using our cloud function, so our listeners are called + let response = await FirebaseHelpers.updateRemoteConfigTemplate({ + operations: { + add: [{ name: 'rttest1' + timestamp, value: timestamp }], + }, + }); + should(response.result !== undefined).equal(true, 'response result not defined'); - after(async function () { - // clean up our own test data after the whole suite runs - const response = await FirebaseHelpers.updateRemoteConfigTemplate({ - operations: { - delete: ['rttest1' + timestamp, 'rttest2' + timestamp, 'rttest3' + timestamp], - }, + // Assert: we were called exactly once with expected update event contents + await Utils.spyToBeCalledWithVerifierAsync( + callback, + getVerifier('rttest1' + timestamp), + 60000, + ); + unsubscribe(); }); - should(response.result !== undefined).equal(true, 'response result not defined'); - // console.error('after updateTemplate version: ' + response.result.templateVersion); - }); - afterEach(async function () { - // make sure all our callbacks are unsubscribed after each test - convenient - for (let i = 0; i < unsubscribers.length; i++) { - unsubscribers[i](); - } - unsubscribers = []; - }); - - it('adds a listener and receives updates', async function () { - // Configure our listener - const { fetchAndActivate, getRemoteConfig, onConfigUpdated } = remoteConfigModular; - const config = getRemoteConfig(); - await fetchAndActivate(config); - const callback = sinon.spy(); - const unsubscribe = onConfigUpdated(config, (event, error) => callback(event, error)); - unsubscribers.push(unsubscribe); - // Update the template using our cloud function, so our listeners are called - let response = await FirebaseHelpers.updateRemoteConfigTemplate({ - operations: { - add: [{ name: 'rttest1' + timestamp, value: timestamp }], - }, - }); - should(response.result !== undefined).equal(true, 'response result not defined'); - - // Assert: we were called exactly once with expected update event contents - await Utils.spyToBeCalledWithVerifierAsync( - callback, - getVerifier('rttest1' + timestamp), - 60000, - ); - unsubscribe(); - }); - - it('manages multiple listeners', async function () { - const { fetchAndActivate, getRemoteConfig, onConfigUpdated } = remoteConfigModular; - const config = getRemoteConfig(); - - // activate the current config so the "updated" list starts empty - await fetchAndActivate(config); - - // Set up our listeners - const callback1 = sinon.spy(); - const unsubscribe1 = onConfigUpdated(config, (event, error) => callback1(event, error)); - unsubscribers.push(unsubscribe1); - const callback2 = sinon.spy(); - const unsubscribe2 = onConfigUpdated(config, (event, error) => callback2(event, error)); - unsubscribers.push(unsubscribe2); - const callback3 = sinon.spy(); - const unsubscribe3 = onConfigUpdated(config, (event, error) => callback3(event, error)); - unsubscribers.push(unsubscribe3); - - // Trigger an update that should call them all - let response = await FirebaseHelpers.updateRemoteConfigTemplate({ - operations: { - add: [{ name: 'rttest1' + timestamp, value: Date.now() + '' }], - }, - }); - should(response.result !== undefined).equal(true, 'response result not defined'); - - // Assert all were called with expected values - await Utils.spyToBeCalledWithVerifierAsync( - callback1, - getVerifier('rttest1' + timestamp), - 60000, - ); - await Utils.spyToBeCalledWithVerifierAsync( - callback2, - getVerifier('rttest1' + timestamp), - 60000, - ); - await Utils.spyToBeCalledWithVerifierAsync( - callback3, - getVerifier('rttest1' + timestamp), - 60000, - ); - - // Unsubscribe second listener and repeat, this time expecting no call on second listener - unsubscribe2(); - const callback2Count = callback2.callCount; - - // Trigger update that should call listener 1 and 3 for these values - response = await FirebaseHelpers.updateRemoteConfigTemplate({ - operations: { - add: [{ name: 'rttest2' + timestamp, value: Date.now() + '' }], - }, - }); - should(response.result !== undefined).equal(true, 'response result not defined'); - - // Assert first and third were called with expected values - await Utils.spyToBeCalledWithVerifierAsync( - callback1, - getVerifier('rttest2' + timestamp), - 60000, - ); - await Utils.spyToBeCalledWithVerifierAsync( - callback3, - getVerifier('rttest2' + timestamp), - 60000, - ); - - // callback2 should not have been called again - same call count expected - should(callback2.callCount).equal(callback2Count); - - // Unsubscribe remaining listeners - unsubscribe1(); - unsubscribe3(); - const callback1Count = callback1.callCount; - const callback3Count = callback3.callCount; - - // Trigger an update that should call no listeners - response = await FirebaseHelpers.updateRemoteConfigTemplate({ - operations: { - add: [{ name: 'rttest3' + timestamp, value: Date.now() + '' }], - }, - }); - should(response.result !== undefined).equal(true, 'response result not defined'); - // Give the servers plenty of time to call us - await Utils.sleep(20000); - should(callback1.callCount).equal(callback1Count); - should(callback2.callCount).equal(callback2Count); - should(callback3.callCount).equal(callback3Count); - }); - - // - react-native reload - // - make sure native count is zero - // - add a listener, assert native count one - // - rnReload via detox, assert native count is zero - it('handles react-native reload', async function () { - // TODO implement rnReload test - // console.log('checking listener functionality across javascript layer reload'); + it('manages multiple listeners', async function () { + const { fetchAndActivate, getRemoteConfig, onConfigUpdated } = remoteConfigModular; + const config = getRemoteConfig(); + + // activate the current config so the "updated" list starts empty + await fetchAndActivate(config); + + // Set up our listeners + const callback1 = sinon.spy(); + const unsubscribe1 = onConfigUpdated(config, (event, error) => callback1(event, error)); + unsubscribers.push(unsubscribe1); + const callback2 = sinon.spy(); + const unsubscribe2 = onConfigUpdated(config, (event, error) => callback2(event, error)); + unsubscribers.push(unsubscribe2); + const callback3 = sinon.spy(); + const unsubscribe3 = onConfigUpdated(config, (event, error) => callback3(event, error)); + unsubscribers.push(unsubscribe3); + + // Trigger an update that should call them all + let response = await FirebaseHelpers.updateRemoteConfigTemplate({ + operations: { + add: [{ name: 'rttest1' + timestamp, value: Date.now() + '' }], + }, + }); + should(response.result !== undefined).equal(true, 'response result not defined'); + + // Assert all were called with expected values + await Utils.spyToBeCalledWithVerifierAsync( + callback1, + getVerifier('rttest1' + timestamp), + 60000, + ); + await Utils.spyToBeCalledWithVerifierAsync( + callback2, + getVerifier('rttest1' + timestamp), + 60000, + ); + await Utils.spyToBeCalledWithVerifierAsync( + callback3, + getVerifier('rttest1' + timestamp), + 60000, + ); + + // Unsubscribe second listener and repeat, this time expecting no call on second listener + unsubscribe2(); + const callback2Count = callback2.callCount; + + // Trigger update that should call listener 1 and 3 for these values + response = await FirebaseHelpers.updateRemoteConfigTemplate({ + operations: { + add: [{ name: 'rttest2' + timestamp, value: Date.now() + '' }], + }, + }); + should(response.result !== undefined).equal(true, 'response result not defined'); + + // Assert first and third were called with expected values + await Utils.spyToBeCalledWithVerifierAsync( + callback1, + getVerifier('rttest2' + timestamp), + 60000, + ); + await Utils.spyToBeCalledWithVerifierAsync( + callback3, + getVerifier('rttest2' + timestamp), + 60000, + ); + + // callback2 should not have been called again - same call count expected + should(callback2.callCount).equal(callback2Count); + + // Unsubscribe remaining listeners + unsubscribe1(); + unsubscribe3(); + const callback1Count = callback1.callCount; + const callback3Count = callback3.callCount; + + // Trigger an update that should call no listeners + response = await FirebaseHelpers.updateRemoteConfigTemplate({ + operations: { + add: [{ name: 'rttest3' + timestamp, value: Date.now() + '' }], + }, + }); + should(response.result !== undefined).equal(true, 'response result not defined'); + // Give the servers plenty of time to call us + await Utils.sleep(20000); + should(callback1.callCount).equal(callback1Count); + should(callback2.callCount).equal(callback2Count); + should(callback3.callCount).equal(callback3Count); + }); + + // - react-native reload + // - make sure native count is zero + // - add a listener, assert native count one + // - rnReload via detox, assert native count is zero + it('handles react-native reload', async function () { + // TODO implement rnReload test + // console.log('checking listener functionality across javascript layer reload'); + }); }); }); diff --git a/packages/remote-config/lib/index.d.ts b/packages/remote-config/lib/index.d.ts index bb48d647b0..292150c1c0 100644 --- a/packages/remote-config/lib/index.d.ts +++ b/packages/remote-config/lib/index.d.ts @@ -291,6 +291,45 @@ export namespace FirebaseRemoteConfigTypes { */ type LastFetchStatusType = 'success' | 'failure' | 'no_fetch_yet' | 'throttled'; + /** + * Contains information about which keys have been updated. + */ + export interface ConfigUpdate { + /** + * Parameter keys whose values have been updated from the currently activated values. + * Includes keys that are added, deleted, or whose value, value source, or metadata has changed. + */ + getUpdatedKeys(): Set; + } + + /** + * Observer interface for receiving real-time Remote Config update notifications. + * + * NOTE: Although an `complete` callback can be provided, it will + * never be called because the ConfigUpdate stream is never-ending. + */ + export interface ConfigUpdateObserver { + /** + * Called when a new ConfigUpdate is available. + */ + next: (configUpdate: ConfigUpdate) => void; + + /** + * Called if an error occurs during the stream. + */ + error: (error: FirebaseError) => void; + + /** + * Called when the stream is gracefully terminated. + */ + complete: () => void; + } + + /** + * A function that unsubscribes from a real-time event stream. + */ + export type Unsubscribe = () => void; + /** * The Firebase Remote RemoteConfig service interface. * @@ -376,14 +415,33 @@ export namespace FirebaseRemoteConfigTypes { */ setDefaultsFromResource(resourceName: string): Promise; + /** + * Starts listening for real-time config updates from the Remote Config backend and automatically + * fetches updates from the Remote Config backend when they are available. + * + * @remarks + * If a connection to the Remote Config backend is not already open, calling this method will + * open it. Multiple listeners can be added by calling this method again, but subsequent calls + * re-use the same connection to the backend. + * + * The list of updated keys passed to the callback will include all keys not currently active, + * and the config update process fetches the new config but does not automatically activate + * it for you. Typically you will activate the config in your callback to use the new values. + * + * @param remoteConfig - The {@link RemoteConfig} instance. + * @param observer - The {@link ConfigUpdateObserver} to be notified of config updates. + * @returns An {@link Unsubscribe} function to remove the listener. + */ + onConfigUpdate(remoteConfig: RemoteConfig, observer: ConfigUpdateObserver): Unsubscribe; + /** * Start listening for real-time config updates from the Remote Config backend and * automatically fetch updates when they’re available. Note that the list of updated keys * passed to the callback will include all keys not currently active, and the config update * process fetches the new config but does not automatically activate for you. Typically * you will want to activate the config in your callback so the new values are in force. - * * @param listener called with either array of updated keys or error arg when config changes + * @deprecated use official firebase-js-sdk onConfigUpdate now that web supports realtime */ onConfigUpdated(listener: CallbackOrObserver): () => void; @@ -541,8 +599,10 @@ export namespace FirebaseRemoteConfigTypes { reset(): Promise; } + // deprecated: from pre-Web realtime remote-config support - remove with onConfigUpdated export type CallbackOrObserver any> = T | { next: T }; + // deprecated: from pre-Web realtime remote-config support - remove with onConfigUpdated export type OnConfigUpdatedListenerCallback = ( event?: { updatedKeys: string[] }, error?: { diff --git a/packages/remote-config/lib/index.js b/packages/remote-config/lib/index.js index 24cbe810df..63a8b0c4e1 100644 --- a/packages/remote-config/lib/index.js +++ b/packages/remote-config/lib/index.js @@ -22,6 +22,7 @@ import { isString, isUndefined, isIOS, + isFunction, parseListenerOrObserver, } from '@react-native-firebase/app/lib/common'; import Value from './RemoteConfigValue'; @@ -241,11 +242,69 @@ class FirebaseConfigModule extends FirebaseModule { return this._promiseWithConstants(this.native.setDefaultsFromResource(resourceName)); } + /** + * Registers an observer to changes in the configuration. + * + * @param observer - The {@link ConfigUpdateObserver} to be notified of config updates. + * @returns An {@link Unsubscribe} function to remove the listener. + */ + onConfigUpdate(observer) { + if (!isObject(observer) || !isFunction(observer.next) || !isFunction(observer.error)) { + throw new Error("'observer' expected an object with 'next' and 'error' functions."); + } + + // We maintaine our pre-web-support native interface but bend it to match + // the official JS SDK API by assuming the callback is an Observer, and sending it a ConfigUpdate + // compatible parameter that implements the `getUpdatedKeys` method + let unsubscribed = false; + const subscription = this.emitter.addListener( + this.eventNameForApp('on_config_updated'), + event => { + const { resultType } = event; + if (resultType === 'success') { + observer.next({ + getUpdatedKeys: () => { + return new Set(event.updatedKeys); + }, + }); + return; + } + + observer.error({ + code: event.code, + message: event.message, + nativeErrorMessage: event.nativeErrorMessage, + }); + }, + ); + if (this._configUpdateListenerCount === 0) { + this.native.onConfigUpdated(); + } + + this._configUpdateListenerCount++; + + return () => { + if (unsubscribed) { + // there is no harm in calling this multiple times to unsubscribe, + // but anything after the first call is a no-op + return; + } else { + unsubscribed = true; + } + subscription.remove(); + this._configUpdateListenerCount--; + if (this._configUpdateListenerCount === 0) { + this.native.removeConfigUpdateRegistration(); + } + }; + } + /** * Registers a listener to changes in the configuration. * * @param listenerOrObserver - function called on config change * @returns {function} unsubscribe listener + * @deprecated use official firebase-js-sdk onConfigUpdate now that web supports realtime */ onConfigUpdated(listenerOrObserver) { const listener = parseListenerOrObserver(listenerOrObserver); diff --git a/packages/remote-config/lib/modular/index.d.ts b/packages/remote-config/lib/modular/index.d.ts index 062cc01d82..98bca2c5be 100644 --- a/packages/remote-config/lib/modular/index.d.ts +++ b/packages/remote-config/lib/modular/index.d.ts @@ -27,7 +27,12 @@ import RemoteConfigLogLevel = FirebaseRemoteConfigTypes.RemoteConfigLogLevel; import FirebaseApp = ReactNativeFirebase.FirebaseApp; import LastFetchStatusInterface = FirebaseRemoteConfigTypes.LastFetchStatus; import ValueSourceInterface = FirebaseRemoteConfigTypes.ValueSource; +import ConfigUpdate = FirebaseRemoteConfigTypes.ConfigUpdate; +import ConfigUpdateObserver = FirebaseRemoteConfigTypes.ConfigUpdateObserver; +import Unsubscribe = FirebaseRemoteConfigTypes.Unsubscribe; +// deprecated: from pre-Web realtime remote-config support - remove with onConfigUpdated import CallbackOrObserver = FirebaseRemoteConfigTypes.CallbackOrObserver; +// deprecated: from pre-Web realtime remote-config support - remove with onConfigUpdated import OnConfigUpdatedListenerCallback = FirebaseRemoteConfigTypes.OnConfigUpdatedListenerCallback; export const LastFetchStatus: LastFetchStatusInterface; @@ -203,12 +208,35 @@ export function setDefaultsFromResource( resourceName: string, ): Promise; +/** + * Starts listening for real-time config updates from the Remote Config backend and automatically + * fetches updates from the Remote Config backend when they are available. + * + * @remarks + * If a connection to the Remote Config backend is not already open, calling this method will + * open it. Multiple listeners can be added by calling this method again, but subsequent calls + * re-use the same connection to the backend. + * + * The list of updated keys passed to the callback will include all keys not currently active, + * and the config update process fetches the new config but does not automatically activate + * it for you. Typically you will activate the config in your callback to use the new values. + * + * @param remoteConfig - The {@link RemoteConfig} instance. + * @param observer - The {@link ConfigUpdateObserver} to be notified of config updates. + * @returns An {@link Unsubscribe} function to remove the listener. + */ +export function onConfigUpdate( + remoteConfig: RemoteConfig, + observer: ConfigUpdateObserver, +): Unsubscribe; + /** * Registers a listener to changes in the configuration. * * @param remoteConfig - RemoteConfig instance * @param callback - function called on config change * @returns {function} unsubscribe listener + * @deprecated use official firebase-js-sdk onConfigUpdate now that web supports realtime */ export function onConfigUpdated( remoteConfig: RemoteConfig, diff --git a/packages/remote-config/lib/modular/index.js b/packages/remote-config/lib/modular/index.js index 569cf49e08..d04ed6b189 100644 --- a/packages/remote-config/lib/modular/index.js +++ b/packages/remote-config/lib/modular/index.js @@ -28,6 +28,8 @@ import { MODULAR_DEPRECATION_ARG } from '@react-native-firebase/app/lib/common'; * @typedef {import('..').FirebaseRemoteConfigTypes.ConfigValues} ConfigValues * @typedef {import('..').FirebaseRemoteConfigTypes.LastFetchStatusType} LastFetchStatusType * @typedef {import('..').FirebaseRemoteConfigTypes.RemoteConfigLogLevel} RemoteConfigLogLevel + * @typedef {import('..').FirebaseRemoteConfigTypes.ConfigUpdateObserver} ConfigUpdateObserver + * @typedef {import('..').FirebaseRemoteConfigTypes.Unsubscribe} Unsubscribe * @typedef {import('.').CustomSignals} CustomSignals */ @@ -239,12 +241,25 @@ export function setDefaultsFromResource(remoteConfig, resourceName) { ); } +/** + * Registers a listener to changes in the configuration. + * + * @param {RemoteConfig} remoteConfig - RemoteConfig instance + * @param {ConfigUpdateObserver} observer - to be notified of config updates. + * @returns {Unsubscribe} function to remove the listener. + * @deprecated use official firebase-js-sdk onConfigUpdate now that web supports realtime + */ +export function onConfigUpdate(remoteConfig, observer) { + return remoteConfig.onConfigUpdate.call(remoteConfig, observer, MODULAR_DEPRECATION_ARG); +} + /** * Registers a listener to changes in the configuration. * * @param {RemoteConfig} remoteConfig - RemoteConfig instance * @param {CallbackOrObserver} callback - function called on config change * @returns {function} unsubscribe listener + * @deprecated use official firebase-js-sdk onConfigUpdate now that web supports realtime */ export function onConfigUpdated(remoteConfig, callback) { return remoteConfig.onConfigUpdated.call(remoteConfig, callback, MODULAR_DEPRECATION_ARG); diff --git a/packages/remote-config/lib/polyfills.js b/packages/remote-config/lib/polyfills.js index 93165adede..f2294f55fe 100644 --- a/packages/remote-config/lib/polyfills.js +++ b/packages/remote-config/lib/polyfills.js @@ -1,8 +1,9 @@ -/* - * Copyright (c) 2016-present Invertase Limited & Contributors +/** + * @license + * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this library except in compliance with the License. + * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 @@ -12,17 +13,20 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. - * */ - import { polyfillGlobal } from 'react-native/Libraries/Utilities/PolyfillFunctions'; +import { ReadableStream } from 'web-streams-polyfill/dist/ponyfill'; +import { fetch, Headers, Request, Response } from 'react-native-fetch-api'; -// maybe this could be remote-config local install of text-encoding (similar to ai package) -import { TextEncoder, TextDecoder } from 'text-encoding'; +polyfillGlobal( + 'fetch', + () => + (...args) => + fetch(args[0], { ...args[1], reactNative: { textStreaming: true } }), +); +polyfillGlobal('Headers', () => Headers); +polyfillGlobal('Request', () => Request); +polyfillGlobal('Response', () => Response); +polyfillGlobal('ReadableStream', () => ReadableStream); -polyfillGlobal('TextEncoder', () => TextEncoder); -polyfillGlobal('TextDecoder', () => TextDecoder); -// Object.assign(global, { -// TextEncoder: TextEncoder, -// TextDecoder: TextDecoder, -// }); +import 'text-encoding'; diff --git a/packages/remote-config/lib/web/RNFBConfigModule.js b/packages/remote-config/lib/web/RNFBConfigModule.js index a84987f4fc..f43015adc8 100644 --- a/packages/remote-config/lib/web/RNFBConfigModule.js +++ b/packages/remote-config/lib/web/RNFBConfigModule.js @@ -8,9 +8,10 @@ import { fetchConfig, getAll, makeIDBAvailable, + onConfigUpdate, setCustomSignals, } from '@react-native-firebase/app/lib/internal/web/firebaseRemoteConfig'; -import { guard, getWebError } from '@react-native-firebase/app/lib/internal/web/utils'; +import { guard, getWebError, emitEvent } from '@react-native-firebase/app/lib/internal/web/utils'; let configSettingsForInstance = { // [APP_NAME]: RemoteConfigSettings @@ -24,6 +25,8 @@ function makeGlobalsAvailable() { makeIDBAvailable(); } +const onConfigUpdateListeners = {}; + function getRemoteConfigInstanceForApp(appName, overrides /*: RemoteConfigSettings */) { makeGlobalsAvailable(); const configSettings = configSettingsForInstance[appName] ?? { @@ -122,10 +125,37 @@ export default { return resultAndConstants(remoteConfig, null); }); }, - onConfigUpdated() { - throw getWebError({ - code: 'unsupported', - message: 'Not supported by the Firebase Javascript SDK.', - }); + onConfigUpdated(appName) { + if (onConfigUpdateListeners[appName]) { + return; + } + + const remoteConfig = getRemoteConfigInstanceForApp(appName); + + const nativeObserver = { + next: configUpdate => { + emitEvent('on_config_updated', { + appName, + resultType: 'success', + updatedKeys: Array.from(configUpdate.getUpdatedKeys()), + }); + }, + error: firebaseError => { + emitEvent('on_config_updated', { + appName, + event: getWebError(firebaseError), + }); + }, + complete: () => {}, + }; + + onConfigUpdateListeners[appName] = onConfigUpdate(remoteConfig, nativeObserver); + }, + removeConfigUpdateRegistration(appName) { + if (!onConfigUpdateListeners[appName]) { + return; + } + onConfigUpdateListeners[appName](); + delete onConfigUpdateListeners[appName]; }, }; diff --git a/packages/remote-config/package.json b/packages/remote-config/package.json index 630df9f1ef..15a09d6134 100644 --- a/packages/remote-config/package.json +++ b/packages/remote-config/package.json @@ -32,7 +32,9 @@ "provenance": true }, "dependencies": { - "text-encoding": "^0.7.0" + "react-native-fetch-api": "^3.0.0", + "text-encoding": "^0.7.0", + "web-streams-polyfill": "^4.2.0" }, "devDependencies": { "@types/text-encoding": "^0.0.40" diff --git a/yarn.lock b/yarn.lock index b7505eab86..9150e95a5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5767,7 +5767,9 @@ __metadata: resolution: "@react-native-firebase/remote-config@workspace:packages/remote-config" dependencies: "@types/text-encoding": "npm:^0.0.40" + react-native-fetch-api: "npm:^3.0.0" text-encoding: "npm:^0.7.0" + web-streams-polyfill: "npm:^4.2.0" peerDependencies: "@react-native-firebase/analytics": 23.4.1 "@react-native-firebase/app": 23.4.1