From c2a72209fcf8d96256d40370efed4bce0b603813 Mon Sep 17 00:00:00 2001 From: tusharkhandelwal8 Date: Wed, 25 Mar 2026 15:57:44 +0530 Subject: [PATCH 01/10] add experiment metadata tracking to realtime diffing --- FirebaseRemoteConfig/CHANGELOG.md | 5 + .../Sources/RCNConfigContent.m | 95 ++++++++++- .../Tests/Unit/RCNConfigContentTest.m | 153 ++++++++++++++++++ .../Tests/Unit/TestABTPayload.txt | 3 +- 4 files changed, 253 insertions(+), 3 deletions(-) diff --git a/FirebaseRemoteConfig/CHANGELOG.md b/FirebaseRemoteConfig/CHANGELOG.md index d77fbc41d21..dec79806c7f 100644 --- a/FirebaseRemoteConfig/CHANGELOG.md +++ b/FirebaseRemoteConfig/CHANGELOG.md @@ -1,3 +1,8 @@ +# Unreleased +- [fixed] Remote Config Realtime updates now trigger when a parameter's experiment + or variant assignment changes, ensuring more accurate A/B test analytics and + consistent user experiences. + # 12.10.0 - [issue] A workaround to restore service if Remote Config data remains empty after a device restore is to publish a new version of your Remote Config template diff --git a/FirebaseRemoteConfig/Sources/RCNConfigContent.m b/FirebaseRemoteConfig/Sources/RCNConfigContent.m index 90eba544205..e318769fdac 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigContent.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigContent.m @@ -25,6 +25,8 @@ #import "FirebaseCore/Extension/FirebaseCoreInternal.h" +static NSString *const kAffectedParameterKeys = @"affectedParameterKeys"; + @implementation RCNConfigContent { /// Active config data that is currently used. NSMutableDictionary *_activeConfig; @@ -434,12 +436,96 @@ - (BOOL)checkAndWaitForInitialDatabaseLoad { return true; } +/// Load active and fetched experiment payloads and return them in a map. +- (NSDictionary *> *)loadExperimentsPayloads { + __block NSMutableArray *activeExperimentPayloads = [[NSMutableArray alloc] init]; + __block NSMutableArray *experimentPayloads = [[NSMutableArray alloc] init]; + + /// Load experiments from DB. + [_DBManager loadExperimentWithCompletionHandler:^(BOOL success, NSDictionary *result) { + if (success && result) { + experimentPayloads = + [result[@RCNExperimentTableKeyPayload] mutableCopy] ?: experimentPayloads; + activeExperimentPayloads = + [result[@RCNExperimentTableKeyActivePayload] mutableCopy] ?: activeExperimentPayloads; + } + }]; + + [_DBManager waitForDatabaseOperationQueue]; + + return @{ + @RCNExperimentTableKeyPayload : experimentPayloads, + @RCNExperimentTableKeyActivePayload : activeExperimentPayloads + }; +} + +/// Creates a map where the key is the config key and the value is the experiment description. +- (NSMutableDictionary *)createExperimentsMap:(NSMutableArray *)experiments { + NSMutableDictionary *experimentsMap = + [[NSMutableDictionary alloc] init]; + + /// Iterate through all the experiments and check if they contain `affectedParameterKeys`. + for (NSData *experiment in experiments) { + NSError *error; + NSDictionary *experimentJSON = + [NSJSONSerialization JSONObjectWithData:experiment + options:NSJSONReadingMutableContainers + error:&error]; + if (!error && experimentJSON) { + if ([experimentJSON objectForKey:kAffectedParameterKeys]) { + NSMutableArray *configKeys = + (NSMutableArray *)[experimentJSON objectForKey:kAffectedParameterKeys]; + NSMutableDictionary *experimentCopy = [experimentJSON mutableCopy]; + /// Remove `affectedParameterKeys` because the values come out of order and could affect the + /// diffing. + [experimentCopy removeObjectForKey:kAffectedParameterKeys]; + + /// Map experiments to config keys. + for (NSString *key in configKeys) { + [experimentsMap setObject:experimentCopy forKey:key]; + } + } + } + } + + return experimentsMap; +} + +- (NSMutableSet *)getKeysAffectedByChangedExperiments:(NSMutableArray *)activePayloads + fetchedExperimentPayloads: + (NSMutableArray *)fetchedPayloads { + NSMutableSet *changedKeys = [[NSMutableSet alloc] init]; + + /// Create config keys to experiments map. + NSDictionary *activeMap = [self createExperimentsMap:activePayloads]; + NSDictionary *fetchedMap = [self createExperimentsMap:fetchedPayloads]; + + /// Combine all unique keys from both maps to iterate exactly once per key + NSMutableSet *allKeys = [NSMutableSet setWithArray:[activeMap allKeys]]; + [allKeys addObjectsFromArray:[fetchedMap allKeys]]; + + for (NSString *key in allKeys) { + NSDictionary *activeExp = activeMap[key]; + NSDictionary *fetchedExp = fetchedMap[key]; + + // If one exists and the other doesn't, or if they both exist but differ + if (![activeExp isEqualToDictionary:fetchedExp]) { + [changedKeys addObject:key]; + } + } + + return changedKeys; +} + // Compare fetched config with active config and output what has changed - (FIRRemoteConfigUpdate *)getConfigUpdateForNamespace:(NSString *)FIRNamespace { - // TODO: handle diff in experiment metadata - FIRRemoteConfigUpdate *configUpdate; NSMutableSet *updatedKeys = [[NSMutableSet alloc] init]; + NSDictionary *experiments = [self loadExperimentsPayloads]; + NSMutableSet *changedExperimentKeys = [self + getKeysAffectedByChangedExperiments:[experiments + objectForKey:@RCNExperimentTableKeyActivePayload] + fetchedExperimentPayloads:[experiments objectForKey:@RCNExperimentTableKeyPayload]]; NSDictionary *fetchedConfig = _fetchedConfig[FIRNamespace] ? _fetchedConfig[FIRNamespace] : [[NSDictionary alloc] init]; @@ -496,6 +582,11 @@ - (FIRRemoteConfigUpdate *)getConfigUpdateForNamespace:(NSString *)FIRNamespace } } + // Add params affected by changed experiments. + for (NSString *key in changedExperimentKeys) { + [updatedKeys addObject:key]; + } + configUpdate = [[FIRRemoteConfigUpdate alloc] initWithUpdatedKeys:updatedKeys]; return configUpdate; } diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m b/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m index 68320408ff3..7a940679e09 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m @@ -27,6 +27,9 @@ @import FirebaseRemoteConfigInterop; @interface RCNConfigContent (Testing) +- (NSMutableSet *) + getKeysAffectedByChangedExperiments:(NSMutableArray *)activeExperimentPayloads + fetchedExperimentPayloads:(NSMutableArray *)experimentPayloads; - (BOOL)checkAndWaitForInitialDatabaseLoad; @end @@ -354,6 +357,131 @@ - (void)testConfigUpdate_noChange_emptyResponse { XCTAssertTrue([update updatedKeys].count == 0); } +- (void)testConfigUpdate_noParamChange_butExperimentChange { + NSString *namespace = @"test_namespace"; + RCNConfigContent *configContent = [[RCNConfigContent alloc] initWithDBManager:nil]; + NSMutableSet *experimentKeys = [[NSMutableSet alloc] init]; + [experimentKeys addObject:@"key_2"]; + id configMock = OCMPartialMock(configContent); + OCMStub([configMock getKeysAffectedByChangedExperiments:OCMOCK_ANY + fetchedExperimentPayloads:OCMOCK_ANY]) + .andReturn(experimentKeys); + + // populate fetched config + NSMutableDictionary *fetchResponse = + [self createFetchResponseWithConfigEntries:@{@"key1" : @"value1"} + p13nMetadata:nil + rolloutMetadata:nil]; + [configMock updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; + + // active config is the same as fetched config + FIRRemoteConfigValue *value = + [[FIRRemoteConfigValue alloc] initWithData:[@"value1" dataUsingEncoding:NSUTF8StringEncoding] + source:FIRRemoteConfigSourceRemote]; + NSDictionary *namespaceToConfig = @{namespace : @{@"key1" : value}}; + [configMock copyFromDictionary:namespaceToConfig + toSource:RCNDBSourceActive + forNamespace:namespace]; + + FIRRemoteConfigUpdate *update = [configMock getConfigUpdateForNamespace:namespace]; + + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:@"key_2"]); +} + +- (void)testExperimentDiff_addedExperiment { + NSData *payloadData1 = [[self class] payloadDataFromTestFile]; + NSMutableArray *activeExperimentPayloads = [@[ payloadData1 ] mutableCopy]; + + NSError *dataError; + NSMutableDictionary *payload = + [NSJSONSerialization JSONObjectWithData:payloadData1 + options:NSJSONReadingMutableContainers + error:&dataError]; + [payload setValue:@"exp_2" forKey:@"experimentId"]; + NSError *jsonError; + NSData *payloadData2 = [NSJSONSerialization dataWithJSONObject:payload + options:kNilOptions + error:&jsonError]; + NSMutableArray *experimentPayloads = [@[ payloadData1, payloadData2 ] mutableCopy]; + + RCNConfigContent *configContent = [[RCNConfigContent alloc] initWithDBManager:nil]; + NSMutableSet *changedKeys = + [configContent getKeysAffectedByChangedExperiments:activeExperimentPayloads + fetchedExperimentPayloads:experimentPayloads]; + XCTAssertTrue([changedKeys containsObject:@"test_key_1"]); +} + +- (void)testExperimentDiff_changedExperimentMetadata { + NSData *payloadData1 = [[self class] payloadDataFromTestFile]; + NSMutableArray *activeExperimentPayloads = [@[ payloadData1 ] mutableCopy]; + + NSError *dataError; + NSMutableDictionary *payload = + [NSJSONSerialization JSONObjectWithData:payloadData1 + options:NSJSONReadingMutableContainers + error:&dataError]; + [payload setValue:@"var_2" forKey:@"variantId"]; + NSError *jsonError; + NSData *payloadData2 = [NSJSONSerialization dataWithJSONObject:payload + options:kNilOptions + error:&jsonError]; + NSMutableArray *experimentPayloads = [@[ payloadData1, payloadData2 ] mutableCopy]; + + RCNConfigContent *configContent = [[RCNConfigContent alloc] initWithDBManager:nil]; + NSMutableSet *changedKeys = + [configContent getKeysAffectedByChangedExperiments:activeExperimentPayloads + fetchedExperimentPayloads:experimentPayloads]; + XCTAssertTrue([changedKeys containsObject:@"test_key_1"]); +} + +- (void)testExperimentDiff_changedExperimentKeys { + NSData *payloadData1 = [[self class] payloadDataFromTestFile]; + NSMutableArray *activeExperimentPayloads = [@[ payloadData1 ] mutableCopy]; + + NSError *dataError; + NSMutableDictionary *payload = + [NSJSONSerialization JSONObjectWithData:payloadData1 + options:NSJSONReadingMutableContainers + error:&dataError]; + [payload setValue:@[ @"test_key_1", @"test_key_2" ] forKey:@"affectedParameterKeys"]; + NSError *jsonError; + NSData *payloadData2 = [NSJSONSerialization dataWithJSONObject:payload + options:kNilOptions + error:&jsonError]; + NSMutableArray *experimentPayloads = [@[ payloadData1, payloadData2 ] mutableCopy]; + + RCNConfigContent *configContent = [[RCNConfigContent alloc] initWithDBManager:nil]; + NSMutableSet *changedKeys = + [configContent getKeysAffectedByChangedExperiments:activeExperimentPayloads + fetchedExperimentPayloads:experimentPayloads]; + XCTAssertTrue([changedKeys containsObject:@"test_key_2"]); +} + +- (void)testExperimentDiff_deletedExperiment { + NSData *payloadData1 = [[self class] payloadDataFromTestFile]; + NSMutableArray *activeExperimentPayloads = [@[ payloadData1 ] mutableCopy]; + NSMutableArray *experimentPayloads = [@[] mutableCopy]; + + RCNConfigContent *configContent = [[RCNConfigContent alloc] initWithDBManager:nil]; + NSMutableSet *changedKeys = + [configContent getKeysAffectedByChangedExperiments:activeExperimentPayloads + fetchedExperimentPayloads:experimentPayloads]; + XCTAssertTrue([changedKeys containsObject:@"test_key_1"]); +} + +- (void)testExperimentDiff_noChange { + NSData *payloadData1 = [[self class] payloadDataFromTestFile]; + NSMutableArray *activeExperimentPayloads = [@[ payloadData1 ] mutableCopy]; + NSMutableArray *experimentPayloads = [@[ payloadData1 ] mutableCopy]; + + RCNConfigContent *configContent = [[RCNConfigContent alloc] initWithDBManager:nil]; + NSMutableSet *changedKeys = + [configContent getKeysAffectedByChangedExperiments:activeExperimentPayloads + fetchedExperimentPayloads:experimentPayloads]; + XCTAssertTrue([changedKeys count] == 0); +} + - (void)testConfigUpdate_paramAdded_returnsNewKey { NSString *namespace = @"test_namespace"; NSString *newParam = @"key2"; @@ -660,4 +788,29 @@ - (NSMutableDictionary *)createFetchResponseWithConfigEntries:(NSDictionary *)co return fetchResponse; } ++ (NSData *)payloadDataFromTestFile { +#if SWIFT_PACKAGE + NSBundle *bundle = SWIFTPM_MODULE_BUNDLE; +#else + NSBundle *bundle = [NSBundle bundleForClass:[self class]]; +#endif + NSString *testJsonDataFilePath = [bundle pathForResource:@"TestABTPayload" ofType:@"txt"]; + NSError *readTextError = nil; + NSString *fileText = [[NSString alloc] initWithContentsOfFile:testJsonDataFilePath + encoding:NSUTF8StringEncoding + error:&readTextError]; + + NSData *fileData = [fileText dataUsingEncoding:kCFStringEncodingUTF8]; + + NSError *jsonDictionaryError = nil; + NSMutableDictionary *jsonDictionary = + [[NSJSONSerialization JSONObjectWithData:fileData + options:kNilOptions + error:&jsonDictionaryError] mutableCopy]; + NSError *jsonDataError = nil; + return [NSJSONSerialization dataWithJSONObject:jsonDictionary + options:kNilOptions + error:&jsonDataError]; +} + @end diff --git a/FirebaseRemoteConfig/Tests/Unit/TestABTPayload.txt b/FirebaseRemoteConfig/Tests/Unit/TestABTPayload.txt index fb0e71cc54f..ef3ca9f76e3 100644 --- a/FirebaseRemoteConfig/Tests/Unit/TestABTPayload.txt +++ b/FirebaseRemoteConfig/Tests/Unit/TestABTPayload.txt @@ -15,5 +15,6 @@ { "experimentId": "exp_1" } - ] + ], + "affectedParameterKeys": ["test_key_1"] } From ab12dc0edf4c036da59c3f051def346d370842d1 Mon Sep 17 00:00:00 2001 From: tusharkhandelwal8 Date: Wed, 25 Mar 2026 16:06:06 +0530 Subject: [PATCH 02/10] remove trailing whitespace --- FirebaseRemoteConfig/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FirebaseRemoteConfig/CHANGELOG.md b/FirebaseRemoteConfig/CHANGELOG.md index dec79806c7f..e9a942fcbb5 100644 --- a/FirebaseRemoteConfig/CHANGELOG.md +++ b/FirebaseRemoteConfig/CHANGELOG.md @@ -1,6 +1,6 @@ # Unreleased -- [fixed] Remote Config Realtime updates now trigger when a parameter's experiment - or variant assignment changes, ensuring more accurate A/B test analytics and +- [fixed] Remote Config Realtime updates now trigger when a parameter's experiment + or variant assignment changes, ensuring more accurate A/B test analytics and consistent user experiences. # 12.10.0 From 87c7c32be8bbc9a6f9f078aec2cdca1375c0cb48 Mon Sep 17 00:00:00 2001 From: tusharkhandelwal8 Date: Thu, 2 Apr 2026 00:53:45 +0530 Subject: [PATCH 03/10] Fix a race condition in loadExperimentsPayloads --- FirebaseRemoteConfig/Sources/RCNConfigContent.m | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/FirebaseRemoteConfig/Sources/RCNConfigContent.m b/FirebaseRemoteConfig/Sources/RCNConfigContent.m index e318769fdac..ff960cc8fe3 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigContent.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigContent.m @@ -441,6 +441,9 @@ - (BOOL)checkAndWaitForInitialDatabaseLoad { __block NSMutableArray *activeExperimentPayloads = [[NSMutableArray alloc] init]; __block NSMutableArray *experimentPayloads = [[NSMutableArray alloc] init]; + // Create a semaphore to synchronize the async completion handler. + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + /// Load experiments from DB. [_DBManager loadExperimentWithCompletionHandler:^(BOOL success, NSDictionary *result) { if (success && result) { @@ -449,9 +452,18 @@ - (BOOL)checkAndWaitForInitialDatabaseLoad { activeExperimentPayloads = [result[@RCNExperimentTableKeyActivePayload] mutableCopy] ?: activeExperimentPayloads; } + // Signal that the block has finished updating the variables. + dispatch_semaphore_signal(semaphore); }]; - [_DBManager waitForDatabaseOperationQueue]; + // Wait for the completion handler to signal. + // We use a timeout to prevent permanent deadlocks if something goes wrong in the DB layer. + long timedOut = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kDatabaseLoadTimeoutSecs * NSEC_PER_SEC))); + + if (timedOut) { + FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000048", + @"Timed out waiting for experiment payloads to be loaded from DB"); + } return @{ @RCNExperimentTableKeyPayload : experimentPayloads, From 919f4df9b16039d2814a8cdb65a4de3a301bba83 Mon Sep 17 00:00:00 2001 From: tusharkhandelwal8 Date: Thu, 2 Apr 2026 01:04:56 +0530 Subject: [PATCH 04/10] Fix Lint error --- FirebaseRemoteConfig/Sources/RCNConfigContent.m | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/FirebaseRemoteConfig/Sources/RCNConfigContent.m b/FirebaseRemoteConfig/Sources/RCNConfigContent.m index ff960cc8fe3..5a7281839b5 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigContent.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigContent.m @@ -458,11 +458,13 @@ - (BOOL)checkAndWaitForInitialDatabaseLoad { // Wait for the completion handler to signal. // We use a timeout to prevent permanent deadlocks if something goes wrong in the DB layer. - long timedOut = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kDatabaseLoadTimeoutSecs * NSEC_PER_SEC))); - + long timedOut = dispatch_semaphore_wait( + semaphore, + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kDatabaseLoadTimeoutSecs * NSEC_PER_SEC))); + if (timedOut) { - FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000048", - @"Timed out waiting for experiment payloads to be loaded from DB"); + FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000048", + @"Timed out waiting for experiment payloads to be loaded from DB"); } return @{ From e65ef749c9ed143fb9f3d54ed2b58b7088af40b6 Mon Sep 17 00:00:00 2001 From: tusharkhandelwal8 Date: Thu, 2 Apr 2026 17:43:21 +0530 Subject: [PATCH 05/10] Replace semaphore with completion handler in experiment loading --- .../Sources/RCNConfigContent.h | 3 +- .../Sources/RCNConfigContent.m | 178 +++++++++--------- FirebaseRemoteConfig/Sources/RCNConfigFetch.m | 40 ++-- .../Tests/Unit/RCNConfigContentTest.m | 131 ++++++++----- 4 files changed, 201 insertions(+), 151 deletions(-) diff --git a/FirebaseRemoteConfig/Sources/RCNConfigContent.h b/FirebaseRemoteConfig/Sources/RCNConfigContent.h index e8410074b30..bd0f2bc650b 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigContent.h +++ b/FirebaseRemoteConfig/Sources/RCNConfigContent.h @@ -71,6 +71,7 @@ typedef NS_ENUM(NSInteger, RCNDBSource) { - (void)activateRolloutMetadata:(void (^)(BOOL success))completionHandler; /// Returns the updated parameters between fetched and active config. -- (FIRRemoteConfigUpdate *)getConfigUpdateForNamespace:(NSString *)FIRNamespace; +- (void)getConfigUpdateForNamespace:(NSString *)FIRNamespace + completionHandler:(void (^)(FIRRemoteConfigUpdate *update))completionHandler; @end diff --git a/FirebaseRemoteConfig/Sources/RCNConfigContent.m b/FirebaseRemoteConfig/Sources/RCNConfigContent.m index 5a7281839b5..54472313b36 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigContent.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigContent.m @@ -436,41 +436,39 @@ - (BOOL)checkAndWaitForInitialDatabaseLoad { return true; } -/// Load active and fetched experiment payloads and return them in a map. -- (NSDictionary *> *)loadExperimentsPayloads { - __block NSMutableArray *activeExperimentPayloads = [[NSMutableArray alloc] init]; - __block NSMutableArray *experimentPayloads = [[NSMutableArray alloc] init]; - - // Create a semaphore to synchronize the async completion handler. - dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); +/// Load active and fetched experiment payloads asynchronously. +- (void)loadExperimentsPayloadsWithCompletion: + (void (^)(NSDictionary *> *payloads))completion { + if (!_DBManager) { + if (completion) { + completion(@{ + @RCNExperimentTableKeyPayload : [[NSMutableArray alloc] init], + @RCNExperimentTableKeyActivePayload : [[NSMutableArray alloc] init] + }); + } + return; + } - /// Load experiments from DB. [_DBManager loadExperimentWithCompletionHandler:^(BOOL success, NSDictionary *result) { + NSMutableArray *activeExperimentPayloads = [[NSMutableArray alloc] init]; + NSMutableArray *experimentPayloads = [[NSMutableArray alloc] init]; + if (success && result) { experimentPayloads = [result[@RCNExperimentTableKeyPayload] mutableCopy] ?: experimentPayloads; activeExperimentPayloads = [result[@RCNExperimentTableKeyActivePayload] mutableCopy] ?: activeExperimentPayloads; } - // Signal that the block has finished updating the variables. - dispatch_semaphore_signal(semaphore); - }]; - // Wait for the completion handler to signal. - // We use a timeout to prevent permanent deadlocks if something goes wrong in the DB layer. - long timedOut = dispatch_semaphore_wait( - semaphore, - dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kDatabaseLoadTimeoutSecs * NSEC_PER_SEC))); + NSDictionary *payloads = @{ + @RCNExperimentTableKeyPayload : experimentPayloads, + @RCNExperimentTableKeyActivePayload : activeExperimentPayloads + }; - if (timedOut) { - FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000048", - @"Timed out waiting for experiment payloads to be loaded from DB"); - } - - return @{ - @RCNExperimentTableKeyPayload : experimentPayloads, - @RCNExperimentTableKeyActivePayload : activeExperimentPayloads - }; + if (completion) { + completion(payloads); + } + }]; } /// Creates a map where the key is the config key and the value is the experiment description. @@ -485,10 +483,9 @@ - (NSMutableDictionary *)createExperimentsMap:(NSMutableArray *)experi [NSJSONSerialization JSONObjectWithData:experiment options:NSJSONReadingMutableContainers error:&error]; - if (!error && experimentJSON) { - if ([experimentJSON objectForKey:kAffectedParameterKeys]) { - NSMutableArray *configKeys = - (NSMutableArray *)[experimentJSON objectForKey:kAffectedParameterKeys]; + if (!error && [experimentJSON isKindOfClass:[NSDictionary class]]) { + NSArray *configKeys = experimentJSON[kAffectedParameterKeys]; + if ([configKeys isKindOfClass:[NSArray class]]) { NSMutableDictionary *experimentCopy = [experimentJSON mutableCopy]; /// Remove `affectedParameterKeys` because the values come out of order and could affect the /// diffing. @@ -531,78 +528,79 @@ - (NSMutableDictionary *)createExperimentsMap:(NSMutableArray *)experi return changedKeys; } -// Compare fetched config with active config and output what has changed -- (FIRRemoteConfigUpdate *)getConfigUpdateForNamespace:(NSString *)FIRNamespace { - FIRRemoteConfigUpdate *configUpdate; - NSMutableSet *updatedKeys = [[NSMutableSet alloc] init]; - NSDictionary *experiments = [self loadExperimentsPayloads]; - NSMutableSet *changedExperimentKeys = [self - getKeysAffectedByChangedExperiments:[experiments - objectForKey:@RCNExperimentTableKeyActivePayload] - fetchedExperimentPayloads:[experiments objectForKey:@RCNExperimentTableKeyPayload]]; - - NSDictionary *fetchedConfig = - _fetchedConfig[FIRNamespace] ? _fetchedConfig[FIRNamespace] : [[NSDictionary alloc] init]; - NSDictionary *activeConfig = - _activeConfig[FIRNamespace] ? _activeConfig[FIRNamespace] : [[NSDictionary alloc] init]; - NSDictionary *fetchedP13n = _fetchedPersonalization; - NSDictionary *activeP13n = _activePersonalization; - NSArray *fetchedRolloutMetadata = _fetchedRolloutMetadata; - NSArray *activeRolloutMetadata = _activeRolloutMetadata; - - // add new/updated params - for (NSString *key in [fetchedConfig allKeys]) { - if (activeConfig[key] == nil || - ![[activeConfig[key] stringValue] isEqualToString:[fetchedConfig[key] stringValue]]) { - [updatedKeys addObject:key]; +// Compare fetched config with active config and output what has changed asynchronously +- (void)getConfigUpdateForNamespace:(NSString *)FIRNamespace + completionHandler:(void (^)(FIRRemoteConfigUpdate *update))completionHandler { + [self loadExperimentsPayloadsWithCompletion:^(NSDictionary *experiments) { + NSMutableSet *updatedKeys = [[NSMutableSet alloc] init]; + + // Get keys affected by changed experiments + NSMutableSet *changedExperimentKeys = [self + getKeysAffectedByChangedExperiments:[experiments + objectForKey:@RCNExperimentTableKeyActivePayload] + fetchedExperimentPayloads:[experiments + objectForKey:@RCNExperimentTableKeyPayload]]; + + NSDictionary *fetchedConfig = self->_fetchedConfig[FIRNamespace] ?: [[NSDictionary alloc] init]; + NSDictionary *activeConfig = self->_activeConfig[FIRNamespace] ?: [[NSDictionary alloc] init]; + NSDictionary *fetchedP13n = self->_fetchedPersonalization; + NSDictionary *activeP13n = self->_activePersonalization; + NSArray *fetchedRolloutMetadata = self->_fetchedRolloutMetadata; + NSArray *activeRolloutMetadata = self->_activeRolloutMetadata; + + // Check for param changes (Value/Presence) + for (NSString *key in [fetchedConfig allKeys]) { + if (activeConfig[key] == nil || + ![[activeConfig[key] stringValue] isEqualToString:[fetchedConfig[key] stringValue]]) { + [updatedKeys addObject:key]; + } } - } - // add deleted params - for (NSString *key in [activeConfig allKeys]) { - if (fetchedConfig[key] == nil) { - [updatedKeys addObject:key]; + for (NSString *key in [activeConfig allKeys]) { + if (fetchedConfig[key] == nil) { + [updatedKeys addObject:key]; + } } - } - // add params with new/updated p13n metadata - for (NSString *key in [fetchedP13n allKeys]) { - if (activeP13n[key] == nil || ![activeP13n[key] isEqualToDictionary:fetchedP13n[key]]) { - [updatedKeys addObject:key]; + // Check for Personalization changes + for (NSString *key in [fetchedP13n allKeys]) { + if (activeP13n[key] == nil || ![activeP13n[key] isEqualToDictionary:fetchedP13n[key]]) { + [updatedKeys addObject:key]; + } } - } - // add params with deleted p13n metadata - for (NSString *key in [activeP13n allKeys]) { - if (fetchedP13n[key] == nil) { - [updatedKeys addObject:key]; + for (NSString *key in [activeP13n allKeys]) { + if (fetchedP13n[key] == nil) { + [updatedKeys addObject:key]; + } } - } - NSDictionary *fetchedRollouts = - [self getParameterKeyToRolloutMetadata:fetchedRolloutMetadata]; - NSDictionary *activeRollouts = - [self getParameterKeyToRolloutMetadata:activeRolloutMetadata]; + // Check for Rollout metadata changes + NSDictionary *fetchedRollouts = + [self getParameterKeyToRolloutMetadata:fetchedRolloutMetadata]; + NSDictionary *activeRollouts = + [self getParameterKeyToRolloutMetadata:activeRolloutMetadata]; - // add params with new/updated rollout metadata - for (NSString *key in [fetchedRollouts allKeys]) { - if (activeRollouts[key] == nil || - ![activeRollouts[key] isEqualToDictionary:fetchedRollouts[key]]) { - [updatedKeys addObject:key]; + for (NSString *key in [fetchedRollouts allKeys]) { + if (activeRollouts[key] == nil || + ![activeRollouts[key] isEqualToDictionary:fetchedRollouts[key]]) { + [updatedKeys addObject:key]; + } } - } - // add params with deleted rollout metadata - for (NSString *key in [activeRollouts allKeys]) { - if (fetchedRollouts[key] == nil) { - [updatedKeys addObject:key]; + for (NSString *key in [activeRollouts allKeys]) { + if (fetchedRollouts[key] == nil) { + [updatedKeys addObject:key]; + } } - } - // Add params affected by changed experiments. - for (NSString *key in changedExperimentKeys) { - [updatedKeys addObject:key]; - } + // Add params affected by changed experiments + [updatedKeys unionSet:changedExperimentKeys]; - configUpdate = [[FIRRemoteConfigUpdate alloc] initWithUpdatedKeys:updatedKeys]; - return configUpdate; + FIRRemoteConfigUpdate *configUpdate = + [[FIRRemoteConfigUpdate alloc] initWithUpdatedKeys:updatedKeys]; + + if (completionHandler) { + completionHandler(configUpdate); + } + }]; } - (NSDictionary *)getParameterKeyToRolloutMetadata: diff --git a/FirebaseRemoteConfig/Sources/RCNConfigFetch.m b/FirebaseRemoteConfig/Sources/RCNConfigFetch.m index 1df80385972..0b7a5dab0b8 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigFetch.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigFetch.m @@ -516,13 +516,16 @@ - (void)fetchWithUserProperties:(NSDictionary *)userProperties if (!data) { FIRLogInfo(kFIRLoggerRemoteConfig, @"I-RCN000043", @"RCN Fetch: No data in fetch response"); // There may still be a difference between fetched and active config - FIRRemoteConfigUpdate *update = - [strongSelf->_content getConfigUpdateForNamespace:strongSelf->_FIRNamespace]; - return [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusSuccess - withUpdate:update - withError:nil - completionHandler:completionHandler - updateCompletionHandler:updateCompletionHandler]; + [strongSelf->_content + getConfigUpdateForNamespace:strongSelf->_FIRNamespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusSuccess + withUpdate:update + withError:nil + completionHandler:completionHandler + updateCompletionHandler:updateCompletionHandler]; + }]; + return; } // Config fetch succeeded. @@ -594,17 +597,18 @@ - (void)fetchWithUserProperties:(NSDictionary *)userProperties strongSelf->_settings.lastETag = latestETag; } // Compute config update after successful fetch - FIRRemoteConfigUpdate *update = - [strongSelf->_content getConfigUpdateForNamespace:strongSelf->_FIRNamespace]; - - [strongSelf->_settings - updateMetadataWithFetchSuccessStatus:YES - templateVersion:strongSelf->_templateVersionNumber]; - return [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusSuccess - withUpdate:update - withError:nil - completionHandler:completionHandler - updateCompletionHandler:updateCompletionHandler]; + [strongSelf->_content + getConfigUpdateForNamespace:strongSelf->_FIRNamespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + [strongSelf->_settings + updateMetadataWithFetchSuccessStatus:YES + templateVersion:strongSelf->_templateVersionNumber]; + [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusSuccess + withUpdate:update + withError:nil + completionHandler:completionHandler + updateCompletionHandler:updateCompletionHandler]; + }]; }); }; diff --git a/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m b/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m index 7a940679e09..76ad07dade4 100644 --- a/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m +++ b/FirebaseRemoteConfig/Tests/Unit/RCNConfigContentTest.m @@ -343,7 +343,7 @@ - (void)testConfigUpdate_noChange_emptyResponse { rolloutMetadata:nil]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - // active config is the same as fetched config + // active config to match fetched config FIRRemoteConfigValue *value = [[FIRRemoteConfigValue alloc] initWithData:[@"value1" dataUsingEncoding:NSUTF8StringEncoding] source:FIRRemoteConfigSourceRemote]; @@ -352,9 +352,16 @@ - (void)testConfigUpdate_noChange_emptyResponse { toSource:RCNDBSourceActive forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; + XCTestExpectation *expectation = [self expectationWithDescription:@"Config update completion"]; - XCTAssertTrue([update updatedKeys].count == 0); + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertNotNil(update, @"Update object should not be nil"); + XCTAssertEqual(update.updatedKeys.count, 0, + @"There should be no updated keys when configs match"); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_noParamChange_butExperimentChange { @@ -383,10 +390,16 @@ - (void)testConfigUpdate_noParamChange_butExperimentChange { toSource:RCNDBSourceActive forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [configMock getConfigUpdateForNamespace:namespace]; + // Create expectation for async callback + XCTestExpectation *expectation = [self expectationWithDescription:@"Config update completion"]; - XCTAssertTrue([update updatedKeys].count == 1); - XCTAssertTrue([[update updatedKeys] containsObject:@"key_2"]); + [configMock getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:@"key_2"]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testExperimentDiff_addedExperiment { @@ -502,10 +515,14 @@ - (void)testConfigUpdate_paramAdded_returnsNewKey { rolloutMetadata:nil]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - - XCTAssertTrue([update updatedKeys].count == 1); - XCTAssertTrue([[update updatedKeys] containsObject:newParam]); + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:newParam]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_paramValueChanged_returnsUpdatedKey { @@ -530,10 +547,14 @@ - (void)testConfigUpdate_paramValueChanged_returnsUpdatedKey { rolloutMetadata:nil]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - - XCTAssertTrue([update updatedKeys].count == 1); - XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_paramDeleted_returnsDeletedKey { @@ -558,11 +579,16 @@ - (void)testConfigUpdate_paramDeleted_returnsDeletedKey { rolloutMetadata:nil]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - - XCTAssertTrue([update updatedKeys].count == 2); - XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); // deleted - XCTAssertTrue([[update updatedKeys] containsObject:newParam]); // added + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent + getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 2); + XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); // deleted + XCTAssertTrue([[update updatedKeys] containsObject:newParam]); // added + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_p13nMetadataUpdated_returnsKey { @@ -594,10 +620,14 @@ - (void)testConfigUpdate_p13nMetadataUpdated_returnsKey { forKey:RCNFetchResponseKeyPersonalizationMetadata]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - - XCTAssertTrue([update updatedKeys].count == 1); - XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_rolloutMetadataUpdated_returnsKey { @@ -647,11 +677,15 @@ - (void)testConfigUpdate_rolloutMetadataUpdated_returnsKey { [fetchResponse setValue:updatedRolloutMetadata forKey:RCNFetchResponseKeyRolloutMetadata]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - - XCTAssertTrue([update updatedKeys].count == 2); - XCTAssertTrue([[update updatedKeys] containsObject:key1]); - XCTAssertTrue([[update updatedKeys] containsObject:key2]); + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 2); + XCTAssertTrue([[update updatedKeys] containsObject:key1]); + XCTAssertTrue([[update updatedKeys] containsObject:key2]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_rolloutMetadataDeleted_returnsKey { @@ -693,10 +727,14 @@ - (void)testConfigUpdate_rolloutMetadataDeleted_returnsKey { [fetchResponse setValue:updatedRolloutMetadata forKey:RCNFetchResponseKeyRolloutMetadata]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - - XCTAssertTrue([update updatedKeys].count == 1); - XCTAssertTrue([[update updatedKeys] containsObject:key2]); + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:key2]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_rolloutMetadataDeletedAll_returnsKey { @@ -734,12 +772,17 @@ - (void)testConfigUpdate_rolloutMetadataDeletedAll_returnsKey { rolloutMetadata:nil]; [_configContent updateConfigContentWithResponse:updateFetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - [_configContent activateRolloutMetadata:nil]; + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + [self->_configContent activateRolloutMetadata:nil]; - XCTAssertTrue([update updatedKeys].count == 1); - XCTAssertTrue([[update updatedKeys] containsObject:key]); - XCTAssertTrue(_configContent.activeRolloutMetadata.count == 0); + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:key]); + XCTAssertTrue(self->_configContent.activeRolloutMetadata.count == 0); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testConfigUpdate_valueSourceChanged_returnsKey { @@ -763,10 +806,14 @@ - (void)testConfigUpdate_valueSourceChanged_returnsKey { rolloutMetadata:nil]; [_configContent updateConfigContentWithResponse:fetchResponse forNamespace:namespace]; - FIRRemoteConfigUpdate *update = [_configContent getConfigUpdateForNamespace:namespace]; - - XCTAssertTrue([update updatedKeys].count == 1); - XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); + XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)]; + [_configContent getConfigUpdateForNamespace:namespace + completionHandler:^(FIRRemoteConfigUpdate *update) { + XCTAssertTrue([update updatedKeys].count == 1); + XCTAssertTrue([[update updatedKeys] containsObject:existingParam]); + [expectation fulfill]; + }]; + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } #pragma mark - Test Helpers @@ -800,7 +847,7 @@ + (NSData *)payloadDataFromTestFile { encoding:NSUTF8StringEncoding error:&readTextError]; - NSData *fileData = [fileText dataUsingEncoding:kCFStringEncodingUTF8]; + NSData *fileData = [fileText dataUsingEncoding:NSUTF8StringEncoding]; NSError *jsonDictionaryError = nil; NSMutableDictionary *jsonDictionary = From 68f17d10bcb771969940992da71d3be461acee4c Mon Sep 17 00:00:00 2001 From: tusharkhandelwal8 Date: Sat, 11 Apr 2026 07:54:11 +0530 Subject: [PATCH 06/10] Update changlog release version --- FirebaseRemoteConfig/CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/FirebaseRemoteConfig/CHANGELOG.md b/FirebaseRemoteConfig/CHANGELOG.md index 3f7aa775f2a..f01609e425a 100644 --- a/FirebaseRemoteConfig/CHANGELOG.md +++ b/FirebaseRemoteConfig/CHANGELOG.md @@ -1,11 +1,13 @@ +# Unreleased +- [fixed] Remote Config Realtime updates now trigger when a parameter's experiment + or variant assignment changes, ensuring more accurate A/B test analytics and + consistent user experiences. + # 12.12.0 - [added] Introduced a new `configUpdates` property to `RemoteConfig` that provides an `AsyncSequence` for consuming real-time config updates. This offers a modern, Swift Concurrency-native alternative to the existing closure-based listener. -- [fixed] Remote Config Realtime updates now trigger when a parameter's experiment - or variant assignment changes, ensuring more accurate A/B test analytics and - consistent user experiences. # 12.10.0 - [issue] A workaround to restore service if Remote Config data remains empty From 68f85b8add7d022fce187c12af705b20c878c97e Mon Sep 17 00:00:00 2001 From: tusharkhandelwal8 Date: Tue, 14 Apr 2026 15:26:06 +0530 Subject: [PATCH 07/10] Implement weak self dance in RCNConfigContent async blocks --- .../Sources/RCNConfigContent.m | 64 ++++++++++++------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/FirebaseRemoteConfig/Sources/RCNConfigContent.m b/FirebaseRemoteConfig/Sources/RCNConfigContent.m index 54472313b36..e6109a0504a 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigContent.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigContent.m @@ -449,7 +449,13 @@ - (void)loadExperimentsPayloadsWithCompletion: return; } + __weak RCNConfigContent *weakSelf = self; [_DBManager loadExperimentWithCompletionHandler:^(BOOL success, NSDictionary *result) { + RCNConfigContent *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + NSMutableArray *activeExperimentPayloads = [[NSMutableArray alloc] init]; NSMutableArray *experimentPayloads = [[NSMutableArray alloc] init]; @@ -528,75 +534,87 @@ - (NSMutableDictionary *)createExperimentsMap:(NSMutableArray *)experi return changedKeys; } -// Compare fetched config with active config and output what has changed asynchronously +// Compare fetched config with active config and output what has changed - (void)getConfigUpdateForNamespace:(NSString *)FIRNamespace completionHandler:(void (^)(FIRRemoteConfigUpdate *update))completionHandler { + __weak RCNConfigContent *weakSelf = self; [self loadExperimentsPayloadsWithCompletion:^(NSDictionary *experiments) { + RCNConfigContent *strongSelf = weakSelf; + if (!strongSelf) { + return; + } + NSMutableSet *updatedKeys = [[NSMutableSet alloc] init]; - // Get keys affected by changed experiments - NSMutableSet *changedExperimentKeys = [self - getKeysAffectedByChangedExperiments:[experiments - objectForKey:@RCNExperimentTableKeyActivePayload] - fetchedExperimentPayloads:[experiments - objectForKey:@RCNExperimentTableKeyPayload]]; - - NSDictionary *fetchedConfig = self->_fetchedConfig[FIRNamespace] ?: [[NSDictionary alloc] init]; - NSDictionary *activeConfig = self->_activeConfig[FIRNamespace] ?: [[NSDictionary alloc] init]; - NSDictionary *fetchedP13n = self->_fetchedPersonalization; - NSDictionary *activeP13n = self->_activePersonalization; - NSArray *fetchedRolloutMetadata = self->_fetchedRolloutMetadata; - NSArray *activeRolloutMetadata = self->_activeRolloutMetadata; - - // Check for param changes (Value/Presence) + NSDictionary *fetchedConfig = strongSelf->_fetchedConfig[FIRNamespace] + ? strongSelf->_fetchedConfig[FIRNamespace] + : [[NSDictionary alloc] init]; + NSDictionary *activeConfig = strongSelf->_activeConfig[FIRNamespace] + ? strongSelf->_activeConfig[FIRNamespace] + : [[NSDictionary alloc] init]; + NSDictionary *fetchedP13n = strongSelf->_fetchedPersonalization; + NSDictionary *activeP13n = strongSelf->_activePersonalization; + NSArray *fetchedRolloutMetadata = strongSelf->_fetchedRolloutMetadata; + NSArray *activeRolloutMetadata = strongSelf->_activeRolloutMetadata; + + // add new/updated params for (NSString *key in [fetchedConfig allKeys]) { if (activeConfig[key] == nil || ![[activeConfig[key] stringValue] isEqualToString:[fetchedConfig[key] stringValue]]) { [updatedKeys addObject:key]; } } + // add deleted params for (NSString *key in [activeConfig allKeys]) { if (fetchedConfig[key] == nil) { [updatedKeys addObject:key]; } } - // Check for Personalization changes + // add params with new/updated p13n metadata for (NSString *key in [fetchedP13n allKeys]) { if (activeP13n[key] == nil || ![activeP13n[key] isEqualToDictionary:fetchedP13n[key]]) { [updatedKeys addObject:key]; } } + // add params with deleted p13n metadata for (NSString *key in [activeP13n allKeys]) { if (fetchedP13n[key] == nil) { [updatedKeys addObject:key]; } } - // Check for Rollout metadata changes NSDictionary *fetchedRollouts = - [self getParameterKeyToRolloutMetadata:fetchedRolloutMetadata]; + [strongSelf getParameterKeyToRolloutMetadata:fetchedRolloutMetadata]; NSDictionary *activeRollouts = - [self getParameterKeyToRolloutMetadata:activeRolloutMetadata]; + [strongSelf getParameterKeyToRolloutMetadata:activeRolloutMetadata]; + // add params with new/updated rollout metadata for (NSString *key in [fetchedRollouts allKeys]) { if (activeRollouts[key] == nil || ![activeRollouts[key] isEqualToDictionary:fetchedRollouts[key]]) { [updatedKeys addObject:key]; } } + // add params with deleted rollout metadata for (NSString *key in [activeRollouts allKeys]) { if (fetchedRollouts[key] == nil) { [updatedKeys addObject:key]; } } - // Add params affected by changed experiments - [updatedKeys unionSet:changedExperimentKeys]; + // add params with updated experiment metadata + if (experiments) { + NSMutableSet *experimentKeys = [strongSelf + getKeysAffectedByChangedExperiments:[experiments + objectForKey:@RCNExperimentTableKeyActivePayload] + fetchedExperimentPayloads:[experiments + objectForKey:@RCNExperimentTableKeyPayload]]; + [updatedKeys unionSet:experimentKeys]; + } FIRRemoteConfigUpdate *configUpdate = [[FIRRemoteConfigUpdate alloc] initWithUpdatedKeys:updatedKeys]; - if (completionHandler) { completionHandler(configUpdate); } From da7fd44fd8694a68f7b4be0d090294ac722fb31b Mon Sep 17 00:00:00 2001 From: tusharkhandelwal8 Date: Wed, 15 Apr 2026 10:52:02 +0530 Subject: [PATCH 08/10] Call the completion handler in the strongSelf before return --- FirebaseRemoteConfig/Sources/RCNConfigContent.m | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/FirebaseRemoteConfig/Sources/RCNConfigContent.m b/FirebaseRemoteConfig/Sources/RCNConfigContent.m index e6109a0504a..e3da5cf5afb 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigContent.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigContent.m @@ -453,6 +453,12 @@ - (void)loadExperimentsPayloadsWithCompletion: [_DBManager loadExperimentWithCompletionHandler:^(BOOL success, NSDictionary *result) { RCNConfigContent *strongSelf = weakSelf; if (!strongSelf) { + if (completion) { + completion(@{ + @RCNExperimentTableKeyPayload : [[NSMutableArray alloc] init], + @RCNExperimentTableKeyActivePayload : [[NSMutableArray alloc] init] + }); + } return; } @@ -541,6 +547,9 @@ - (void)getConfigUpdateForNamespace:(NSString *)FIRNamespace [self loadExperimentsPayloadsWithCompletion:^(NSDictionary *experiments) { RCNConfigContent *strongSelf = weakSelf; if (!strongSelf) { + if (completionHandler) { + completionHandler(nil); + } return; } From 260aadb9aa1d1f3551365f8f375351ec75188f1b Mon Sep 17 00:00:00 2001 From: tusharkhandelwal8 Date: Wed, 15 Apr 2026 12:04:16 +0530 Subject: [PATCH 09/10] Fix potential data race in getConfigUpdateForNamespace after async transition --- .../Sources/RCNConfigContent.m | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/FirebaseRemoteConfig/Sources/RCNConfigContent.m b/FirebaseRemoteConfig/Sources/RCNConfigContent.m index e3da5cf5afb..da9df36ffcf 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigContent.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigContent.m @@ -543,6 +543,23 @@ - (NSMutableDictionary *)createExperimentsMap:(NSMutableArray *)experi // Compare fetched config with active config and output what has changed - (void)getConfigUpdateForNamespace:(NSString *)FIRNamespace completionHandler:(void (^)(FIRRemoteConfigUpdate *update))completionHandler { + NSDictionary *fetchedConfig; + NSDictionary *activeConfig; + NSDictionary *fetchedP13n; + NSDictionary *activeP13n; + NSArray *fetchedRolloutMetadata; + NSArray *activeRolloutMetadata; + + // Fully synchronize direct reads of shared state to prevent data races. + @synchronized(self) { + fetchedConfig = [self->_fetchedConfig[FIRNamespace] copy] ?: @{}; + activeConfig = [self->_activeConfig[FIRNamespace] copy] ?: @{}; + fetchedP13n = [self->_fetchedPersonalization copy] ?: @{}; + activeP13n = [self->_activePersonalization copy] ?: @{}; + fetchedRolloutMetadata = [self->_fetchedRolloutMetadata copy] ?: @[]; + activeRolloutMetadata = [self->_activeRolloutMetadata copy] ?: @[]; + } + __weak RCNConfigContent *weakSelf = self; [self loadExperimentsPayloadsWithCompletion:^(NSDictionary *experiments) { RCNConfigContent *strongSelf = weakSelf; @@ -555,18 +572,8 @@ - (void)getConfigUpdateForNamespace:(NSString *)FIRNamespace NSMutableSet *updatedKeys = [[NSMutableSet alloc] init]; - NSDictionary *fetchedConfig = strongSelf->_fetchedConfig[FIRNamespace] - ? strongSelf->_fetchedConfig[FIRNamespace] - : [[NSDictionary alloc] init]; - NSDictionary *activeConfig = strongSelf->_activeConfig[FIRNamespace] - ? strongSelf->_activeConfig[FIRNamespace] - : [[NSDictionary alloc] init]; - NSDictionary *fetchedP13n = strongSelf->_fetchedPersonalization; - NSDictionary *activeP13n = strongSelf->_activePersonalization; - NSArray *fetchedRolloutMetadata = strongSelf->_fetchedRolloutMetadata; - NSArray *activeRolloutMetadata = strongSelf->_activeRolloutMetadata; - // add new/updated params + // Note: We use the captured immutable snapshots here safely. for (NSString *key in [fetchedConfig allKeys]) { if (activeConfig[key] == nil || ![[activeConfig[key] stringValue] isEqualToString:[fetchedConfig[key] stringValue]]) { From 476b857648a502144ffd024a957b59c70b11bd50 Mon Sep 17 00:00:00 2001 From: tusharkhandelwal8 Date: Tue, 21 Apr 2026 21:42:29 +0530 Subject: [PATCH 10/10] Fixed concurrency issues and improved nil-safety --- .../Sources/RCNConfigContent.m | 71 +++++++++++-------- FirebaseRemoteConfig/Sources/RCNConfigFetch.m | 10 ++- 2 files changed, 51 insertions(+), 30 deletions(-) diff --git a/FirebaseRemoteConfig/Sources/RCNConfigContent.m b/FirebaseRemoteConfig/Sources/RCNConfigContent.m index da9df36ffcf..d49104b180a 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigContent.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigContent.m @@ -127,13 +127,15 @@ - (void)loadConfigFromMainTable { completionHandler:^( BOOL success, NSDictionary *fetchedConfig, NSDictionary *activeConfig, NSDictionary *defaultConfig, NSDictionary *rolloutMetadata) { - self->_fetchedConfig = [fetchedConfig mutableCopy]; - self->_activeConfig = [activeConfig mutableCopy]; - self->_defaultConfig = [defaultConfig mutableCopy]; - self->_fetchedRolloutMetadata = - [rolloutMetadata[@RCNRolloutTableKeyFetchedMetadata] copy]; - self->_activeRolloutMetadata = - [rolloutMetadata[@RCNRolloutTableKeyActiveMetadata] copy]; + @synchronized(self) { + self->_fetchedConfig = [fetchedConfig mutableCopy]; + self->_activeConfig = [activeConfig mutableCopy]; + self->_defaultConfig = [defaultConfig mutableCopy]; + self->_fetchedRolloutMetadata = + [rolloutMetadata[@RCNRolloutTableKeyFetchedMetadata] copy]; + self->_activeRolloutMetadata = + [rolloutMetadata[@RCNRolloutTableKeyActiveMetadata] copy]; + } dispatch_group_leave(self->_dispatch_group); }]; @@ -143,8 +145,10 @@ - (void)loadConfigFromMainTable { loadPersonalizationWithCompletionHandler:^( BOOL success, NSDictionary *fetchedPersonalization, NSDictionary *activePersonalization, NSDictionary *defaultConfig, NSDictionary *rolloutMetadata) { - self->_fetchedPersonalization = [fetchedPersonalization copy]; - self->_activePersonalization = [activePersonalization copy]; + @synchronized(self) { + self->_fetchedPersonalization = [fetchedPersonalization copy]; + self->_activePersonalization = [activePersonalization copy]; + } dispatch_group_leave(self->_dispatch_group); }]; } @@ -288,13 +292,17 @@ - (void)updateConfigContentWithResponse:(NSDictionary *)response } - (void)activatePersonalization { - _activePersonalization = _fetchedPersonalization; + @synchronized(self) { + _activePersonalization = _fetchedPersonalization; + } [_DBManager insertOrUpdatePersonalizationConfig:_activePersonalization fromSource:RCNDBSourceActive]; } - (void)activateRolloutMetadata:(void (^)(BOOL success))completionHandler { - _activeRolloutMetadata = _fetchedRolloutMetadata; + @synchronized(self) { + _activeRolloutMetadata = _fetchedRolloutMetadata; + } [_DBManager insertOrUpdateRolloutTableWithKey:@RCNRolloutTableKeyActiveMetadata value:_activeRolloutMetadata completionHandler:^(BOOL success, NSDictionary *result) { @@ -338,22 +346,25 @@ - (void)handleUpdateStateForConfigNamespace:(NSString *)currentNamespace [_DBManager deleteRecordFromMainTableWithNamespace:currentNamespace bundleIdentifier:_bundleIdentifier fromSource:RCNDBSourceFetched]; - if ([_fetchedConfig objectForKey:currentNamespace]) { - [_fetchedConfig[currentNamespace] removeAllObjects]; - } else { - _fetchedConfig[currentNamespace] = [[NSMutableDictionary alloc] init]; - } - // Store the fetched config values. - for (NSString *key in entries) { - NSData *valueData = [entries[key] dataUsingEncoding:NSUTF8StringEncoding]; - if (!valueData) { - continue; + @synchronized(self) { + if ([_fetchedConfig objectForKey:currentNamespace]) { + [_fetchedConfig[currentNamespace] removeAllObjects]; + } else { + _fetchedConfig[currentNamespace] = [[NSMutableDictionary alloc] init]; + } + + // Store the fetched config values. + for (NSString *key in entries) { + NSData *valueData = [entries[key] dataUsingEncoding:NSUTF8StringEncoding]; + if (!valueData) { + continue; + } + _fetchedConfig[currentNamespace][key] = + [[FIRRemoteConfigValue alloc] initWithData:valueData source:FIRRemoteConfigSourceRemote]; + NSArray *values = @[ _bundleIdentifier, currentNamespace, key, valueData ]; + [self updateMainTableWithValues:values fromSource:RCNDBSourceFetched]; } - _fetchedConfig[currentNamespace][key] = - [[FIRRemoteConfigValue alloc] initWithData:valueData source:FIRRemoteConfigSourceRemote]; - NSArray *values = @[ _bundleIdentifier, currentNamespace, key, valueData ]; - [self updateMainTableWithValues:values fromSource:RCNDBSourceFetched]; } } @@ -361,7 +372,9 @@ - (void)handleUpdatePersonalization:(NSDictionary *)metadata { if (!metadata) { return; } - _fetchedPersonalization = metadata; + @synchronized(self) { + _fetchedPersonalization = metadata; + } [_DBManager insertOrUpdatePersonalizationConfig:metadata fromSource:RCNDBSourceFetched]; } @@ -369,7 +382,9 @@ - (void)handleUpdateRolloutFetchedMetadata:(NSArray *)metadata { if (!metadata) { metadata = [[NSArray alloc] init]; } - _fetchedRolloutMetadata = metadata; + @synchronized(self) { + _fetchedRolloutMetadata = metadata; + } [_DBManager insertOrUpdateRolloutTableWithKey:@RCNRolloutTableKeyFetchedMetadata value:metadata completionHandler:nil]; @@ -532,7 +547,7 @@ - (NSMutableDictionary *)createExperimentsMap:(NSMutableArray *)experi NSDictionary *fetchedExp = fetchedMap[key]; // If one exists and the other doesn't, or if they both exist but differ - if (![activeExp isEqualToDictionary:fetchedExp]) { + if (![activeExp isEqual:fetchedExp]) { [changedKeys addObject:key]; } } diff --git a/FirebaseRemoteConfig/Sources/RCNConfigFetch.m b/FirebaseRemoteConfig/Sources/RCNConfigFetch.m index 0b7a5dab0b8..813e638b02d 100644 --- a/FirebaseRemoteConfig/Sources/RCNConfigFetch.m +++ b/FirebaseRemoteConfig/Sources/RCNConfigFetch.m @@ -584,7 +584,9 @@ - (void)fetchWithUserProperties:(NSDictionary *)userProperties fetchedConfig[RCNFetchResponseKeyExperimentDescriptions]]; } - strongSelf->_templateVersionNumber = [strongSelf getTemplateVersionNumber:fetchedConfig]; + @synchronized(strongSelf) { + strongSelf->_templateVersionNumber = [strongSelf getTemplateVersionNumber:fetchedConfig]; + } } else { FIRLogDebug(kFIRLoggerRemoteConfig, @"I-RCN000063", @"Empty response with no fetched config."); @@ -597,12 +599,16 @@ - (void)fetchWithUserProperties:(NSDictionary *)userProperties strongSelf->_settings.lastETag = latestETag; } // Compute config update after successful fetch + NSString *templateVersionNumber; + @synchronized(strongSelf) { + templateVersionNumber = strongSelf->_templateVersionNumber; + } [strongSelf->_content getConfigUpdateForNamespace:strongSelf->_FIRNamespace completionHandler:^(FIRRemoteConfigUpdate *update) { [strongSelf->_settings updateMetadataWithFetchSuccessStatus:YES - templateVersion:strongSelf->_templateVersionNumber]; + templateVersion:templateVersionNumber]; [strongSelf reportCompletionWithStatus:FIRRemoteConfigFetchStatusSuccess withUpdate:update withError:nil