Skip to content

Bug/onboarding flag issue - #3058

Merged
panasetskaya merged 4 commits into
developfrom
bug/onboarding-flag-issue
Aug 14, 2026
Merged

Bug/onboarding flag issue#3058
panasetskaya merged 4 commits into
developfrom
bug/onboarding-flag-issue

Conversation

@panasetskaya

@panasetskaya panasetskaya commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Fix for the bug where onboarding showed on every cold start regardless of the disable_onboarding F flag.

Seems like it was an existing bug that never surfaced since we don't have any other flag that is needed only once on start and is never re-fetched again.

The cause description from Claude:

Root cause was our own bootstrap list. DefaultUnleash.start(bootstrap = …) writes the seeded                                                                      
 toggles into the cache like any other state; readyOnFeaturesReceived() flips ready on the first                                                                   
 non-empty cache update, so bootstrap alone made isReady() true instantly; and                                                                                     
 initializeLocalBackup() only loads the on-disk backup while !ready, so seeding bootstrap also                                                                     
 meant the backup could never load. Net effect: awaitReady() returned immediately with only the                                                                    
 three bootstrapped toggles in cache, and every other flag read false until the first poll landed                                                                  
 ~1s later. OnboardingGate is the app's only one-shot (.first()) flag read that runs at startup,                                                                   
 so it was the only place the defect was visible.                                                                                                                  

So the fix mainly removed bootstrap from isReady(), and did adjustments: added 3 existing defaults for 3 flags to neverFetchedDefaults map consulted only while !client.isReady(), added valueOf(feature), switched featureUpdatedFlow to UnleashStateListener.onStateChanged().

@panasetskaya
panasetskaya requested a review from a team as a code owner August 14, 2026 06:49
Comment on lines 62 to 66
val listener = object : UnleashStateListener {
override fun onStateChanged() {
trySend(Unit)
}
}

@StylianosGakis StylianosGakis Aug 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think I am mostly concerned with this, if it's equivalent and fits all of our needs here.

I think we can make this a bit more complete with this change

Suggested change
val listener = object : UnleashStateListener {
override fun onStateChanged() {
trySend(Unit)
}
}
val listener = object : UnleashStateListener, UnleashReadyListener {
override fun onStateChanged() { trySend(Unit) }
override fun onReady() { trySend(Unit) }
}

valueOf() branches on client.isReady(), but the SDK sets that flag from
readyOnFeaturesReceived(), a coroutine independent of the one that delivers
onStateChanged() to registered state listeners. Both are collectors of the same
cache updates SharedFlow, so on the first cache write they race with no ordering
guarantee. When the state listener wins, featureUpdatedFlow emits, valueOf()
sees isReady() == false and returns the neverFetchedDefaults value even though
the cache already holds real toggles.

Nothing corrects that afterwards on a fresh install: doFetchToggles emits to
featuresReceivedFlow only on a successful fetch, and once the fetcher has an
ETag the following polls answer 304, so there is no further cache write and no
further emission. The flag stays at its default for the rest of the process.
With a backup on disk a second write follows shortly and hides the problem,
which is why it only bites the never-fetched case the defaults exist for.

Registering the same listener object as an UnleashReadyListener too closes it.
onReady() runs after readyOnFeaturesReceived() has flipped the flag, so whichever
collector wins, a re-read follows. distinctUntilChanged() in
UnleashFeatureFlagProvider absorbs the duplicate emission.

Also drops a paragraph from FEATURE_FLAG_DEFAULTS.md claiming a 304 in a fresh
process keeps the cache near-empty via an ETag carried in the OkHttp disk cache.
UnleashFetcher.etag is an in-memory var, so a fresh process sends no
If-None-Match of its own, and OkHttp never surfaces a 304 to the caller: it
merges and returns the stored 200, which repopulates.
…ner-emit

Emit on Unleash readiness, not only on toggle state change
@panasetskaya
panasetskaya enabled auto-merge August 14, 2026 07:59
@panasetskaya
panasetskaya merged commit 0f9a39e into develop Aug 14, 2026
4 checks passed
@panasetskaya
panasetskaya deleted the bug/onboarding-flag-issue branch August 14, 2026 08:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants