Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tidy-mails-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'posthog-js': patch
'@posthog/types': patch
---

Adds a fresh option to getFeatureFlag(), getFeatureFlagResult(), and isFeatureEnabled() that only returns values loaded from the server, not cached localStorage values.
50 changes: 50 additions & 0 deletions packages/browser/src/__tests__/featureflags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,56 @@ describe('featureflags', () => {
)
})

describe('fresh option', () => {
it('should return undefined when fresh: true and flags have not been loaded from remote', () => {
// Flags exist in persistence (from previous session)
featureFlags._hasLoadedFlags = true
// But they haven't been loaded from the server yet
featureFlags._flagsLoadedFromRemote = false

expect(featureFlags.getFeatureFlag('beta-feature')).toEqual(true)
expect(featureFlags.getFeatureFlag('beta-feature', { fresh: true })).toEqual(undefined)

expect(featureFlags.isFeatureEnabled('beta-feature')).toEqual(true)
expect(featureFlags.isFeatureEnabled('beta-feature', { fresh: true })).toEqual(undefined)

expect(featureFlags.getFeatureFlagResult('beta-feature')).toEqual({
key: 'beta-feature',
enabled: true,
variant: undefined,
payload: { some: 'payload' },
})
expect(featureFlags.getFeatureFlagResult('beta-feature', { fresh: true })).toEqual(undefined)
})

it('should return flag value when fresh: true and flags have been loaded from remote', () => {
featureFlags._hasLoadedFlags = true
featureFlags._flagsLoadedFromRemote = true

expect(featureFlags.getFeatureFlag('beta-feature', { fresh: true })).toEqual(true)
expect(featureFlags.isFeatureEnabled('beta-feature', { fresh: true })).toEqual(true)
expect(featureFlags.getFeatureFlagResult('beta-feature', { fresh: true })).toEqual({
key: 'beta-feature',
enabled: true,
variant: undefined,
payload: { some: 'payload' },
})
})

it('should return undefined for fresh: true when only localStorage cache exists', () => {
// Simulate: flags exist in localStorage from previous session
// but no network request has completed yet
featureFlags._hasLoadedFlags = false
featureFlags._flagsLoadedFromRemote = false

// Without fresh option, cached values are returned
expect(featureFlags.getFeatureFlag('beta-feature')).toEqual(true)

// With fresh option, undefined is returned
expect(featureFlags.getFeatureFlag('beta-feature', { fresh: true })).toEqual(undefined)
})
})

it('should return the right feature flag and call capture', () => {
featureFlags._hasLoadedFlags = false

Expand Down
10 changes: 7 additions & 3 deletions packages/browser/src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
ExceptionAutoCaptureConfig,
FeatureFlagDetail,
FeatureFlagsCallback,
FeatureFlagOptions,
FeatureFlagResult,
JsonType,
OverrideConfig,
Expand Down Expand Up @@ -1679,8 +1680,9 @@ export class PostHog implements PostHogInterface {
*
* @param {Object|String} prop Key of the feature flag.
* @param {Object|String} options (optional) If {send_event: false}, we won't send an $feature_flag_call event to PostHog.
* If {fresh: true}, we won't return cached values from localStorage - only values loaded from the server.
*/
getFeatureFlag(key: string, options?: { send_event?: boolean }): boolean | string | undefined {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have the type options?: { send_event?: boolean; fresh?: boolean } in several places, maybe worth creating a type or an interface for it an centralize it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

getFeatureFlag(key: string, options?: FeatureFlagOptions): boolean | string | undefined {
return this.featureFlags.getFeatureFlag(key, options)
}

Expand Down Expand Up @@ -1736,9 +1738,10 @@ export class PostHog implements PostHogInterface {
* @param {string} key Key of the feature flag.
* @param {Object} [options] Options for the feature flag lookup.
* @param {boolean} [options.send_event=true] If false, won't send the $feature_flag_called event.
* @param {boolean} [options.fresh=false] If true, won't return cached values from localStorage - only values loaded from the server.
* @returns {FeatureFlagResult | undefined} The feature flag result including key, enabled, variant, and payload.
*/
getFeatureFlagResult(key: string, options?: { send_event?: boolean }): FeatureFlagResult | undefined {
getFeatureFlagResult(key: string, options?: FeatureFlagOptions): FeatureFlagResult | undefined {
return this.featureFlags.getFeatureFlagResult(key, options)
}

Expand Down Expand Up @@ -1771,8 +1774,9 @@ export class PostHog implements PostHogInterface {
*
* @param {Object|String} prop Key of the feature flag.
* @param {Object|String} options (optional) If {send_event: false}, we won't send an $feature_flag_call event to PostHog.
* If {fresh: true}, we won't return cached values from localStorage - only values loaded from the server.
*/
isFeatureEnabled(key: string, options?: { send_event: boolean }): boolean | undefined {
isFeatureEnabled(key: string, options?: FeatureFlagOptions): boolean | undefined {
return this.featureFlags.isFeatureEnabled(key, options)
}

Expand Down
62 changes: 52 additions & 10 deletions packages/browser/src/posthog-featureflags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
EarlyAccessFeatureStage,
FeatureFlagDetail,
FeatureFlagResult,
FeatureFlagOptions,
OverrideFeatureFlagsOptions,
} from './types'
import { PostHogPersistence } from './posthog-persistence'
Expand Down Expand Up @@ -528,17 +529,32 @@ export class PostHogFeatureFlags {
})
}

/*
/**
* Get feature flag's value for user.
*
* By default, this method may return cached values from localStorage if the `/flags` endpoint
* hasn't responded yet. This reduces flicker but means you might briefly see stale values
* (e.g., a flag that was disabled on the server).
*
* ### Usage:
*
* if(posthog.getFeatureFlag('my-flag') === 'some-variant') { // do something }
*
* @param {Object|String} key Key of the feature flag.
* @param {Object|String} options (optional) If {send_event: false}, we won't send an $feature_flag_called event to PostHog.
* // Only use fresh values from the server (returns undefined until /flags responds)
* if(posthog.getFeatureFlag('my-flag', { fresh: true }) === 'some-variant') { // do something }
*
* @param {String} key Key of the feature flag.
* @param {Object} options Optional settings.
* @param {boolean} [options.send_event=true] If false, won't send a $feature_flag_called event to PostHog.
* @param {boolean} [options.fresh=false] If true, only returns values loaded from the server, not cached localStorage values.
* Use this when you need to ensure the flag value reflects the current server state,
* such as after disabling a flag. Returns undefined until the /flags endpoint responds.
* @returns {boolean | string | undefined} The flag value, or undefined if not found or not yet loaded.
*/
getFeatureFlag(key: string, options: { send_event?: boolean } = {}): boolean | string | undefined {
getFeatureFlag(key: string, options: FeatureFlagOptions = {}): boolean | string | undefined {
if (options.fresh && !this._flagsLoadedFromRemote) {
return undefined
}
if (!this._hasLoadedFlags && !(this.getFlags() && this.getFlags().length > 0)) {
logger.warn('getFeatureFlag for key "' + key + '" failed. Feature flags didn\'t load in time.')
return undefined
Expand Down Expand Up @@ -578,19 +594,32 @@ export class PostHogFeatureFlags {
* Get a feature flag result including both the flag value and payload, while properly tracking the call.
* This method emits the `$feature_flag_called` event by default.
*
* By default, this method may return cached values from localStorage if the `/flags` endpoint
* hasn't responded yet. This reduces flicker but means you might briefly see stale values
* (e.g., a flag that was disabled on the server).
*
* ### Usage:
*
* const result = posthog.getFeatureFlagResult('my-flag')
* if (result?.enabled) {
* console.log('Flag is enabled with payload:', result.payload)
* }
*
* // Only use fresh values from the server
* const freshResult = posthog.getFeatureFlagResult('my-flag', { fresh: true })
*
* @param {String} key Key of the feature flag.
* @param {Object} [options] Options for the feature flag lookup.
* @param {boolean} [options.send_event=true] If false, won't send the $feature_flag_called event.
* @param {boolean} [options.fresh=false] If true, only returns values loaded from the server, not cached localStorage values.
* Use this when you need to ensure the flag value reflects the current server state.
* Returns undefined until the /flags endpoint responds.
* @returns {FeatureFlagResult | undefined} The feature flag result including key, enabled, variant, and payload.
*/
getFeatureFlagResult(key: string, options: { send_event?: boolean } = {}): FeatureFlagResult | undefined {
getFeatureFlagResult(key: string, options: FeatureFlagOptions = {}): FeatureFlagResult | undefined {
if (options.fresh && !this._flagsLoadedFromRemote) {
return undefined
}
if (!this._hasLoadedFlags && !(this.getFlags() && this.getFlags().length > 0)) {
logger.warn('getFeatureFlagResult for key "' + key + '" failed. Feature flags didn\'t load in time.')
return undefined
Expand Down Expand Up @@ -731,16 +760,29 @@ export class PostHogFeatureFlags {
/**
* See if feature flag is enabled for user.
*
* By default, this method may return cached values from localStorage if the `/flags` endpoint
* hasn't responded yet. This reduces flicker but means you might briefly see stale values
* (e.g., a flag that was disabled on the server).
*
* ### Usage:
*
* if(posthog.isFeatureEnabled('beta-feature')) { // do something }
*
* @param key Key of the feature flag.
* @param [options] If {send_event: false}, we won't send an $feature_flag_call event to PostHog.
* @returns A boolean value indicating whether or not the specified feature flag is enabled. If flag information has not yet been loaded,
* or if the specified feature flag is disabled or does not exist, returns undefined.
* // Only use fresh values from the server
* if(posthog.isFeatureEnabled('beta-feature', { fresh: true })) { // do something }
*
* @param {String} key Key of the feature flag.
* @param {Object} [options] Optional settings.
* @param {boolean} [options.send_event=true] If false, won't send a $feature_flag_called event to PostHog.
* @param {boolean} [options.fresh=false] If true, only returns values loaded from the server, not cached localStorage values.
* Use this when you need to ensure the flag value reflects the current server state.
* Returns undefined until the /flags endpoint responds.
* @returns {boolean | undefined} Whether the flag is enabled, or undefined if not found or not yet loaded.
*/
isFeatureEnabled(key: string, options: { send_event?: boolean } = {}): boolean | undefined {
isFeatureEnabled(key: string, options: FeatureFlagOptions = {}): boolean | undefined {
if (options.fresh && !this._flagsLoadedFromRemote) {
return undefined
}
if (!this._hasLoadedFlags && !(this.getFlags() && this.getFlags().length > 0)) {
logger.warn('isFeatureEnabled for key "' + key + '" failed. Feature flags didn\'t load in time.')
return undefined
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type {
FeatureFlagMetadata,
EvaluationReason,
FeatureFlagResult,
FeatureFlagOptions,
RemoteConfigFeatureFlagCallback,
EarlyAccessFeature,
EarlyAccessFeatureStage,
Expand Down
16 changes: 16 additions & 0 deletions packages/types/src/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ export type FeatureFlagOverrideOptions = {
suppressWarning?: boolean
}

/**
* Options for feature flag lookup methods (getFeatureFlag, isFeatureEnabled, getFeatureFlagResult).
*/
export type FeatureFlagOptions = {
/**
* Whether to send a $feature_flag_called event. Defaults to true.
*/
send_event?: boolean
/**
* If true, only return values loaded from the server, not cached localStorage values.
* Returns undefined if flags haven't been loaded from the server yet.
* Defaults to false.
*/
fresh?: boolean
}

/**
* Options for overriding feature flags on the client-side.
*
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type {
FeatureFlagMetadata,
EvaluationReason,
FeatureFlagResult,
FeatureFlagOptions,
RemoteConfigFeatureFlagCallback,
EarlyAccessFeature,
EarlyAccessFeatureStage,
Expand Down
13 changes: 10 additions & 3 deletions packages/types/src/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
EarlyAccessFeatureCallback,
EarlyAccessFeatureStage,
FeatureFlagResult,
FeatureFlagOptions,
OverrideFeatureFlagsOptions,
} from './feature-flags'
import type { SessionIdChangedCallback } from './session-recording'
Expand Down Expand Up @@ -231,9 +232,11 @@ export interface PostHog {
*
* @param key - The feature flag key
* @param options - Options for the feature flag lookup
* @param options.send_event - Whether to send a $feature_flag_called event (default: true)
* @param options.fresh - If true, only return values loaded from the server, not cached localStorage values (default: false)
* @returns The feature flag value (boolean for simple flags, string for multivariate)
*/
getFeatureFlag(key: string, options?: { send_event?: boolean }): boolean | string | undefined
getFeatureFlag(key: string, options?: FeatureFlagOptions): boolean | string | undefined

/**
* Get the payload of a feature flag.
Expand All @@ -251,18 +254,22 @@ export interface PostHog {
*
* @param key - The feature flag key
* @param options - Options for the feature flag lookup
* @param options.send_event - Whether to send a $feature_flag_called event (default: true)
* @param options.fresh - If true, only return values loaded from the server, not cached localStorage values (default: false)
* @returns The feature flag result including key, enabled, variant, and payload, or undefined if not loaded
*/
getFeatureFlagResult(key: string, options?: { send_event?: boolean }): FeatureFlagResult | undefined
getFeatureFlagResult(key: string, options?: FeatureFlagOptions): FeatureFlagResult | undefined

/**
* Check if a feature flag is enabled.
*
* @param key - The feature flag key
* @param options - Options for the feature flag lookup
* @param options.send_event - Whether to send a $feature_flag_called event (default: true)
* @param options.fresh - If true, only return values loaded from the server, not cached localStorage values (default: false)
* @returns Whether the feature flag is enabled
*/
isFeatureEnabled(key: string, options?: { send_event?: boolean }): boolean | undefined
isFeatureEnabled(key: string, options?: FeatureFlagOptions): boolean | undefined

/**
* Reload feature flags from the server.
Expand Down
Loading