Skip to content

fix(appMode): unify precedence between boot and resolver; session tuple wins unconditionally - #31369

Merged
chirag-madlani merged 6 commits into
mainfrom
fix/appmode-unified-precedence
Aug 13, 2026
Merged

chirag-madlani merged 6 commits into
mainfrom
fix/appmode-unified-precedence

Conversation

@chirag-madlani

Copy link
Copy Markdown
Collaborator

Summary

The AppMode feature had two contradictory precedence chains for the same fallback logic — the async runtime resolver used one, the boot-time pre-session write used another — and the runtime resolver silently clobbered a valid session tuple whenever the persona changed. This PR fixes both.

Unified precedence

`resolveEffectiveAppMode(userPref, personaMode, appDefault)` in `useAppMode.ts` is the single source of truth for the "no session tuple, no fresh hint" case:

userPref -> personaMode -> appDefault -> DEFAULT_APP_MODE

Both entry points use it:

  • `AuthProvider.hydrateAndResolveAppMode` (boot-time pre-session write, passes `null` for persona)
  • `useResolvedAppMode`'s async `candidate` (passes the resolved persona once its docStore doc settles)

Before this PR the resolver placed persona BEFORE userPref in its `candidate` chain (`useResolvedAppMode.ts:236-240`), which meant an admin persona edit silently overrode a user's server-side "remember" — while the boot-time write kept respecting userPref. The two writes disagreed within one session.

Session tuple wins unconditionally

`useResolvedAppMode.ts:204` previously kept the session only when `session.personaAppMode === currentPersonaAppMode`. If persona had changed since the tuple was written, the resolver fell through and overwrote the user's manual in-tab switch. Rung 1 of the target precedence is "manual in-tab switch wins" — an admin persona edit must not silently revert a user's right-now click. Drop the persona-match check: `if (validSession) return;`.

New precedence (final, top wins)

  1. Session tuple (manual in-tab switch) — sessionStorage, per-tab
  2. Fresh cross-tab hint (`omAppModeHint`, TTL-gated) — localStorage
  3. User pref (`user_preferences.appMode`, server-persisted "remember")
  4. Persona (docStore `personaPreferences[].appMode`)
  5. Tenant default (`appConfiguration.defaultAppMode`)
  6. `DEFAULT_APP_MODE` constant

Boot-time write bails on rungs 1 & 2 (via my earlier #31316 + #31335 guards, unchanged here). The remaining rungs (3 → 6) are what `resolveEffectiveAppMode` encodes.

Tests

  • Rewrote `useResolvedAppMode.test.ts:248` ("persona overrides session when personaAppMode differs from tuple's snapshot") into a regression guard for the opposite: session survives persona change (rung 1).
  • Added three new tests covering rung transitions:
    • user pref beats persona
    • persona beats tenant default
    • tenant default beats DEFAULT
  • `beforeEach` now resets `setAppDefaultMode(null)` so tenant-default tests don't leak into siblings.

All 69 tests pass (`yarn jest --testPathPattern='hooks/useAppMode|hooks/useResolvedAppMode'`).

Not in scope

`resolveInitialAppMode` (`useAppMode.ts:346`) still considers only session/hint/userPref — no persona, no tenant default. It's used by the post-login redirect only to pick a landing route, and the resolver corrects a moment later. Fixing that helper is a separate cleanup.

Test plan

  • Fresh login, no session, no hint, no server pref, no persona, tenant AI → boots into AI (rung 5).
  • Fresh login with server `appMode: ai`, persona says Classic → AI wins (rung 3).
  • User manually toggles to Classic in a tab where persona=AI. Admin edits persona to Classic then back to AI. Reload → still Classic (rung 1 regression).
  • Cmd+click new tab from an AI tab → new tab boots AI (rung 2).
  • Close all tabs → reopen without "remember" → falls to rung 3/4/5/6 correctly.

🤖 Generated with Claude Code

…le wins unconditionally

The AppMode feature had two contradictory precedence chains — the
async runtime resolver used one, the boot-time pre-session write used
another — and the runtime resolver silently clobbered a valid session
tuple whenever the persona changed. This PR fixes both.

## Unified precedence

`resolveEffectiveAppMode(userPref, personaMode, appDefault)` in
`useAppMode.ts` is now the single source of truth for the "no session
tuple, no fresh hint" case:

    userPref -> personaMode -> appDefault -> DEFAULT_APP_MODE

Both entry points use it:
  * `AuthProvider.hydrateAndResolveAppMode` (boot-time pre-session
    write — passes `null` for persona, since persona isn't known
    synchronously).
  * `useResolvedAppMode`'s async `candidate` (passes the resolved
    persona once its docStore doc has settled).

Before this PR the resolver placed persona BEFORE userPref in its
`candidate` chain (`useResolvedAppMode.ts:236-240`, deleted here),
which meant an admin persona edit silently overrode a user's server-
side "remember" choice for the resolver run — while the boot-time
write kept respecting userPref. The two writes disagreed within one
session.

## Session tuple wins unconditionally

`useResolvedAppMode.ts:204` previously kept the session only when
`session.personaAppMode === currentPersonaAppMode`. If persona had
changed since the tuple was written, the resolver fell through and
overwrote the user's manual in-tab switch. Rung 1 of the target
precedence is "manual in-tab switch wins" — an admin persona edit
must not silently revert a user's right-now click. Drop the persona-
match check: `if (validSession) return;`.

## New precedence (final)

Top wins:

  1. Session tuple (manual in-tab switch) — sessionStorage, per-tab.
  2. Fresh cross-tab hint (`omAppModeHint`, TTL-gated) — localStorage.
  3. User pref (`user_preferences.appMode`, server-persisted "remember").
  4. Persona (docStore `personaPreferences[].appMode`).
  5. Tenant default (`appConfiguration.defaultAppMode`).
  6. `DEFAULT_APP_MODE` constant.

Boot-time write (`hydrateAndResolveAppMode`) bails on rungs 1 & 2 (my
earlier #31316 + #31335 guards, unchanged here). The remaining rungs
(3 → 6) are what `resolveEffectiveAppMode` encodes.

## Tests

  * Rewrote `useResolvedAppMode.test.ts:248` ("persona overrides
    session when personaAppMode differs from tuple's snapshot") into
    a regression guard for the opposite: session survives persona
    change (rung 1).
  * Added three new tests covering rung transitions:
    - user pref beats persona
    - persona beats tenant default
    - tenant default beats DEFAULT
  * `beforeEach` now resets `setAppDefaultMode(null)` so tenant-
    default tests don't leak into siblings.

All 69 existing + new tests pass.

## Not in scope

`resolveInitialAppMode` (`useAppMode.ts:346`) still considers only
session/hint/userPref — no persona, no tenant default. It's used by
the post-login redirect ONLY to pick a landing route, and the resolver
corrects a moment later. Fixing that helper is a separate cleanup.
Copilot AI lite review requested due to automatic review settings August 12, 2026 05:49

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown
Contributor

❌ PR checklist incomplete

This PR cannot be merged until the following are addressed on its linked issue:

  • No GitHub issue is linked. Link an issue in the Development section of the PR (or add Fixes #12345 to the description). For a same-org cross-repo issue, add Fixes open-metadata/<repo>#123 to the description.

The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically.

Maintainers can bypass this check by adding the skip-pr-checks label.

@github-actions github-actions Bot added safe to test Add this label to run secure Github workflows on PRs UI UI specific issues labels Aug 12, 2026
// Shared with `resolveEffectiveAppMode` in `useAppMode.ts` so the
// async resolver here and the boot-time write in `AuthProvider`
// encode exactly the same policy.
const candidate = resolveEffectiveAppMode(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Edge Case: Install gate only tests the top candidate, may strand user

With the reorder, candidate is now userPref first. The install gate (if (candidate !== DEFAULT_APP_MODE && !isModeRegistered(candidate)) return;) checks only that single top-priority candidate and never falls through to the next registered rung. So if a user's server-remembered appMode points at a plugin that has since been uninstalled (not in registeredRoutes), the resolver waits indefinitely and never applies the persona/tenant-default fallback, leaving the tab on whatever the boot-time write picked. Consider resolving down the chain to the first registered candidate rather than gating on only the top one. This is a pre-existing gate limitation that the reorder now routes through userPref, so severity is minor/speculative.

Was this helpful? React with 👍 / 👎

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ UI Checkstyle passed — lint findings in changed files

🔍 ESLint findings in this PR's files — 0 error(s), 17 warning(s)

Errors block the build. Warnings do not yet — they are rules whose backlog is still
being worked down, listed so this PR does not add to it. See docs/ui-code-quality-gate.md.

0 error(s), 17 warning(s) across 3 changed file(s).

Count Rule
9 react-hooks/exhaustive-deps
3 sonarjs/no-nested-functions
3 sonarjs/cyclomatic-complexity
1 sonarjs/expression-complexity
1 sonarjs/cognitive-complexity
All findings
Location Rule Message
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:280:9 react-hooks/exhaustive-deps The 'onLoginHandler' function makes the dependencies of useMemo Hook (at line 918) change on every render. Move it inside the useMemo callback. Alternatively, w
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:357:6 react-hooks/exhaustive-deps React Hook useCallback has missing dependencies: 'navigate', 'setApplicationLoading', 'setCurrentUser', and 'setIsAuthenticated'. Either include them or remove
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:415:9 react-hooks/exhaustive-deps The 'resetUserDetails' function makes the dependencies of useMemo Hook (at line 918) change on every render. To fix this, wrap the definition of 'resetUserDetai
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:497:6 react-hooks/exhaustive-deps React Hook useEffect has a missing dependency: 'startTokenExpiryTimer'. Either include it or remove the dependency array.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:540:6 react-hooks/exhaustive-deps React Hook useEffect has a missing dependency: 'startTokenExpiryTimer'. Either include it or remove the dependency array.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:550:9 react-hooks/exhaustive-deps The 'handleFailedLogin' function makes the dependencies of useMemo Hook (at line 918) change on every render. Move it inside the useMemo callback. Alternatively
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:608:5 react-hooks/exhaustive-deps React Hook useCallback has missing dependencies: 'authConfig?.provider', 'handledVerifiedUser', 'navigate', 'resetUserDetails', and 'startTokenExpiryTimer'. Eit
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:653:9 react-hooks/exhaustive-deps The 'initializeAxiosInterceptors' function makes the dependencies of useMemo Hook (at line 918) change on every render. To fix this, wrap the definition of 'ini
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:721:67 sonarjs/no-nested-functions Refactor this code to not nest functions more than 4 levels deep.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:740:37 sonarjs/no-nested-functions Refactor this code to not nest functions more than 4 levels deep.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:749:27 sonarjs/no-nested-functions Refactor this code to not nest functions more than 4 levels deep.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:818:30 sonarjs/cyclomatic-complexity {"message":"Function has a complexity of 17 which is greater than 10 authorized.","cost":7,"secondaryLocations":[{"line":818,"column":29,"endLine":818,"endColum
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:907:6 react-hooks/exhaustive-deps React Hook useEffect has missing dependencies: 'cleanup', 'fetchAuthConfig', 'initializeAxiosInterceptors', and 'startTokenExpiryTimer'. Either include them or
🟡 src/hooks/useAppMode.ts:75:47 sonarjs/cyclomatic-complexity {"message":"Function has a complexity of 12 which is greater than 10 authorized.","cost":2,"secondaryLocations":[{"line":75,"column":46,"endLine":75,"endColumn"
🟡 src/hooks/useAppMode.ts:165:7 sonarjs/expression-complexity Reduce the number of conditional operators (5) used in the expression (maximum allowed 3).
🟡 src/hooks/useResolvedAppMode.ts:180:16 sonarjs/cognitive-complexity Refactor this function to reduce its Cognitive Complexity from 22 to the 15 allowed.
🟡 src/hooks/useResolvedAppMode.ts:180:16 sonarjs/cyclomatic-complexity {"message":"Function has a complexity of 22 which is greater than 10 authorized.","cost":12,"secondaryLocations":[{"line":180,"column":15,"endLine":180,"endColu

Fix locally (fast - only checks files changed in this branch):

make ui-checkstyle-changed

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Jest test Coverage

UI tests summary

Lines Statements Branches Functions
Coverage: 66%
66.57% (79230/119006) 50.83% (48132/94675) 51.86% (14446/27851)

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

✅ Playwright Results — workflow succeeded

Validated commit f7b88d484f7921f068a6c0af6983e7d744f7b95a in Playwright run 31675326440, attempt 1.

✅ 795 passed · ❌ 0 failed · 🟡 1 flaky · ⏭️ 0 skipped · 🧰 0 lifecycle flaky

Performance

Blocking targets: ✅ met · Optimization targets: 🟡 in progress

Shard-job maxima below are not the full workflow wall time; the linked run includes build, fixture, planning, and reporting.

🕒 Full workflow signal wall (to summary) 56m 52s

⏱️ Max setup 3m 5s · max shard execution 18m 44s · max shard-job elapsed before upload 22m 21s · reporting 5s

🌐 221.42 requests/attempt · 2.67 app boots/UI scenario · 5.16% common-shard skew

Optimization targets still in progress:

  • Browser traffic was 221.42 requests per attempt (convergence target: fewer than 200).
  • Application boot ratio was 2.67 per UI scenario (2498 boots / 936 scenarios; convergence target: at most 1).
Shard Passed Failed Flaky Skipped Lifecycle failed Lifecycle flaky
✅ Shard chromium-01 145 0 0 0 0 0
🟡 Shard chromium-02 164 0 1 0 0 0
✅ Shard chromium-03 143 0 0 0 0 0
✅ Shard chromium-04 175 0 0 0 0 0
✅ Shard data-asset-rules-01 61 0 0 0 0 0
✅ Shard domain-isolation-01 14 0 0 0 0 0
✅ Shard global-state-01 34 0 0 0 0 0
✅ Shard import-export-01 17 0 0 0 0 0
✅ Shard ingestion-01 1 0 0 0 0 0
✅ Shard reindex-01 2 0 0 0 0 0
✅ Shard search-01 10 0 0 0 0 0
✅ Shard search-rbac-01 29 0 0 0 0 0
🟡 1 flaky test(s) (passed on retry)
  • Pages/Entity.spec.ts › Domain Propagation (shard chromium-02, 1 retry)

📦 Download artifacts

How to debug locally
# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip    # view trace

…side pref

Under the unified precedence (userPref -> persona -> appDefault ->
DEFAULT), `hydrateAndResolveAppMode` was writing to the session tuple
at boot with `personaMode = null` (persona isn't known
synchronously). If the user had no userPref but DID have a
defaultPersona, the boot write computed the wrong candidate — falling
through to `appDefault` or `DEFAULT_APP_MODE` instead of persona's
opinion. Once the tuple was written, `useResolvedAppMode`'s
`if (validSession) return;` guard kept the wrong mode; the persona-
doc fetch that ran a moment later never got to influence the write.

Symptom: `AppModePrecedence.spec.ts:242` (rung 4 persona beats rung 5
tenant default) — fresh user, persona=AI, tenant default=Classic,
no user pref. Boot wrote 'default' (falling through to appDefault
since persona wasn't known and userPref was null); resolver's session
guard locked it. Expected 'ai', got 'default'.

Fix: third guard in `hydrateAndResolveAppMode` — skip the boot write
when `userPref === null && user.defaultPersona`. Defer to
`useResolvedAppMode`, which fetches the persona doc and can compute
the correct candidate against the full chain. When userPref IS set it
wins over persona under the unified precedence, so writing at boot
remains safe in that branch.

All 69 existing Jest tests still pass.
Copilot AI review requested due to automatic review settings August 12, 2026 08:29

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 12, 2026 18:44

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 13, 2026 06:49

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@sonarqubecloud

Copy link
Copy Markdown

@gitar-bot

gitar-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown
Code Review 👍 Approved with suggestions 0 resolved / 1 findings

Unifies the AppMode precedence between boot and runtime resolver to ensure manual session selections win unconditionally over persona changes. Consider addressing the install gate check which currently only tests the top candidate.

💡 Edge Case: Install gate only tests the top candidate, may strand user

📄 openmetadata-ui/src/main/resources/ui/src/hooks/useResolvedAppMode.ts:258-269

With the reorder, candidate is now userPref first. The install gate (if (candidate !== DEFAULT_APP_MODE && !isModeRegistered(candidate)) return;) checks only that single top-priority candidate and never falls through to the next registered rung. So if a user's server-remembered appMode points at a plugin that has since been uninstalled (not in registeredRoutes), the resolver waits indefinitely and never applies the persona/tenant-default fallback, leaving the tab on whatever the boot-time write picked. Consider resolving down the chain to the first registered candidate rather than gating on only the top one. This is a pre-existing gate limitation that the reorder now routes through userPref, so severity is minor/speculative.

🤖 Prompt for agents
Code Review: Unifies the AppMode precedence between boot and runtime resolver to ensure manual session selections win unconditionally over persona changes. Consider addressing the install gate check which currently only tests the top candidate.

1. 💡 Edge Case: Install gate only tests the top candidate, may strand user
   Files: openmetadata-ui/src/main/resources/ui/src/hooks/useResolvedAppMode.ts:258-269

   With the reorder, `candidate` is now `userPref` first. The install gate (`if (candidate !== DEFAULT_APP_MODE && !isModeRegistered(candidate)) return;`) checks only that single top-priority candidate and never falls through to the next registered rung. So if a user's server-remembered `appMode` points at a plugin that has since been uninstalled (not in `registeredRoutes`), the resolver waits indefinitely and never applies the persona/tenant-default fallback, leaving the tab on whatever the boot-time write picked. Consider resolving down the chain to the first *registered* candidate rather than gating on only the top one. This is a pre-existing gate limitation that the reorder now routes through `userPref`, so severity is minor/speculative.

Options

Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Powered by Gitar — free for open source

This branch was previously deployed

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

Labels

safe to test Add this label to run secure Github workflows on PRs UI UI specific issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants