Skip to content

fix(identities): multivariate override editor hides the identity's override value - #8279

Open
bardock-2393 wants to merge 3 commits into
Flagsmith:mainfrom
bardock-2393:fix/mv-override-editor-hides-value
Open

fix(identities): multivariate override editor hides the identity's override value#8279
bardock-2393 wants to merge 3 commits into
Flagsmith:mainfrom
bardock-2393:fix/mv-override-editor-hides-value

Conversation

@bardock-2393

Copy link
Copy Markdown
Contributor

When a flag with an existing identity override is later made multivariate, the Edit User Feature modal stops showing what that identity is actually being served. The editor offers only the environment's control value and each variation as radios, so an override holding anything else has nowhere to appear — the control row reads as selected and the identity looks like it is on the environment default. The override is intact and the SDK keeps serving it, but saving the modal replaced it with the control value, so the value could be lost by simply opening and saving.

Rather than hiding it, the editor now shows the override's own value as a read-only, already-selected row alongside the variations, and warns that the value is not one of them so the user can move the identity onto a variation deliberately. Saving keeps the value unless the user picks the control or a variation instead.

Changes

  • The Edit User Feature modal shows an identity's override value even when it is not one of the flag's variations.
  • A warning explains that the value is not a variation, and recommends changing it.
  • Saving the modal no longer replaces such an override with the environment's control value.
  • Tests covering when an override counts as unrepresentable by the variation radios.

Closes #8271

Review effort: 2/5

How did you test this code?

Manually, end to end, against a local API and dashboard, following the reproduction steps in the issue: a flag with control value ENV_DEFAULT, an identity override of MY_OVERRIDE, then a variation VARIANT_A added to make the flag multivariate.

  • Opening Edit User Feature for that identity shows the warning, MY_OVERRIDE selected and read-only, ENV_DEFAULT and VARIANT_A unselected. On main the same modal shows ENV_DEFAULT selected and MY_OVERRIDE nowhere.
  • Pressing Update Feature without changing the selection leaves the stored value as MY_OVERRIDE (confirmed directly in the database). On main this is where the override was replaced with the control value.

Automated: the rule deciding whether an override is representable by the variation radios is unit tested in common/utils/__tests__/multivariate.test.ts, including the plan-unchanged case, an identity assigned a variation, a partially weighted override, and null/undefined/empty edge cases. npm run test:unit passes in full (402 tests), npm run lint reports nothing on the changed files, and npm run typecheck produces an identical error set to main for them.

A note on behaviour

The same guard applies when a multivariate identity override's stored value has drifted from the environment's control value for any other reason — for example the environment control being edited after the override was made. Previously a save silently re-synced the identity onto the new control value, changing what that identity is served; now the value is shown, flagged, and left alone until the user chooses. That seemed the safer default given the issue is about a value disappearing, but say the word if you would rather keep re-syncing in that case.

…erride value

The editor for a multivariate flag only renders the environment's control
value and each variation as radios, so an identity override holding any other
value has nowhere to appear. The control row reads as selected and the
identity looks like it is on the environment default, even though the SDK
still serves the override. Saving the modal then replaced the override with
the control value.

Show such a value as a read-only, selected row alongside the variations, warn
that it is not one of them, and keep it on save unless the user picks the
control or a variation instead.
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

@bardock-2393 is attempting to deploy a commit to the Flagsmith Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds hasUnmatchedIdentityOverride and resolveUnmatchedOverride with tests for value, allocation, and latching cases. The feature value tab detects and latches unmatched identity overrides, then passes them to VariationOptions. The UI displays the latched value in a disabled editor and updates control selection. Identity saves preserve unmatched override values instead of always applying the environment control value.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🔵 Low · up to 630b7

The change prevents an identity override from being silently replaced when editing a multivariate flag, but the new editor behavior still has bounded frontend risks around keyboard accessibility and state handling that should have explicit owner follow-up before or after merge.

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the front-end Issue related to the React Front End Dashboard label Aug 12, 2026
@bardock-2393
bardock-2393 marked this pull request as ready for review August 12, 2026 20:33
@bardock-2393
bardock-2393 requested a review from a team as a code owner August 12, 2026 20:33
@bardock-2393
bardock-2393 requested review from talissoncosta and removed request for a team August 12, 2026 20:33

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0a36622b-b132-4506-adde-3ddfe7686830

📥 Commits

Reviewing files that changed from the base of the PR and between 6c88198 and 66eeee4.

📒 Files selected for processing (5)
  • frontend/common/utils/__tests__/multivariate.test.ts
  • frontend/common/utils/multivariate.ts
  • frontend/web/components/modals/create-feature/index.tsx
  • frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx
  • frontend/web/components/mv/VariationOptions.tsx

Comment on lines +20 to +23
}: {
controlValue: FlagsmithValue
overrideValue: FlagsmithValue
variationOverrides: { percentage_allocation: number }[] | null | undefined

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Extract the variation override union into a named type.

variationOverrides uses an inline union type. Define a named type and use it in the parameter object.

Proposed fix
+type VariationOverrides =
+  | { percentage_allocation: number }[]
+  | null
+  | undefined
+
 export const hasUnmatchedIdentityOverride = ({
   controlValue,
   overrideValue,
   variationOverrides,
 }: {
   controlValue: FlagsmithValue
   overrideValue: FlagsmithValue
-  variationOverrides: { percentage_allocation: number }[] | null | undefined
+  variationOverrides: VariationOverrides
 }): boolean =>

As per coding guidelines, frontend/**/*.{ts,tsx} must extract inline union types into named types.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
}: {
controlValue: FlagsmithValue
overrideValue: FlagsmithValue
variationOverrides: { percentage_allocation: number }[] | null | undefined
type VariationOverrides =
| { percentage_allocation: number }[]
| null
| undefined
export const hasUnmatchedIdentityOverride = ({
controlValue,
overrideValue,
variationOverrides,
}: {
controlValue: FlagsmithValue
overrideValue: FlagsmithValue
variationOverrides: VariationOverrides
}): boolean =>

Source: Coding guidelines

@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
flagsmith-frontend-preview Ready Ready Preview Aug 19, 2026 2:25pm
flagsmith-frontend-staging Ready Ready Preview Aug 19, 2026 2:25pm

Request Review

@Holmus

Holmus commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Hey @bardock-2393, looks like there are some automated review comments to address.

Thanks for your contribution!

@bardock-2393

Copy link
Copy Markdown
Contributor Author

I will resolved it today

@kyle-ssg

Copy link
Copy Markdown
Member

hey @bardock-2393 thanks for raising this use case, what you've done makes sense however I think one thing could do with fixing:

image

Here as soon as I click the second option the first (the identity override) vanishes. I think it should only be removed once the feature has been saved.

Thanks!

Picking a variation removed the unmatched override row outright, because
its presence was read from the same live editor state that decides which
row is selected. The value the user is choosing to replace vanished from
under them, with no way back short of closing the modal.

Latch the row's presence for the lifetime of the editor, and let only its
selected state follow the live edits. Selecting it again restores the
original value, so the row is a real choice rather than a notice.
The rule the previous commit fixed — presence outlives selection — lived
inline in the tab, where nothing could reach it: jest runs in a node
environment with no DOM, so a component test would mean new dependencies.

Lift the decision into resolveUnmatchedOverride and test it there. The
three cases that matter fail against the old presence-follows-selection
rule, including the null override value that a naive latch drops.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9f96d5c8-43ac-4ac5-8883-9d6c5e5ffe6c

📥 Commits

Reviewing files that changed from the base of the PR and between 66eeee4 and 4ee1e53.

📒 Files selected for processing (3)
  • frontend/common/utils/multivariate.ts
  • frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx
  • frontend/web/components/mv/VariationOptions.tsx

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.

Comment thread frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx Outdated
Comment on lines +85 to +95
<div
data-test='select-unmatched-override'
onMouseDown={(e) => {
e.stopPropagation()
setVariations([])
setValue?.(unmatchedOverride.value)
}}
className={`btn-radio ml-2 ${
unmatchedOverride.selected ? 'btn-radio-on' : ''
}`}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the unmatched override selector keyboard accessible.

This interactive div cannot receive keyboard focus or keyboard activation. Keyboard users cannot select the unmatched override, so they cannot restore its preserved value.

Use a native button or implement radio semantics, focus handling, aria-checked, and Enter/Space activation.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx (1)

446-448: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the warning visible while the unmatched row is latched.

Line 446 checks unmatchedOverrideSelected. This becomes false after the user selects the control value or a variation, while unmatchedOverride remains visible. Check unmatchedOverride so the warning remains with the read-only unmatched row until save.

Proposed fix
- {unmatchedOverrideSelected && (
+ {unmatchedOverride && (
    <WarningMessage warningMessage="This identity override contains a value that is not one of this flag's variations. We recommend changing it." />
  )}

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f1806800-fcc3-4a8f-b77d-71ddd6509499

📥 Commits

Reviewing files that changed from the base of the PR and between 4ee1e53 and 630b70d.

📒 Files selected for processing (3)
  • frontend/common/utils/__tests__/multivariate.test.ts
  • frontend/common/utils/multivariate.ts
  • frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.

Comment on lines +304 to +313
// Held in a ref rather than read once on mount, as the feature state loads
// async — there is no single render at which the override is known to be
// there. See resolveUnmatchedOverride for why presence outlives selection.
const latchedOverrideValue = useRef<FlagsmithValue | undefined>(undefined)
const unmatchedOverride = resolveUnmatchedOverride({
isSelected: unmatchedOverrideSelected,
latchedValue: latchedOverrideValue.current,
overrideValue: featureState.feature_state_value ?? null,
})
latchedOverrideValue.current = unmatchedOverride?.value

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

target="frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx"

# Inspect render-phase ref writes and commit-phase synchronisation.
rg -n -C 5 'latchedOverrideValue\.current|useEffect\(' "$target"

# Inspect configured React Hooks or React Compiler rules.
fd -a -t f 'eslint.config.*' . | while IFS= read -r file; do
  rg -n -C 3 'react-hooks|react-compiler|refs' "$file" || true
done

Repository: Flagsmith/flagsmith

Length of output: 1203


🏁 Script executed:

#!/bin/bash
set -euo pipefail

target="frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx"

printf '%s\n' '--- imports and relevant component body ---'
sed -n '1,190p' "$target"
sed -n '280,470p' "$target"

printf '%s\n' '--- resolver definition and call sites ---'
rg -n -C 12 'resolveUnmatchedOverride|unmatchedOverrideSelected|latchedOverrideValue' frontend common 2>/dev/null || true

printf '%s\n' '--- hook and compiler configuration ---'
fd -a -t f . | rg '(^|/)(eslint|biome|oxlint|package\.json|tsconfig).*' |
  while IFS= read -r file; do
    rg -n -C 3 'react-hooks|react-compiler|refs|purity|set-state-in-render' "$file" || true
  done

Repository: Flagsmith/flagsmith

Length of output: 35555


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- React versions and ref-write patterns ---'
rg -n '"react"|"react-dom"|"eslint-plugin-react-hooks"|refs|set-state-in-render' \
  package.json frontend/**/package.json .eslintrc* eslint.config.* 2>/dev/null || true
rg -n -U 'useRef<[^;]+>\([^;]*\)[\s\S]{0,500}\.current\s*=' frontend common \
  -g '*.{ts,tsx,js,jsx}' 2>/dev/null | head -200 || true

printf '%s\n' '--- resolver implementation and tests ---'
sed -n '1,75p' frontend/common/utils/multivariate.ts
sed -n '119,198p' frontend/common/utils/__tests__/multivariate.test.ts

printf '%s\n' '--- deterministic render/effect model ---'
python3 - <<'PY'
UNSET = object()

def resolve(is_selected, latched, override):
    value = override if latched is UNSET and is_selected else latched
    return None if value is UNSET else (is_selected, value)

def render_phase(sequence):
    latched = UNSET
    committed = None
    for label, selected, override, commit in sequence:
        result = resolve(selected, latched, override)
        latched = result[1] if result is not None else UNSET
        if commit:
            committed = result
        print(label, 'committed' if commit else 'aborted',
              'result=', result, 'ref=', latched)
    print('render-phase final committed=', committed, 'ref=', latched)

def effect_phase(sequence):
    latched = UNSET
    committed = None
    pending_effect = None
    for label, selected, override, commit in sequence:
        result = resolve(selected, latched, override)
        pending_effect = None if result is None else result[1]
        if commit:
            committed = result
            latched = pending_effect
        print(label, 'committed' if commit else 'aborted',
              'result=', result, 'ref=', latched,
              'effect=', pending_effect if commit else 'not run')
    print('effect-phase final committed=', committed, 'ref=', latched)

sequence = [
    ('async data absent', False, None, True),
    ('override appears', True, 'OLD', False),
    ('later committed data', True, 'NEW', True),
    ('user selects variation', False, 'NEW', True),
]
print('Case: an abandoned render occurs before the first committed override render')
render_phase(sequence)
effect_phase(sequence)
PY

Repository: Flagsmith/flagsmith

Length of output: 23111


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- package React versions ---'
rg -n -C 2 '"react"|"react-dom"|"react-test-renderer"' package.json frontend -g 'package.json' 2>/dev/null || true

printf '%s\n' '--- corrected concurrent-render model ---'
python3 - <<'PY'
UNSET = object()

def resolve(is_selected, latched, override):
    value = override if latched is UNSET and is_selected else latched
    return None if value is UNSET else (is_selected, value)

def render_latch(sequence):
    latched = UNSET
    committed_result = None
    for label, selected, override, commits in sequence:
        result = resolve(selected, latched, override)
        # This is the implementation under review: the write occurs even if
        # this render is later abandoned.
        latched = UNSET if result is None else result[1]
        if commits:
            committed_result = result
        print(f'{label}: {"commit" if commits else "abort"}, '
              f'result={result}, ref={None if latched is UNSET else latched!r}')
    return committed_result, latched

def effect_latch(sequence):
    latched = UNSET
    committed_result = None
    for label, selected, override, commits in sequence:
        result = resolve(selected, latched, override)
        # An effect runs only for a committed render and writes undefined when
        # the result is absent, preserving the unset sentinel.
        if commits:
            committed_result = result
            latched = UNSET if result is None else result[1]
        print(f'{label}: {"commit" if commits else "abort"}, '
              f'result={result}, ref={None if latched is UNSET else latched!r}')
    return committed_result, latched

sequence = [
    ('initial data absent', False, None, True),
    ('abandoned render sees OLD override', True, 'OLD', False),
    ('committed render sees NEW override', True, 'NEW', True),
    ('user selects a variation', False, 'NEW', True),
]

print('render-phase write:')
print('final:', render_latch(sequence))
print('commit-phase write:')
print('final:', effect_latch(sequence))
PY

Repository: Flagsmith/flagsmith

Length of output: 1761


Move the ref write out of render.

Line 313 mutates latchedOverrideValue.current during rendering. An abandoned concurrent render can leave this mutation in place. A later committed render can then show a stale unmatched override. Update the latch in a commit-phase effect.

Proposed fix
 const unmatchedOverride = resolveUnmatchedOverride({
   isSelected: unmatchedOverrideSelected,
   latchedValue: latchedOverrideValue.current,
   overrideValue: featureState.feature_state_value ?? null,
 })
-latchedOverrideValue.current = unmatchedOverride?.value
+useEffect(() => {
+  latchedOverrideValue.current = unmatchedOverride?.value
+}, [unmatchedOverride?.value])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Held in a ref rather than read once on mount, as the feature state loads
// async — there is no single render at which the override is known to be
// there. See resolveUnmatchedOverride for why presence outlives selection.
const latchedOverrideValue = useRef<FlagsmithValue | undefined>(undefined)
const unmatchedOverride = resolveUnmatchedOverride({
isSelected: unmatchedOverrideSelected,
latchedValue: latchedOverrideValue.current,
overrideValue: featureState.feature_state_value ?? null,
})
latchedOverrideValue.current = unmatchedOverride?.value
// Held in a ref rather than read once on mount, as the feature state loads
// async — there is no single render at which the override is known to be
// there. See resolveUnmatchedOverride for why presence outlives selection.
const latchedOverrideValue = useRef<FlagsmithValue | undefined>(undefined)
const unmatchedOverride = resolveUnmatchedOverride({
isSelected: unmatchedOverrideSelected,
latchedValue: latchedOverrideValue.current,
overrideValue: featureState.feature_state_value ?? null,
})
useEffect(() => {
latchedOverrideValue.current = unmatchedOverride?.value
}, [unmatchedOverride?.value])

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

front-end Issue related to the React Front End Dashboard

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multivariate override editor hides the identity's override value (shows the flag's control value instead)

3 participants