fix(cli): login creating duplicate tokens on re-renders (#46120)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Bug fix

## What is the current behavior?

The CLI login page was firing `POST /platform/cli/login` multiple times
per page load due to an unstable `navigate` reference in the parent
component re-triggering the effect. This caused several duplicate
personal access tokens to be created for each browser sign-in attempt
(regression CLI-1491).

Additionally, error messages from non-Error rejection shapes (like those
from openapi-fetch) were being replaced with a generic "Unknown error"
message instead of surfacing the actual platform error.

## What is the new behavior?

1. **Prevents duplicate API calls**: Added a `useRef` guard
(`startedForSessionIdRef`) to ensure `createCliLoginSession` is only
called once per `sessionId`, even if the parent component re-renders
with a new `navigate` reference.

2. **Improved error handling**: Created a `getErrorMessage()` utility
that properly extracts error messages from both Error instances and
plain objects (e.g., `{ message: string, statusCode: number }`),
allowing platform error messages to surface instead of generic
fallbacks.

3. **Added comprehensive test coverage**:
- E2E test verifying the POST fires exactly once per page load with
realistic network latency
   - E2E test confirming platform error messages are displayed correctly
   - Unit test for non-Error rejection shapes
- Unit test verifying the effect doesn't re-trigger on parent re-renders

## Additional context

The fix addresses the root cause by:
- Using a ref to track which `sessionId` has already been processed,
preventing re-execution when deps change
- Extracting error message handling into a reusable utility that handles
both Error instances and plain objects
- Adding tests that specifically check for the regression (multiple POST
calls and error message display)

The E2E test includes a 400ms delay in the mock response to simulate
real-world conditions where the original bug only surfaced after React
committed multiple re-renders.

Closes: CLI-1491

https://claude.ai/code/session_01GAujw33MTRBRYSnS8cxcEa

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved CLI login error handling to display platform-specific error
messages instead of generic fallback text.
  * Fixed duplicate login session creation during component rerenders.

* **Tests**
  * Added test coverage for error message display in CLI login.
* Added regression tests for single session creation and platform error
scenarios.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46120?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Andrew Valleteau authored and GitHub committed 2026-05-20 09:17:22 +02:00
1 parent c414146572
commit da3b0cb3ec
2 files changed
+62 -5

No files matched your search

+23 -5
View File
@@ -3,7 +3,7 @@ import { Terminal } from 'lucide-react'
import Head from 'next/head'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Button, Card, CardContent } from 'ui'
import { Admonition, ShimmeringLoader } from 'ui-patterns'
@@ -48,6 +48,19 @@ const CliLoginInterstitial = ({
</InterstitialLayout>
)
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as { message: unknown }).message === 'string'
) {
return (error as { message: string }).message
}
return 'Unknown error'
}
const CliLoginPage: NextPageWithLayout = () => {
const router = useRouter()
const { session_id, public_key, token_name, device_code } = useParams()
@@ -98,6 +111,7 @@ export const CliLoginScreen = ({
}) => {
const { profile } = useProfile()
const [status, setStatus] = useState<CliLoginStatus>({ _tag: 'loading' })
const startedForSessionIdRef = useRef<string | undefined>(undefined)
const displayName = profile?.primary_email ?? profile?.username
useEffect(() => {
@@ -117,6 +131,13 @@ export const CliLoginScreen = ({
return
}
// Guard against re-render loops triggered by unstable deps (e.g. a new
// `navigate` reference on each parent render) firing the POST more than
// once per session_id. Without this, the dashboard creates several
// identical access tokens before navigating to the device_code view.
if (startedForSessionIdRef.current === sessionId) return
startedForSessionIdRef.current = sessionId
let isActive = true
setStatus({ _tag: 'loading' })
@@ -133,10 +154,7 @@ export const CliLoginScreen = ({
}
} catch (error: unknown) {
if (!isActive) return
setStatus({
_tag: 'error',
message: error instanceof Error ? error.message : 'Unknown error',
})
setStatus({ _tag: 'error', message: getErrorMessage(error) })
}
}
@@ -119,4 +119,43 @@ describe('CliLoginScreen', () => {
expect(await screen.findByText('Unable to create CLI sign-in')).toBeInTheDocument()
expect(screen.getByText(/Session expired/)).toBeInTheDocument()
})
test('surfaces error messages from non-Error rejection shapes (openapi-fetch)', async () => {
createCliLoginSessionMock.mockRejectedValue({
message:
'User can have up to 20 personal access tokens. Please remove the excess tokens to create new ones.',
statusCode: 403,
})
renderScreen()
expect(await screen.findByText('Unable to create CLI sign-in')).toBeInTheDocument()
expect(screen.getByText(/User can have up to 20 personal access tokens/)).toBeInTheDocument()
expect(screen.queryByText(/Unknown error/)).not.toBeInTheDocument()
})
test('POSTs createCliLoginSession exactly once even when parent re-renders', async () => {
createCliLoginSessionMock.mockResolvedValue({ nonce: 'ABCDEFGH12345678' })
const { rerender } = renderScreen()
await waitFor(() => {
expect(createCliLoginSessionMock).toHaveBeenCalledTimes(1)
})
// Re-render with a brand-new `navigate` prop (mirrors the production
// bug where the parent recreated the closure on every render).
for (let i = 0; i < 5; i++) {
rerender(
<CliLoginScreen
isLoggedIn
routerReady
sessionId="session-test"
publicKey="public-key-test"
tokenName="local-dev"
navigate={vi.fn()}
/>
)
}
expect(createCliLoginSessionMock).toHaveBeenCalledTimes(1)
})
})