Skip to content

fix(ui): visibility handler no longer refreshes when there's nothing to refresh - #31819

Merged
chirag-madlani merged 3 commits into
mainfrom
hotfix/visibility-refresh-guard
Aug 20, 2026
Merged

chirag-madlani merged 3 commits into
mainfrom
hotfix/visibility-refresh-guard

Conversation

@chirag-madlani

@chirag-madlani chirag-madlani commented Aug 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Stops `tokenService.refreshToken()` from firing on every tab focus when there's nothing to refresh — the current behavior on main hits the IdP on every visibility change while the user is signed out (or holds an opaque token), which surfaces as `/auth/refresh` (or a hidden `signinSilent` iframe) calls in the Network tab and unnecessary IdP-side load.

Root cause

`handleVisibilityChange` used the shorthand:
```ts
if (isExpired || timeoutExpiry <= 0) {
await tokenService.current?.refreshToken();
}
```

`extractDetailsFromToken` returns `{isExpired:true, timeoutExpiry:0}` for empty tokens (signed-out) and `{isExpired:false, timeoutExpiry:0}` for tokens without an `exp` claim. Both fell into the refresh branch.

The fix

  • No token in storage → early return. No IdP call.
  • Expired token → refresh (unchanged).
  • Missing / non-positive `exp` → early return; the next real 401 drives the refresh via the axios interceptor.
  • Near-expiry (within the 60s pre-expiry buffer, `exp` confirmed valid) → refresh.
  • Otherwise → reschedule the proactive timer.

Also drops the noisy `[VisibilityHandler]` `console.debug` that fired on every focus and made real refresh calls hard to spot in the console.

Relationship to the AuthCoordinator refactor PR

The AuthCoordinator refactor PR (#31675) already carries this fix as part of the larger cleanup + full Jest coverage (`AuthCoordinator.test.ts › tab visibility gating`). Shipping this as a targeted hotfix on `main` unblocks users on the current release while the refactor is under review.

Test plan

  • `npx tsc --noEmit` — no new errors (pre-existing MSAL / AuthProviderUtil issues untouched)
  • `yarn test --testPathPattern=components/Auth/AuthProviders` — 28/28 pass
  • `yarn lint:base` on `AuthProvider.tsx` — 0 errors
  • Manual: sign out → toggle tabs → verify Network tab shows no `/auth/refresh` call
  • Manual: sign in with a fresh token (>1min lifetime) → toggle tabs → verify no `/auth/refresh` call
  • Manual: let token drift into last 60s of lifetime → toggle tabs → verify one `/auth/refresh` call

🤖 Generated with Claude Code

Greptile Summary

The PR prevents the tab-visibility handler from initiating token renewal when storage has no usable expiring token.

  • Returns immediately for absent, opaque, undecodable, or non-positive-expiry tokens.
  • Preserves renewal for expired and near-expiry JWTs and reschedules the proactive timer for fresh JWTs.
  • Adds regression coverage for the visibility-handler branches and removes focus-time debug logging.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx Reorders token validation so unusable expiry metadata is rejected before expired and near-expiry refresh branches, fully addressing the prior opaque-token finding.
openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.test.tsx Adds focused regression coverage for absent, opaque, malformed, fresh, expired, and near-expiry token states.

Reviews (3): Last reviewed commit: "fix(ui): visibility handler skips refres..." | Re-trigger Greptile

Context used:

…to refresh

Three cases were quietly firing tokenService.refreshToken() on every
tab focus:

1. **No token in storage** (user on /signin, or just logged out).
   The old check hit extractDetailsFromToken('') → isExpired:true →
   refresh call. On OIDC that meant a hidden signinSilent iframe to
   the IdP on every focus of an unauthenticated tab.
2. **Token has no `exp` claim** (opaque/non-JWT/spec-violating
   id_token). extractDetailsFromToken returns timeoutExpiry:0 in the
   isNil(exp) branch — same value it uses for near-expiry — and the
   old shorthand `timeoutExpiry <= 0` treated both the same.
3. Legit near-expiry (< 60s to exp) — unchanged; still refreshes.

Now:
- No token → early return.
- Expired → refresh (unchanged).
- Missing/non-positive exp → early return; next real 401 drives it.
- timeoutExpiry <= 0 (near-expiry, exp confirmed valid) → refresh.
- Otherwise → reschedule the proactive timer.

Also drops the noisy `[VisibilityHandler]` console.debug that fired
on every focus regardless of outcome.
Copilot AI lite review requested due to automatic review settings August 20, 2026 08: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.

@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 20, 2026
Comment on lines +564 to 567
} catch {
// Storage read errors fall through: the next real 401 will drive
// the refresh via the axios interceptor.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Quality: Empty catch silently swallows refreshToken errors

The refactor replaced the previous catch (error) { console.error(...) } with a fully empty catch {}. Beyond the intended removal of the noisy debug log, this now also silently discards any error thrown by tokenService.current?.refreshToken() (e.g. a failing silent-renew), leaving no trace for debugging genuine refresh failures. Consider retaining a low-noise console.debug/console.error of the caught error so real failures remain diagnosable while still fixing the per-focus IdP noise.

Was this helpful? React with 👍 / 👎

@github-actions

github-actions Bot commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ UI Checkstyle passed — lint findings in changed files

🔍 ESLint findings in this PR's files — 0 error(s), 16 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), 16 warning(s) across 2 changed file(s).

Count Rule
8 react-hooks/exhaustive-deps
3 sonarjs/no-nested-functions
2 openmetadata-imports/no-internal-barrel-imports
2 sonarjs/cyclomatic-complexity
1 openmetadata-imports/no-api-calls-in-iteration
All findings
Location Rule Message
🟡 src/components/Auth/AuthProviders/AuthProvider.test.tsx:17:1 openmetadata-imports/no-internal-barrel-imports Import the internal module directly instead of its index barrel so unrelated siblings do not enter the bundle graph.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:80:1 openmetadata-imports/no-internal-barrel-imports Import the internal module directly instead of its index barrel so unrelated siblings do not enter the bundle graph.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:280:9 react-hooks/exhaustive-deps The 'onLoginHandler' function makes the dependencies of useMemo Hook (at line 959) 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 959) change on every render. To fix this, wrap the definition of 'resetUserDetai
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:515:45 sonarjs/cyclomatic-complexity {"message":"Function has a complexity of 12 which is greater than 10 authorized.","cost":2,"secondaryLocations":[{"line":515,"column":44,"endLine":515,"endColum
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:577:6 react-hooks/exhaustive-deps React Hook useEffect has missing dependencies: 'getLoggedInUserDetails' and 'startTokenExpiryTimer'. Either include them or remove the dependency array.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:587:9 react-hooks/exhaustive-deps The 'handleFailedLogin' function makes the dependencies of useMemo Hook (at line 959) change on every render. Move it inside the useMemo callback. Alternatively
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:645: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:690:9 react-hooks/exhaustive-deps The 'initializeAxiosInterceptors' function makes the dependencies of useMemo Hook (at line 959) change on every render. To fix this, wrap the definition of 'ini
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:758:67 sonarjs/no-nested-functions Refactor this code to not nest functions more than 4 levels deep.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:765:23 openmetadata-imports/no-api-calls-in-iteration Avoid issuing one API request per item. Fetch at the data owner, use a bulk endpoint, or use useQueries with an intentional concurrency policy.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:777:37 sonarjs/no-nested-functions Refactor this code to not nest functions more than 4 levels deep.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:786:27 sonarjs/no-nested-functions Refactor this code to not nest functions more than 4 levels deep.
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:855:30 sonarjs/cyclomatic-complexity {"message":"Function has a complexity of 17 which is greater than 10 authorized.","cost":7,"secondaryLocations":[{"line":855,"column":29,"endLine":855,"endColum
🟡 src/components/Auth/AuthProviders/AuthProvider.tsx:948:6 react-hooks/exhaustive-deps React Hook useEffect has missing dependencies: 'cleanup', 'fetchAuthConfig', 'initializeAxiosInterceptors', and 'startTokenExpiryTimer'. Either include them or

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

make ui-checkstyle-changed

@github-actions

github-actions Bot commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Jest test Coverage

UI tests summary

Lines Statements Branches Functions
Coverage: 66%
66.98% (80276/119843) 51.4% (49106/95535) 52.37% (14671/28010)

karanh37
karanh37 previously approved these changes Aug 20, 2026
@github-actions

github-actions Bot commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

✅ Playwright Results — workflow succeeded

Validated commit 422793386c81bdeaa91784d2122f734db4292197 in Playwright run 32356437075, attempt 1.

✅ 320 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) 51m 18s

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

🌐 216.03 requests/attempt · 2.22 app boots/UI scenario · 4.99% common-shard skew

Optimization targets still in progress:

  • Browser traffic was 216.03 requests per attempt (convergence target: fewer than 200).
  • Application boot ratio was 2.22 per UI scenario (977 boots / 441 scenarios; convergence target: at most 1).
Shard Passed Failed Flaky Skipped Lifecycle failed Lifecycle flaky
🟡 Shard chromium-01 141 0 1 0 0 0
✅ Shard chromium-02 133 0 0 0 0 0
✅ Shard import-export-01 17 0 0 0 0 0
✅ Shard search-rbac-01 29 0 0 0 0 0
🟡 1 flaky test(s) (passed on retry)
  • Pages/DataMarketplace.spec.ts › Search with no results shows empty state (shard chromium-01, 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

- AuthProvider.tsx: prettier reflow on the destructure of
  extractDetailsFromToken (checkstyle fix flagged by CI on this PR).
- AuthProvider.test.tsx: five new tests under "AuthProvider visibility
  handler" pinning the guards the hotfix adds so they can't regress:

  * no token in storage → NO refreshToken() call
  * token with no exp claim → NO refreshToken() call
  * fresh token (>60s of headroom) → NO refreshToken() call
  * expired token → exactly one refreshToken() call
  * near-expiry (<60s of headroom) → exactly one refreshToken() call

Adds shared mocks for getOidcToken and extractDetailsFromToken (with
requireActual spreads so the pre-existing 14 tests still run untouched).
Copilot AI review requested due to automatic review settings August 20, 2026 09:48

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.

Greptile P1 on #31819: when jwt-decode throws (opaque / non-JWT /
malformed token), extractDetailsFromToken falls through to
{exp: 0, isExpired: true, timeoutExpiry: 0}. The previous ordering hit
the `isExpired` branch first and fired refreshToken() before the
invalid-exp guard could stop it — so opaque tokens still triggered an
IdP request on every tab focus.

Move the invalid-exp guard above the isExpired branch so both jwt-
decode-throws AND isNil(exp) paths short-circuit the same way. The
next real 401 still drives the refresh via the interceptor.

Adds a regression test covering the jwt-decode-throws shape
(exp: 0, isExpired: true).
Copilot AI review requested due to automatic review settings August 20, 2026 09:56

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

@chirag-madlani
chirag-madlani added this pull request to the merge queue Aug 20, 2026
chirag-madlani pushed a commit that referenced this pull request Aug 20, 2026
Same ordering fix Greptile P1 flagged on the sibling hotfix PR
(#31819). extractDetailsFromToken returns
{exp: 0, isExpired: true, timeoutExpiry: 0} when jwt-decode throws
(opaque / non-JWT / malformed token). Previously the isExpired branch
fired ensureFreshToken() before the invalid-exp guard could stop it —
so opaque tokens triggered an IdP request on every tab focus.

Guard now runs BEFORE isExpired so both jwt-decode-throws AND
isNil(exp) paths short-circuit the same way. The next real 401 still
drives a refresh via the axios interceptor.

Adds a Jest test with the {exp:0, isExpired:true} shape so this
ordering can't get reversed, and updates the SSO flow matrix to spell
out both branches.
Merged via the queue into main with commit 935dead Aug 20, 2026
83 of 86 checks passed
@chirag-madlani
chirag-madlani deleted the hotfix/visibility-refresh-guard branch August 20, 2026 17:51
chirag-madlani added a commit that referenced this pull request Aug 20, 2026
…to refresh (#31819)

* fix(ui): visibility handler no longer refreshes when there's nothing to refresh

Three cases were quietly firing tokenService.refreshToken() on every
tab focus:

1. **No token in storage** (user on /signin, or just logged out).
   The old check hit extractDetailsFromToken('') → isExpired:true →
   refresh call. On OIDC that meant a hidden signinSilent iframe to
   the IdP on every focus of an unauthenticated tab.
2. **Token has no `exp` claim** (opaque/non-JWT/spec-violating
   id_token). extractDetailsFromToken returns timeoutExpiry:0 in the
   isNil(exp) branch — same value it uses for near-expiry — and the
   old shorthand `timeoutExpiry <= 0` treated both the same.
3. Legit near-expiry (< 60s to exp) — unchanged; still refreshes.

Now:
- No token → early return.
- Expired → refresh (unchanged).
- Missing/non-positive exp → early return; next real 401 drives it.
- timeoutExpiry <= 0 (near-expiry, exp confirmed valid) → refresh.
- Otherwise → reschedule the proactive timer.

Also drops the noisy `[VisibilityHandler]` console.debug that fired
on every focus regardless of outcome.

* test(ui): pin visibility handler guards + prettier reflow

- AuthProvider.tsx: prettier reflow on the destructure of
  extractDetailsFromToken (checkstyle fix flagged by CI on this PR).
- AuthProvider.test.tsx: five new tests under "AuthProvider visibility
  handler" pinning the guards the hotfix adds so they can't regress:

  * no token in storage → NO refreshToken() call
  * token with no exp claim → NO refreshToken() call
  * fresh token (>60s of headroom) → NO refreshToken() call
  * expired token → exactly one refreshToken() call
  * near-expiry (<60s of headroom) → exactly one refreshToken() call

Adds shared mocks for getOidcToken and extractDetailsFromToken (with
requireActual spreads so the pre-existing 14 tests still run untouched).

* fix(ui): visibility handler skips refresh for opaque tokens too

Greptile P1 on #31819: when jwt-decode throws (opaque / non-JWT /
malformed token), extractDetailsFromToken falls through to
{exp: 0, isExpired: true, timeoutExpiry: 0}. The previous ordering hit
the `isExpired` branch first and fired refreshToken() before the
invalid-exp guard could stop it — so opaque tokens still triggered an
IdP request on every tab focus.

Move the invalid-exp guard above the isExpired branch so both jwt-
decode-throws AND isNil(exp) paths short-circuit the same way. The
next real 401 still drives the refresh via the interceptor.

Adds a regression test covering the jwt-decode-throws shape
(exp: 0, isExpired: true).

(cherry picked from commit 935dead)
@gitar-bot

gitar-bot Bot commented Aug 20, 2026

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

Adds visibility-refresh gating to prevent unnecessary IdP calls when signed out or using opaque tokens. Consider handling the empty catch block around refreshToken errors to prevent silent failures.

💡 Quality: Empty catch silently swallows refreshToken errors

📄 openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx:564-567

The refactor replaced the previous catch (error) { console.error(...) } with a fully empty catch {}. Beyond the intended removal of the noisy debug log, this now also silently discards any error thrown by tokenService.current?.refreshToken() (e.g. a failing silent-renew), leaving no trace for debugging genuine refresh failures. Consider retaining a low-noise console.debug/console.error of the caught error so real failures remain diagnosable while still fixing the per-focus IdP noise.

🤖 Prompt for agents
Code Review: Adds visibility-refresh gating to prevent unnecessary IdP calls when signed out or using opaque tokens. Consider handling the empty catch block around refreshToken errors to prevent silent failures.

1. 💡 Quality: Empty catch silently swallows refreshToken errors
   Files: openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx:564-567

   The refactor replaced the previous `catch (error) { console.error(...) }` with a fully empty `catch {}`. Beyond the intended removal of the noisy debug log, this now also silently discards any error thrown by `tokenService.current?.refreshToken()` (e.g. a failing silent-renew), leaving no trace for debugging genuine refresh failures. Consider retaining a low-noise `console.debug`/`console.error` of the caught error so real failures remain diagnosable while still fixing the per-focus IdP noise.

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