From 0b53a1869f5cc7284376d4465a8929886b729a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cemal=20K=C4=B1l=C4=B1=C3=A7?= Date: Tue, 21 Jul 2026 12:13:13 +0200 Subject: [PATCH] fix(studio): wire up siwc-enabled query param opt-in on sign-in/sign-up (#48126) Add `useSiwcQueryParamOptIn`, which flips on the ChatGPT sign-in rollout localStorage flag when `?siwc-enabled=1` is present, and call it from both pages/sign-in.tsx and pages/sign-up.tsx. ## Summary by CodeRabbit * **New Features** * Added support for enabling the sign-in experience via `?siwc-enabled=1`, automatically updating the stored opt-in flag on both sign-in and sign-up pages. * **Tests** * Added coverage confirming the stored flag is updated only for `siwc-enabled=1`, and not for missing, non-`1`, `0`, or repeated/array values. * Added assertions that the behavior is triggered consistently when rendering the sign-in and sign-up pages. --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Joshen Lim --- .../__tests__/useSiwcQueryParamOptIn.test.ts | 87 +++++++++++++++++++ .../hooks/misc/useSiwcQueryParamOptIn.ts | 27 ++++++ apps/studio/pages/sign-in.tsx | 3 + apps/studio/pages/sign-up.tsx | 3 + apps/studio/tests/pages/sign-in.test.tsx | 70 +++++++++++++++ apps/studio/tests/pages/sign-up.test.tsx | 46 ++++++++++ 6 files changed, 236 insertions(+) create mode 100644 apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts create mode 100644 apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts create mode 100644 apps/studio/tests/pages/sign-in.test.tsx create mode 100644 apps/studio/tests/pages/sign-up.test.tsx diff --git a/apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts b/apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts new file mode 100644 index 00000000000..71f2acc58d0 --- /dev/null +++ b/apps/studio/hooks/misc/__tests__/useSiwcQueryParamOptIn.test.ts @@ -0,0 +1,87 @@ +import { renderHook } from '@testing-library/react' +import mockRouter from 'next-router-mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useSiwcQueryParamOptIn } from '../useSiwcQueryParamOptIn' + +vi.mock('next/router', () => import('next-router-mock')) + +// tests/vitestSetup.ts globally mocks `common`'s useParams to always return `{ ref: 'default' }`, +// which would make this hook's `siwcEnabled` lookup always undefined. Restore the real +// implementation here so useParams reflects the mocked router's query params. +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual } +}) + +const mockSetValue = vi.hoisted(() => vi.fn()) +const mockUseLocalStorageQuery = vi.hoisted(() => vi.fn()) + +vi.mock('../useLocalStorage', () => ({ + useLocalStorageQuery: mockUseLocalStorageQuery, +})) + +describe('useSiwcQueryParamOptIn', () => { + beforeEach(() => { + mockRouter.setCurrentUrl('/sign-in') + mockSetValue.mockClear() + mockUseLocalStorageQuery.mockReturnValue([false, mockSetValue]) + }) + + it('enables the flag when siwc-enabled=1 is present', () => { + mockRouter.setCurrentUrl('/sign-in?siwc-enabled=1') + + renderHook(() => useSiwcQueryParamOptIn()) + + expect(mockSetValue).toHaveBeenCalledWith(true) + }) + + it('does nothing when the param is absent', () => { + renderHook(() => useSiwcQueryParamOptIn()) + + expect(mockSetValue).not.toHaveBeenCalled() + }) + + it('does nothing for a non-"1" value', () => { + mockRouter.setCurrentUrl('/sign-in?siwc-enabled=true') + + renderHook(() => useSiwcQueryParamOptIn()) + + expect(mockSetValue).not.toHaveBeenCalled() + }) + + it('does nothing when siwc-enabled=0', () => { + mockRouter.setCurrentUrl('/sign-in?siwc-enabled=0') + + renderHook(() => useSiwcQueryParamOptIn()) + + expect(mockSetValue).not.toHaveBeenCalled() + }) + + it('only considers the first value when the param is repeated (array value)', () => { + // useParams (from 'common') flattens repeated query params to their first occurrence, so + // only the first "0" here is seen by the hook, and it does nothing. + mockRouter.setCurrentUrl('/sign-in?siwc-enabled=0&siwc-enabled=1') + + renderHook(() => useSiwcQueryParamOptIn()) + + expect(mockSetValue).not.toHaveBeenCalled() + }) + + it('still calls the setter when the flag is already true (idempotent no-op is the setter’s job)', () => { + mockUseLocalStorageQuery.mockReturnValue([true, mockSetValue]) + mockRouter.setCurrentUrl('/sign-in?siwc-enabled=1') + + renderHook(() => useSiwcQueryParamOptIn()) + + expect(mockSetValue).toHaveBeenCalledWith(true) + }) + + it('works the same way on the sign-up URL', () => { + mockRouter.setCurrentUrl('/sign-up?siwc-enabled=1') + + renderHook(() => useSiwcQueryParamOptIn()) + + expect(mockSetValue).toHaveBeenCalledWith(true) + }) +}) diff --git a/apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts b/apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts new file mode 100644 index 00000000000..400c7a71b03 --- /dev/null +++ b/apps/studio/hooks/misc/useSiwcQueryParamOptIn.ts @@ -0,0 +1,27 @@ +import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { useEffect } from 'react' + +import { useLocalStorageQuery } from './useLocalStorage' + +/** + * Lets a shareable link (e.g. `/sign-in?siwc-enabled=1`) flip on the manual ChatGPT sign-in + * rollout switch (`LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED`, read by + * `useEnabledIdentityProviders`) for this browser, instead of requiring a devtools localStorage + * edit. Only ever meaningful on `/sign-in` and `/sign-up`, where this param would be linked to. + * + * Only the exact string `'1'` opts in; the flag is never cleared based on the param's absence, so + * it persists once set. + */ +export function useSiwcQueryParamOptIn() { + const { siwcEnabled } = useParams() + const [, setChatgptLocalStorageEnabled] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.SIGN_IN_CHATGPT_ENABLED, + false + ) + + useEffect(() => { + if (siwcEnabled === '1') { + setChatgptLocalStorageEnabled(true) + } + }, [siwcEnabled, setChatgptLocalStorageEnabled]) +} diff --git a/apps/studio/pages/sign-in.tsx b/apps/studio/pages/sign-in.tsx index 9cf0b1c6ea5..24335a2af78 100644 --- a/apps/studio/pages/sign-in.tsx +++ b/apps/studio/pages/sign-in.tsx @@ -14,11 +14,14 @@ import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProviders' import { useInboundBranding } from '@/hooks/misc/useInboundBranding' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useSiwcQueryParamOptIn } from '@/hooks/misc/useSiwcQueryParamOptIn' import { IS_PLATFORM } from '@/lib/constants' import type { ExternalIdentityProviderConfig } from '@/lib/external-identity-providers' import type { NextPageWithLayout } from '@/types' const SignInPage: NextPageWithLayout = () => { + useSiwcQueryParamOptIn() + const router = useRouter() const [showOtherOptions, setShowOtherOptions] = useState(false) diff --git a/apps/studio/pages/sign-up.tsx b/apps/studio/pages/sign-up.tsx index 382a78a36ac..417f1dc4bfc 100644 --- a/apps/studio/pages/sign-up.tsx +++ b/apps/studio/pages/sign-up.tsx @@ -9,10 +9,13 @@ import { UnknownInterface } from '@/components/ui/UnknownInterface' import { useEnabledIdentityProviders } from '@/hooks/misc/useEnabledIdentityProviders' import { useInboundBranding } from '@/hooks/misc/useInboundBranding' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useSiwcQueryParamOptIn } from '@/hooks/misc/useSiwcQueryParamOptIn' import type { ExternalIdentityProviderConfig } from '@/lib/external-identity-providers' import type { NextPageWithLayout } from '@/types' const SignUpPage: NextPageWithLayout = () => { + useSiwcQueryParamOptIn() + const [showOtherOptions, setShowOtherOptions] = useState(false) const { dashboardAuthSignUp: signUpEnabled } = useIsFeatureEnabled(['dashboard_auth:sign_up']) diff --git a/apps/studio/tests/pages/sign-in.test.tsx b/apps/studio/tests/pages/sign-in.test.tsx new file mode 100644 index 00000000000..e0e2b6933e4 --- /dev/null +++ b/apps/studio/tests/pages/sign-in.test.tsx @@ -0,0 +1,70 @@ +import { render } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import SignInPage from '@/pages/sign-in' + +const { mockUseSiwcQueryParamOptIn, mockUseIsFeatureEnabled } = vi.hoisted(() => ({ + mockUseSiwcQueryParamOptIn: vi.fn(), + mockUseIsFeatureEnabled: vi.fn(), +})) + +vi.mock('next/router', () => ({ + useRouter: () => ({ query: {}, replace: vi.fn() }), +})) + +vi.mock('@/hooks/misc/useSiwcQueryParamOptIn', () => ({ + useSiwcQueryParamOptIn: mockUseSiwcQueryParamOptIn, +})) + +vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ + useIsFeatureEnabled: mockUseIsFeatureEnabled, +})) + +vi.mock('@/hooks/custom-content/useCustomContent', () => ({ + useCustomContent: () => ({ + dashboardAuthCustomProvider: undefined, + dashboardAuthCustomProviders: undefined, + }), +})) + +vi.mock('@/hooks/misc/useEnabledIdentityProviders', () => ({ + useEnabledIdentityProviders: () => [], +})) + +vi.mock('@/hooks/misc/useInboundBranding', () => ({ + useInboundBranding: () => ({ focusProvider: undefined }), +})) + +vi.mock('@/components/interfaces/SignIn/LastSignInWrapper', () => ({ + LastSignInWrapper: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +vi.mock('@/components/interfaces/SignIn/SignInForm', () => ({ + SignInForm: () =>
SignInForm
, +})) + +vi.mock('@/components/interfaces/SignIn/SignInWithCustom', () => ({ + SignInWithCustom: () =>
SignInWithCustom
, +})) + +vi.mock('@/components/interfaces/SignIn/SignInWithExternalProvider', () => ({ + SignInWithExternalProvider: () =>
SignInWithExternalProvider
, +})) + +describe('/sign-in', () => { + beforeEach(() => { + mockUseSiwcQueryParamOptIn.mockClear() + mockUseIsFeatureEnabled.mockReturnValue({ + dashboardAuthSignInWithSso: false, + dashboardAuthSignInWithEmail: false, + dashboardAuthSignUp: false, + }) + }) + + it('calls useSiwcQueryParamOptIn so a shareable ?siwc-enabled=1 link can opt this browser in', () => { + render() + + expect(mockUseSiwcQueryParamOptIn).toHaveBeenCalled() + }) +}) diff --git a/apps/studio/tests/pages/sign-up.test.tsx b/apps/studio/tests/pages/sign-up.test.tsx new file mode 100644 index 00000000000..37b54070a36 --- /dev/null +++ b/apps/studio/tests/pages/sign-up.test.tsx @@ -0,0 +1,46 @@ +import { render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import SignUpPage from '@/pages/sign-up' + +const { mockUseSiwcQueryParamOptIn, mockUseIsFeatureEnabled } = vi.hoisted(() => ({ + mockUseSiwcQueryParamOptIn: vi.fn(), + mockUseIsFeatureEnabled: vi.fn(), +})) + +vi.mock('@/hooks/misc/useSiwcQueryParamOptIn', () => ({ + useSiwcQueryParamOptIn: mockUseSiwcQueryParamOptIn, +})) + +vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ + useIsFeatureEnabled: mockUseIsFeatureEnabled, +})) + +vi.mock('@/hooks/misc/useEnabledIdentityProviders', () => ({ + useEnabledIdentityProviders: () => [], +})) + +vi.mock('@/hooks/misc/useInboundBranding', () => ({ + useInboundBranding: () => ({ focusProvider: undefined }), +})) + +vi.mock('@/components/interfaces/SignIn/SignInWithExternalProvider', () => ({ + SignInWithExternalProvider: () =>
SignInWithExternalProvider
, +})) + +vi.mock('@/components/interfaces/SignIn/SignUpForm', () => ({ + SignUpForm: () =>
SignUpForm
, +})) + +describe('/sign-up', () => { + beforeEach(() => { + mockUseSiwcQueryParamOptIn.mockClear() + mockUseIsFeatureEnabled.mockReturnValue({ dashboardAuthSignUp: true }) + }) + + it('calls useSiwcQueryParamOptIn so a shareable ?siwc-enabled=1 link can opt this browser in', () => { + render() + + expect(mockUseSiwcQueryParamOptIn).toHaveBeenCalled() + }) +})