Skip to content

Commit 0af87a6

Browse files
jakewskimsiebertclaudedhaval-valotia
authored
2.78.0 release (#563)
* Add `loadFlags` method to FeatureFlagManager Adds a public method for manually refetching feature flags on demand. Deduplicates concurrent requests by returning the existing in-flight promise when a fetch is already in progress. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix in-flight test to use a hanging fetch promise The previous test relied on microtask timing which could allow the fetch to complete before loadFlags was called, making the test pass even without deduplication logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Propagate errors from loadFlags, fix race conditions, and add tests - loadFlags() now returns a Promise that rejects on fetch failure via a new `propagateErrors` parameter on fetchFlags (backwards compatible) - loadFlags always starts a new fetch even when one is in-flight, avoiding a race condition where callers could miss error signals - Add double markFetchComplete guard to prevent redundant cleanup - Add pre-init guard so loadFlags resolves gracefully before init - Update TypeScript declarations with load_flags() - Add tests for error propagation, race conditions with in-flight fetches (init and updateContext), and flag state preservation after failures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Restore in-flight fetch deduplication for loadFlags loadFlags() now reuses an in-flight fetch instead of starting a new one. Tracks fetch errors via _lastFetchError so the loadFlags promise still rejects on failure even when reusing a shared fetchPromise. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add CLAUDE.md for flags module with codebase conventions Documents testing, code style, public API patterns, and error handling conventions discovered during loadFlags implementation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Refactor fetchFlags to always reject on error and flatten promise chains - Remove propagateErrors param; fetchFlags now always rejects on error - Fire-and-forget callers (init, updateContext, identify) swallow errors at the call site with .catch(function() {}) - Flatten nested .then() into a flat promise chain with .bind(this) - Simplify loadFlags to directly return fetchPromise (no _lastFetchError) - Update tests to reflect new error propagation behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove cjs import and follow snippet approach (#409) * Remove cjs import and follow snippet approach * add fallback for module-cjs * Add error logging to fire-and-forget fetchFlags catch blocks Address PR review feedback: log contextual error messages in silent catch blocks so failures are visible in the console. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Revert "FF-603: loadFlags error propagation, race condition fixes, and tests" (#412) * Add when_ready API and fix updateContext setMpConfig call (#414) Add whenReady()/when_ready() public method to FeatureFlagManager that returns the in-flight fetch promise if one exists, otherwise resolves immediately. This allows consumers to await flag readiness without accessing internal properties. Fix updateContext() to pass a config object to setMpConfig() instead of two positional arguments, matching the set_config(config) signature it is bound to. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * FF-603: loadFlags error propagation, race condition fixes, and tests (#413) Re-apply changes from PR #394 onto 2.78.0-rc branch. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * build rc1 * release build * update changelog for 2.78.0 * 2.78.0 * fix changelog version --------- Co-authored-by: Mark Siebert <mark.siebert@mixpanel.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: dhaval-valotia <163026311+dhaval-valotia@users.noreply.github.com> Co-authored-by: Jakub Grzegorzewski <25271819+jakewski@users.noreply.github.com>
1 parent 73cef39 commit 0af87a6

44 files changed

Lines changed: 1574 additions & 1007 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
**2.78.0** (8 Apr 2026)
2+
- Adds `loadFlags` method to the `mixpanel.flags` to manually refresh feature flags.
3+
- Adds `whenReady` method to the `mixpanel.flags`, which returns a Promise that resolves when feature flags are done fetching.
4+
15
**2.77.0** (24 Mar 2026)
26
- Session recording now supports cross origin iframe recording by specifying allowed domains via `record_allowed_iframe_origins`.
37
- Added type dependency @types/json-logic-js for the RulesLogic type introduced in 2.76.0

dist/async-modules/mixpanel-recorder-wIWnMDLA.min.js renamed to dist/async-modules/mixpanel-recorder-BjSlYaNJ.min.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/async-modules/mixpanel-recorder-wIWnMDLA.min.js.map renamed to dist/async-modules/mixpanel-recorder-BjSlYaNJ.min.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/async-modules/mixpanel-recorder-DLKbUIEE.js renamed to dist/async-modules/mixpanel-recorder-zMBXIyeG.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
}
2828

2929
var Config = {
30-
LIB_VERSION: '2.77.0'
30+
LIB_VERSION: '2.78.0'
3131
};
3232
var RECORDER_GLOBAL_NAME = '__mp_recorder';
3333

dist/async-modules/mixpanel-targeting-CTcftSJC.min.js renamed to dist/async-modules/mixpanel-targeting-BSHal4N9.min.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/async-modules/mixpanel-targeting-CTcftSJC.min.js.map renamed to dist/async-modules/mixpanel-targeting-BSHal4N9.min.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/async-modules/mixpanel-targeting-CmVvUyFM.js renamed to dist/async-modules/mixpanel-targeting-UHf4eBfC.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
}
2828

2929
var Config = {
30-
LIB_VERSION: '2.77.0'
30+
LIB_VERSION: '2.78.0'
3131
};
3232

3333
// Window global names for async modules

dist/mixpanel-core.cjs.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ export interface FlagsUpdateContextOptions {
357357

358358
export interface FlagsManager {
359359
are_flags_ready(): boolean;
360+
load_flags(): Promise<void>;
360361
get_variant(
361362
featureName: string,
362363
fallback: FlagsVariant

dist/mixpanel-core.cjs.js

Lines changed: 111 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
var Config = {
44
DEBUG: false,
5-
LIB_VERSION: '2.77.0'
5+
LIB_VERSION: '2.78.0'
66
};
77

88
// Window global names for async modules
@@ -3971,7 +3971,9 @@ FeatureFlagManager.prototype.init = function() {
39713971
}
39723972

39733973
this.flags = null;
3974-
this.fetchFlags();
3974+
this.fetchFlags().catch(function() {
3975+
logger$4.error('Error fetching flags during init');
3976+
});
39753977

39763978
this.trackedFeatures = new Set();
39773979
this.pendingFirstTimeEvents = {};
@@ -4012,8 +4014,12 @@ FeatureFlagManager.prototype.updateContext = function(newContext, options) {
40124014
var oldContext = (options && options['replace']) ? {} : this.getConfig(CONFIG_CONTEXT);
40134015
ffConfig[CONFIG_CONTEXT] = _.extend({}, oldContext, newContext);
40144016

4015-
this.setMpConfig(FLAGS_CONFIG_KEY, ffConfig);
4016-
return this.fetchFlags();
4017+
var configUpdate = {};
4018+
configUpdate[FLAGS_CONFIG_KEY] = ffConfig;
4019+
this.setMpConfig(configUpdate);
4020+
return this.fetchFlags().catch(function() {
4021+
logger$4.error('Error fetching flags during updateContext');
4022+
});
40174023
};
40184024

40194025
FeatureFlagManager.prototype.areFlagsReady = function() {
@@ -4050,96 +4056,110 @@ FeatureFlagManager.prototype.fetchFlags = function() {
40504056
}
40514057
}).then(function(response) {
40524058
this.markFetchComplete();
4053-
return response.json().then(function(responseBody) {
4054-
var responseFlags = responseBody['flags'];
4055-
if (!responseFlags) {
4056-
throw new Error('No flags in API response');
4057-
}
4058-
var flags = new Map();
4059-
var pendingFirstTimeEvents = {};
4060-
4061-
// Process flags from response
4062-
_.each(responseFlags, function(data, key) {
4063-
// Check if this flag has any activated first-time events this session
4064-
var hasActivatedEvent = false;
4065-
var prefix = key + ':';
4066-
_.each(this.activatedFirstTimeEvents, function(activated, eventKey) {
4067-
if (eventKey.startsWith(prefix)) {
4068-
hasActivatedEvent = true;
4069-
}
4070-
});
4059+
return response.json();
4060+
}.bind(this)).then(function(responseBody) {
4061+
var responseFlags = responseBody['flags'];
4062+
if (!responseFlags) {
4063+
throw new Error('No flags in API response');
4064+
}
4065+
var flags = new Map();
4066+
var pendingFirstTimeEvents = {};
4067+
4068+
// Process flags from response
4069+
_.each(responseFlags, function(data, key) {
4070+
// Check if this flag has any activated first-time events this session
4071+
var hasActivatedEvent = false;
4072+
var prefix = key + ':';
4073+
_.each(this.activatedFirstTimeEvents, function(activated, eventKey) {
4074+
if (eventKey.startsWith(prefix)) {
4075+
hasActivatedEvent = true;
4076+
}
4077+
});
40714078

4072-
if (hasActivatedEvent) {
4073-
// Preserve the activated variant, don't overwrite with server's current variant
4074-
var currentFlag = this.flags && this.flags.get(key);
4075-
if (currentFlag) {
4076-
flags.set(key, currentFlag);
4077-
}
4078-
} else {
4079-
// Use server's current variant
4080-
flags.set(key, {
4081-
'key': data['variant_key'],
4082-
'value': data['variant_value'],
4083-
'experiment_id': data['experiment_id'],
4084-
'is_experiment_active': data['is_experiment_active'],
4085-
'is_qa_tester': data['is_qa_tester']
4086-
});
4079+
if (hasActivatedEvent) {
4080+
// Preserve the activated variant, don't overwrite with server's current variant
4081+
var currentFlag = this.flags && this.flags.get(key);
4082+
if (currentFlag) {
4083+
flags.set(key, currentFlag);
40874084
}
4088-
}, this);
4085+
} else {
4086+
// Use server's current variant
4087+
flags.set(key, {
4088+
'key': data['variant_key'],
4089+
'value': data['variant_value'],
4090+
'experiment_id': data['experiment_id'],
4091+
'is_experiment_active': data['is_experiment_active'],
4092+
'is_qa_tester': data['is_qa_tester']
4093+
});
4094+
}
4095+
}, this);
40894096

4090-
// Process top-level pending_first_time_events array
4091-
var topLevelDefinitions = responseBody['pending_first_time_events'];
4092-
if (topLevelDefinitions && topLevelDefinitions.length > 0) {
4093-
_.each(topLevelDefinitions, function(def) {
4094-
var flagKey = def['flag_key'];
4095-
var eventKey = getPendingEventKey(flagKey, def['first_time_event_hash']);
4097+
// Process top-level pending_first_time_events array
4098+
var topLevelDefinitions = responseBody['pending_first_time_events'];
4099+
if (topLevelDefinitions && topLevelDefinitions.length > 0) {
4100+
_.each(topLevelDefinitions, function(def) {
4101+
var flagKey = def['flag_key'];
4102+
var eventKey = getPendingEventKey(flagKey, def['first_time_event_hash']);
40964103

4097-
// Skip if this specific event has already been activated this session
4098-
if (this.activatedFirstTimeEvents[eventKey]) {
4099-
return;
4100-
}
4104+
// Skip if this specific event has already been activated this session
4105+
if (this.activatedFirstTimeEvents[eventKey]) {
4106+
return;
4107+
}
41014108

4102-
// Store pending event definition using composite key
4103-
pendingFirstTimeEvents[eventKey] = {
4104-
'flag_key': flagKey,
4105-
'flag_id': def['flag_id'],
4106-
'project_id': def['project_id'],
4107-
'first_time_event_hash': def['first_time_event_hash'],
4108-
'event_name': def['event_name'],
4109-
'property_filters': def['property_filters'],
4110-
'pending_variant': def['pending_variant']
4111-
};
4112-
}, this);
4113-
}
4109+
// Store pending event definition using composite key
4110+
pendingFirstTimeEvents[eventKey] = {
4111+
'flag_key': flagKey,
4112+
'flag_id': def['flag_id'],
4113+
'project_id': def['project_id'],
4114+
'first_time_event_hash': def['first_time_event_hash'],
4115+
'event_name': def['event_name'],
4116+
'property_filters': def['property_filters'],
4117+
'pending_variant': def['pending_variant']
4118+
};
4119+
}, this);
4120+
}
41144121

4115-
// Preserve any activated orphaned flags (flags that were activated but are no longer in response)
4116-
if (this.activatedFirstTimeEvents) {
4117-
_.each(this.activatedFirstTimeEvents, function(activated, eventKey) {
4118-
var flagKey = getFlagKeyFromPendingEventKey(eventKey);
4119-
if (activated && !flags.has(flagKey) && this.flags && this.flags.has(flagKey)) {
4120-
// Keep the activated flag even though it's not in the new response
4121-
flags.set(flagKey, this.flags.get(flagKey));
4122-
}
4123-
}, this);
4124-
}
4122+
// Preserve any activated orphaned flags (flags that were activated but are no longer in response)
4123+
if (this.activatedFirstTimeEvents) {
4124+
_.each(this.activatedFirstTimeEvents, function(activated, eventKey) {
4125+
var flagKey = getFlagKeyFromPendingEventKey(eventKey);
4126+
if (activated && !flags.has(flagKey) && this.flags && this.flags.has(flagKey)) {
4127+
// Keep the activated flag even though it's not in the new response
4128+
flags.set(flagKey, this.flags.get(flagKey));
4129+
}
4130+
}, this);
4131+
}
41254132

4126-
this.flags = flags;
4127-
this.pendingFirstTimeEvents = pendingFirstTimeEvents;
4128-
this._traceparent = traceparent;
4133+
this.flags = flags;
4134+
this.pendingFirstTimeEvents = pendingFirstTimeEvents;
4135+
this._traceparent = traceparent;
41294136

4130-
this._loadTargetingIfNeeded();
4131-
}.bind(this)).catch(function(error) {
4132-
this.markFetchComplete();
4133-
logger$4.error(error);
4134-
}.bind(this));
4137+
this._loadTargetingIfNeeded();
41354138
}.bind(this)).catch(function(error) {
4136-
this.markFetchComplete();
4139+
if (this._fetchInProgressStartTime) {
4140+
this.markFetchComplete();
4141+
}
41374142
logger$4.error(error);
4143+
throw error;
41384144
}.bind(this));
41394145

41404146
return this.fetchPromise;
41414147
};
41424148

4149+
FeatureFlagManager.prototype.loadFlags = function() {
4150+
if (!this.isSystemEnabled()) {
4151+
return Promise.resolve();
4152+
}
4153+
if (!this.trackedFeatures) {
4154+
logger$4.error('loadFlags called before init');
4155+
return Promise.resolve();
4156+
}
4157+
if (this._fetchInProgressStartTime) {
4158+
return this.fetchPromise;
4159+
}
4160+
return this.fetchFlags();
4161+
};
4162+
41434163
FeatureFlagManager.prototype.markFetchComplete = function() {
41444164
if (!this._fetchInProgressStartTime) {
41454165
logger$4.error('Fetch in progress started time not set, cannot mark fetch complete');
@@ -4419,6 +4439,13 @@ FeatureFlagManager.prototype.trackFeatureCheck = function(featureName, feature)
44194439
this.track('$experiment_started', trackingProperties);
44204440
};
44214441

4442+
FeatureFlagManager.prototype.whenReady = function() {
4443+
if (this.fetchPromise) {
4444+
return this.fetchPromise;
4445+
}
4446+
return Promise.resolve();
4447+
};
4448+
44224449
FeatureFlagManager.prototype.minApisSupported = function() {
44234450
return !!this.fetch &&
44244451
typeof Promise !== 'undefined' &&
@@ -4435,7 +4462,9 @@ FeatureFlagManager.prototype['get_variant_value'] = FeatureFlagManager.prototype
44354462
FeatureFlagManager.prototype['get_variant_value_sync'] = FeatureFlagManager.prototype.getVariantValueSync;
44364463
FeatureFlagManager.prototype['is_enabled'] = FeatureFlagManager.prototype.isEnabled;
44374464
FeatureFlagManager.prototype['is_enabled_sync'] = FeatureFlagManager.prototype.isEnabledSync;
4465+
FeatureFlagManager.prototype['load_flags'] = FeatureFlagManager.prototype.loadFlags;
44384466
FeatureFlagManager.prototype['update_context'] = FeatureFlagManager.prototype.updateContext;
4467+
FeatureFlagManager.prototype['when_ready'] = FeatureFlagManager.prototype.whenReady;
44394468

44404469
// Deprecated method
44414470
FeatureFlagManager.prototype['get_feature_data'] = FeatureFlagManager.prototype.getFeatureData;
@@ -9142,7 +9171,9 @@ MixpanelLib.prototype.identify = function(
91429171

91439172
// check feature flags again if distinct id has changed
91449173
if (new_distinct_id !== previous_distinct_id) {
9145-
this.flags.fetchFlags();
9174+
this.flags.fetchFlags().catch(function() {
9175+
console.error('[flags] Error fetching flags during identify');
9176+
});
91469177
}
91479178
};
91489179

dist/mixpanel-recorder.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
}
2828

2929
var Config = {
30-
LIB_VERSION: '2.77.0'
30+
LIB_VERSION: '2.78.0'
3131
};
3232
var RECORDER_GLOBAL_NAME = '__mp_recorder';
3333

0 commit comments

Comments
 (0)