From ca31762e265a14e7c4f7368d630b2fdffa44d7cf Mon Sep 17 00:00:00 2001 From: y <63027222+yzhkali@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:16:59 +0800 Subject: [PATCH] refactor(web): migrate education verifying storage to useLocalStorage (#36934) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 15 ------ .../education-verification-flow.test.tsx | 9 +++- .../education-verify-action-recorder.spec.tsx | 11 ++-- .../billing/plan/__tests__/index.spec.tsx | 9 +++- web/app/components/billing/plan/index.tsx | 4 +- .../education-verify-action-recorder.tsx | 6 ++- web/context/modal-context-provider.tsx | 8 ++- web/context/modal-context.test.tsx | 50 ++++++++++++++++++- 8 files changed, 80 insertions(+), 32 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 100e36e2f0..8d46984928 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1867,11 +1867,6 @@ "count": 4 } }, - "web/app/components/billing/plan/index.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "web/app/components/billing/pricing/assets/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 12 @@ -2403,11 +2398,6 @@ "count": 2 } }, - "web/app/components/education-verify-action-recorder.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "web/app/components/explore/app-list/index.tsx": { "no-restricted-imports": { "count": 1 @@ -5100,11 +5090,6 @@ "count": 3 } }, - "web/context/modal-context-provider.tsx": { - "no-restricted-globals": { - "count": 2 - } - }, "web/context/provider-context-provider.tsx": { "ts/no-explicit-any": { "count": 1 diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx index 58b531661e..6c7f05c4df 100644 --- a/web/__tests__/billing/education-verification-flow.test.tsx +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -22,6 +22,7 @@ const mockSetShowPricingModal = vi.fn() const mockSetShowAccountSettingModal = vi.fn() const mockRouterPush = vi.fn() const mockMutateAsync = vi.fn() +const mockSetEducationVerifying = vi.hoisted(() => vi.fn()) // ─── Context mocks ─────────────────────────────────────────────────────────── vi.mock('@/context/provider-context', () => ({ @@ -76,6 +77,10 @@ vi.mock('@/hooks/use-async-window-open', () => ({ useAsyncWindowOpen: () => vi.fn(), })) +vi.mock('@/hooks/use-local-storage', () => ({ + useSetLocalStorage: () => mockSetEducationVerifying, +})) + // ─── External component mocks ─────────────────────────────────────────────── vi.mock('@/app/education-apply/verify-state-modal', () => ({ default: ({ isShow, title, content, email, showLink }: { @@ -206,7 +211,7 @@ describe('Education Verification Flow', () => { }) }) - it('should remove education verifying flag from localStorage on success', async () => { + it('should clear education verifying flag on success', async () => { mockMutateAsync.mockResolvedValue({ token: 'token-xyz' }) setupContexts({}, { enableEducationPlan: true, isEducationAccount: false }) const user = userEvent.setup() @@ -216,7 +221,7 @@ describe('Education Verification Flow', () => { await user.click(screen.getByText(/toVerified/i)) await waitFor(() => { - expect(localStorage.removeItem).toHaveBeenCalledWith('educationVerifying') + expect(mockSetEducationVerifying).toHaveBeenCalledWith(null) }) }) }) diff --git a/web/app/components/__tests__/education-verify-action-recorder.spec.tsx b/web/app/components/__tests__/education-verify-action-recorder.spec.tsx index 416715abf2..742fb9c249 100644 --- a/web/app/components/__tests__/education-verify-action-recorder.spec.tsx +++ b/web/app/components/__tests__/education-verify-action-recorder.spec.tsx @@ -2,15 +2,20 @@ import { render, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, - EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, } from '@/app/education-apply/constants' import { useSearchParams } from '@/next/navigation' import { EducationVerifyActionRecorder } from '../education-verify-action-recorder' +const setEducationVerifyingMock = vi.hoisted(() => vi.fn()) + vi.mock('@/next/navigation', () => ({ useSearchParams: vi.fn(), })) +vi.mock('@/hooks/use-local-storage', () => ({ + useSetLocalStorage: () => setEducationVerifyingMock, +})) + const mockUseSearchParams = vi.mocked(useSearchParams) describe('EducationVerifyActionRecorder', () => { @@ -28,13 +33,13 @@ describe('EducationVerifyActionRecorder', () => { render() await waitFor(() => { - expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes') + expect(setEducationVerifyingMock).toHaveBeenCalledWith('yes') }) }) it('should leave localStorage unchanged for unrelated routes', () => { render() - expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBeNull() + expect(setEducationVerifyingMock).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/billing/plan/__tests__/index.spec.tsx b/web/app/components/billing/plan/__tests__/index.spec.tsx index 18c370e833..b433d5df27 100644 --- a/web/app/components/billing/plan/__tests__/index.spec.tsx +++ b/web/app/components/billing/plan/__tests__/index.spec.tsx @@ -1,9 +1,10 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants' import { fetchSubscriptionUrls } from '@/service/billing' import { Plan, SelfHostedPlan } from '../../type' import PlanComp from '../index' +const setEducationVerifyingMock = vi.hoisted(() => vi.fn()) + let currentPath = '/billing' const push = vi.fn() @@ -35,6 +36,10 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/hooks/use-local-storage', () => ({ + useSetLocalStorage: () => setEducationVerifyingMock, +})) + vi.mock('@/service/billing', () => ({ fetchSubscriptionUrls: vi.fn(), })) @@ -143,7 +148,7 @@ describe('PlanComp', () => { await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled()) await waitFor(() => expect(push).toHaveBeenCalledWith('/education-apply?token=token')) - expect(localStorage.removeItem).toHaveBeenCalledWith(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) + expect(setEducationVerifyingMock).toHaveBeenCalledWith(null) }) it('shows modal when education verify fails', async () => { diff --git a/web/app/components/billing/plan/index.tsx b/web/app/components/billing/plan/index.tsx index 69551c68bb..25ea5e9882 100644 --- a/web/app/components/billing/plan/index.tsx +++ b/web/app/components/billing/plan/index.tsx @@ -18,6 +18,7 @@ import VerifyStateModal from '@/app/education-apply/verify-state-modal' import { useAppContext } from '@/context/app-context' import { useModalContextSelector } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' +import { useSetLocalStorage } from '@/hooks/use-local-storage' import { usePathname, useRouter } from '@/next/navigation' import { useEducationVerify } from '@/service/use-education' import { getDaysUntilEndOfMonth } from '@/utils/time' @@ -70,12 +71,13 @@ const PlanComp: FC = ({ const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount() const { mutateAsync, isPending } = useEducationVerify() const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) + const setEducationVerifying = useSetLocalStorage(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, { raw: true }) const unmountedRef = useUnmountedRef() const handleVerify = () => { if (isPending) return mutateAsync().then((res) => { - localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) + setEducationVerifying(null) if (unmountedRef.current) return router.push(`/education-apply?token=${res.token}`) diff --git a/web/app/components/education-verify-action-recorder.tsx b/web/app/components/education-verify-action-recorder.tsx index 017bfa945e..3e26e55dc7 100644 --- a/web/app/components/education-verify-action-recorder.tsx +++ b/web/app/components/education-verify-action-recorder.tsx @@ -5,15 +5,17 @@ import { EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, } from '@/app/education-apply/constants' +import { useSetLocalStorage } from '@/hooks/use-local-storage' import { useSearchParams } from '@/next/navigation' export function EducationVerifyActionRecorder() { const searchParams = useSearchParams() + const setEducationVerifying = useSetLocalStorage(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, { raw: true }) useEffect(() => { if (searchParams.get('action') === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) - localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes') - }, [searchParams]) + setEducationVerifying('yes') + }, [searchParams, setEducationVerifying]) return null } diff --git a/web/context/modal-context-provider.tsx b/web/context/modal-context-provider.tsx index b424a22bf4..4adc1c25d2 100644 --- a/web/context/modal-context-provider.tsx +++ b/web/context/modal-context-provider.tsx @@ -22,6 +22,7 @@ import { } from '@/app/education-apply/constants' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' +import { useSetLocalStorage } from '@/hooks/use-local-storage' import { useAccountSettingModal, usePricingModal, @@ -99,14 +100,11 @@ export const ModalContextProvider = ({ const [showUpdatePluginModal, setShowUpdatePluginModal] = useState | null>(null) const [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] = useState | null>(null) const { currentWorkspace } = useAppContext() + const setEducationVerifying = useSetLocalStorage(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, { raw: true }) const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false) const handleCancelAccountSettingModal = () => { - const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) - - if (educationVerifying === 'yes') - localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) - + setEducationVerifying(educationVerifying => educationVerifying === 'yes' ? null : educationVerifying) accountSettingCallbacksRef.current?.onCancelCallback?.() accountSettingCallbacksRef.current = null setUrlAccountModalState(null) diff --git a/web/context/modal-context.test.tsx b/web/context/modal-context.test.tsx index a5758dc757..62ce95ef97 100644 --- a/web/context/modal-context.test.tsx +++ b/web/context/modal-context.test.tsx @@ -3,9 +3,13 @@ import userEvent from '@testing-library/user-event' import * as React from 'react' import { defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' +import { useModalContextSelector } from '@/context/modal-context' import { ModalContextProvider } from '@/context/modal-context-provider' import { renderWithNuqs } from '@/test/nuqs-testing' +const mockSetEducationVerifying = vi.hoisted(() => vi.fn()) + vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() return { @@ -22,6 +26,16 @@ vi.mock('@/app/components/billing/pricing', () => ({ default: () =>
billing.plansCommon.mostPopular
, })) +vi.mock('@/app/components/header/account-setting', () => ({ + default: ({ onCancelAction }: { onCancelAction: () => void }) => ( + + ), +})) + +vi.mock('@/hooks/use-local-storage', () => ({ + useSetLocalStorage: () => mockSetEducationVerifying, +})) + const mockUseProviderContext = vi.fn() vi.mock('@/context/provider-context', () => ({ useProviderContext: () => mockUseProviderContext(), @@ -61,16 +75,30 @@ const createPlan = (overrides: PlanOverrides = {}): PlanShape => ({ }, }) -const renderProvider = () => renderWithNuqs( +const renderProvider = (children: React.ReactNode =
) => renderWithNuqs( -
+ {children} , ) +const AccountSettingOpener = () => { + const setShowAccountSettingModal = useModalContextSelector(state => state.setShowAccountSettingModal) + + return ( + + ) +} + describe('ModalContextProvider trigger events limit modal', () => { beforeEach(() => { mockUseAppContext.mockReset() mockUseProviderContext.mockReset() + mockSetEducationVerifying.mockReset() window.localStorage.clear() mockUseAppContext.mockReturnValue({ currentWorkspace: { @@ -115,6 +143,24 @@ describe('ModalContextProvider trigger events limit modal', () => { expect(value).toBe('1') }) + it('clears the education verifying flag when account settings are canceled', async () => { + mockUseProviderContext.mockReturnValue({ + plan: createPlan(), + isFetchedPlan: true, + }) + const user = userEvent.setup() + + renderProvider() + + await user.click(screen.getByRole('button', { name: 'open account setting' })) + await user.click(await screen.findByRole('button', { name: 'cancel account setting' })) + + expect(mockSetEducationVerifying).toHaveBeenCalledWith(expect.any(Function)) + const updater = mockSetEducationVerifying.mock.calls[0]?.[0] as (educationVerifying: string) => string | null + expect(updater('yes')).toBeNull() + expect(updater('no')).toBe('no') + }) + it('relies on the in-memory guard when localStorage reads throw', async () => { const plan = createPlan({ type: Plan.professional,