Skip to content

fix(react-sdk): don't mask existing flag values while client is initializing#667

Open
linardsblk wants to merge 1 commit into
reflagcom:mainfrom
linardsblk:fix/useflag-mask-initializing
Open

fix(react-sdk): don't mask existing flag values while client is initializing#667
linardsblk wants to merge 1 commit into
reflagcom:mainfrom
linardsblk:fix/useflag-mask-initializing

Conversation

@linardsblk

Copy link
Copy Markdown

fix(react-sdk): don't mask existing flag values while client is initializing

Problem

useFlag returns isEnabled: false and an empty config for every flag whenever the provider reports isLoading — even when the client already holds servable values:

if (isLoading || !flag) {
  return { key, isLoading, isEnabled: false, config: { key: undefined, payload: undefined }, ... };
}

This bites in two places:

  1. ReflagBootstrappedProvider (SSR): the client is constructed with bootstrapped flags and serves them from the first render — but the post-mount initialize() call unconditionally passes through the "initializing" state (SSE/pubsub setup etc.), flipping isLoading to true for at least one committed frame. During that frame useFlag masks the valid bootstrapped values, so every SSR-bootstrapped page renders: correct values → all flags disabled → correct values. Flag-gated UI visibly flashes, which is exactly what bootstrapping exists to prevent. Notably, ReflagBootstrappedProvider defaults initialLoading to false — the intent that a bootstrapped client isn't loading is already there; the stateUpdated listener overrides it one effect-tick later.

  2. setContext(): the initialized → "initializing" → initialized transition around the context refetch masks last-known values for the whole request duration on every context change (e.g. auth session resolving after mount), instead of serving them stale-while-revalidate style.

Repro for (1): any page using ReflagBootstrappedProvider with getFlagsForBootstrap() data from @reflag/node-sdk; log useFlag(...).isEnabled and observe true → false → true across hydration.

Fix

Fall back to disabled defaults only when the client has no value for the flag (!flag). isLoading is still computed and returned unchanged, so consumers that render skeletons or guard redirects on it keep working exactly as before. The only behavioral change: when a value exists, it is served regardless of the client's lifecycle state.

Test

The existing bootstrapped-provider tests assert via waitFor, which skips transient frames — the flash was invisible to them. The new regression test mounts a probe component that records isEnabled/isLoading on every render under ReflagBootstrappedProvider, waits for initialization to settle, and asserts no render ever reported the bootstrapped flag as disabled. It fails on the previous implementation and passes with this change.

Out of scope (flagging for maintainers)

Two adjacent symptoms share the same root cause (lifecycle state conflated with data readiness) and are not changed here, to keep this PR minimal and non-breaking:

  • A loadingComponent passed to ReflagBootstrappedProvider still swaps in for the one "initializing" frame after hydration.
  • Bare useIsLoading() still reads true during that frame.

A cleaner long-term shape might be separating lifecycle state from a data-readiness signal (e.g. ready/hasFlags), but "initializing" is currently load-bearing (concurrent-initialize() dedupe, setContext transitions, pubsub reconcile checks, the public stateUpdated event sequence), so that felt like a maintainer decision rather than a drive-by change. Happy to follow up if there's interest.

…alizing

useFlag returned isEnabled: false and an empty config for every flag
whenever the client state was "initializing", even when the client
already held servable values. This flashes flag-gated UI to defaults:

- ReflagBootstrappedProvider: initialize() passes through "initializing"
  after hydration although flags were injected at construction, so every
  SSR-bootstrapped page gets one guaranteed frame with all flags disabled
  - defeating the purpose of bootstrapping.
- setContext(): the initialized -> initializing -> initialized transition
  around the refetch masked last-known values for the whole request
  duration on every context change.

Only fall back to disabled defaults when the client has no value for the
flag. isLoading is still exposed unchanged for consumers that want to
render loading states themselves.

The new regression test records every render, since waitFor-based
assertions skip the transient frames where the flash happened.
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.

1 participant