diff --git a/frontend/src/components/command-palette/CommandPalette.tsx b/frontend/src/components/command-palette/CommandPalette.tsx index 321d639dd..5861356ff 100644 --- a/frontend/src/components/command-palette/CommandPalette.tsx +++ b/frontend/src/components/command-palette/CommandPalette.tsx @@ -64,6 +64,7 @@ interface PaletteItem { export default function CommandPalette() { const { t } = useTranslation(); + const [messageApi, messageContextHolder] = message.useMessage(); const navigate = useNavigate(); const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme(); const { isOpen, close } = useCommandPalette(); @@ -194,14 +195,14 @@ export default function CommandPalette() { const copySubscription = useCallback( async (client: ClientRecord) => { if (!client.subId || !allSetting.subURI) { - message.warning(t('pages.clients.noSubId')); + messageApi.warning(t('pages.clients.noSubId')); return; } const link = `${allSetting.subURI}${client.subId}`; const ok = await ClipboardManager.copyText(link); - if (ok) message.success(t('copied')); + if (ok) messageApi.success(t('copied')); }, - [allSetting.subURI, t], + [allSetting.subURI, messageApi, t], ); const restartXray = useCallback(async () => { @@ -210,9 +211,9 @@ export default function CommandPalette() { silentSuccess: true, }); if (msg?.success) { - message.success(t('commandPalette.restartXraySuccess')); + messageApi.success(t('commandPalette.restartXraySuccess')); } - }, [close, t]); + }, [close, messageApi, t]); const cycleTheme = useCallback(() => { if (!isDark) { @@ -679,13 +680,15 @@ export default function CommandPalette() { } }; - if (!isOpen) return null; + // Kept mounted while closed: restartXray closes the palette before its toast. + if (!isOpen) return messageContextHolder; let lastCategory = ''; const themeModeClass = isUltra ? 'ultra' : isDark ? 'dark' : 'light'; return ( + {messageContextHolder}
{ const cleanSni = (sni || '').trim(); if (!cleanSni) { - message.warning(t('pages.xray.tuic.sniRequired')); + messageApi.warning(t('pages.xray.tuic.sniRequired')); return; } setValue('settings.server.certificate', `/root/cert/${cleanSni}/fullchain.pem`); @@ -51,12 +52,12 @@ export default function TuicFields() { ? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true }) : await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true }); if (!msg?.success) { - message.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty')); + messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty')); return; } const obj = msg.obj as { webCertFile?: string; webKeyFile?: string }; if (!obj?.webCertFile && !obj?.webKeyFile) { - message.warning(t('pages.inbounds.setDefaultCertEmpty')); + messageApi.warning(t('pages.inbounds.setDefaultCertEmpty')); return; } if (obj.webCertFile) { @@ -65,9 +66,9 @@ export default function TuicFields() { if (obj.webKeyFile) { setValue('settings.server.private_key', obj.webKeyFile); } - message.success(t('pages.inbounds.setSuccess')); + messageApi.success(t('pages.inbounds.setSuccess')); } catch { - message.error(t('somethingWentWrong')); + messageApi.error(t('somethingWentWrong')); } finally { setLoadingPanelCert(false); } @@ -145,6 +146,7 @@ export default function TuicFields() { return ( <> + {messageContextHolder} ('iran-bypass'); const [includeAdblock, setIncludeAdblock] = useState(false); @@ -37,18 +38,19 @@ export default function HappSettingsContent({ const payload = buildHappPresetDeeplink(selectedPreset, includeAdblock); if (payload) { updateSetting({ subRoutingRules: payload }); - message.success(t('pages.settings.subHappPresetApplied')); + messageApi.success(t('pages.settings.subHappPresetApplied')); } }; const handleBuildDeeplink = (deeplink: string) => { updateSetting({ subRoutingRules: deeplink }); setIsModalOpen(false); - message.success(t('pages.settings.subHappDeeplinkGenerated')); + messageApi.success(t('pages.settings.subHappDeeplinkGenerated')); }; return ( <> + {messageContextHolder} (null); @@ -179,7 +180,7 @@ export default function RoutingTab({ try { parsed = JSON.parse(value); } catch { - message.error(t('pages.xray.importInvalidJson')); + messageApi.error(t('pages.xray.importInvalidJson')); return; } const obj = parsed as { rules?: unknown; routing?: { rules?: unknown } }; @@ -191,7 +192,7 @@ export default function RoutingTab({ ? obj.routing!.rules : null; if (!list) { - message.error(t('pages.xray.importInvalidJson')); + messageApi.error(t('pages.xray.importInvalidJson')); return; } mutate((tt) => { @@ -347,6 +348,7 @@ export default function RoutingTab({ return ( <> {modalContextHolder} + {messageContextHolder} { fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' })); expect(generatedProfile()).toEqual(minimal); }); + + // The static message API outlived the test file and logged act() warnings + // after teardown, failing CI with "Closing rpc while onUserConsoleLog was pending". + it('takes its toast down with it when unmounted', async () => { + renderSettings(); + openEditor(); + fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' })); + await screen.findByText('Deeplink generated and applied to routing rules'); + + cleanup(); + + expect(document.body.textContent).not.toContain( + 'Deeplink generated and applied to routing rules', + ); + }); }); diff --git a/frontend/src/test/happ-settings-presets.test.tsx b/frontend/src/test/happ-settings-presets.test.tsx index 3b7c34266..845776f54 100644 --- a/frontend/src/test/happ-settings-presets.test.tsx +++ b/frontend/src/test/happ-settings-presets.test.tsx @@ -1,15 +1,12 @@ import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { fireEvent, screen } from '@testing-library/react'; -import { message } from 'antd'; import { AllSetting } from '@/models/setting'; import HappSettingsContent from '@/pages/settings/HappSettingsContent'; import { renderWithProviders } from './test-utils'; -vi.spyOn(message, 'success').mockImplementation(() => undefined as never); - const chinaProfile = { Name: 'Bypass-CN', GlobalProxy: 'true', diff --git a/frontend/src/test/no-static-message.test.ts b/frontend/src/test/no-static-message.test.ts new file mode 100644 index 000000000..343f8b705 --- /dev/null +++ b/frontend/src/test/no-static-message.test.ts @@ -0,0 +1,31 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join, relative, resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const srcRoot = resolve(fileURLToPath(import.meta.url), '../..'); +const staticCall = /(? { + const path = join(dir, name); + if (statSync(path).isDirectory()) return name === 'test' ? [] : sourceFiles(path); + return /\.tsx?$/.test(name) ? [path] : []; + }); +} + +// antd's static message renders outside React: it ignores the theme and its +// timers outlive the component, which broke CI after the Happ tests tore down. +describe('antd message', () => { + it('is only used through message.useMessage()', () => { + const offenders = sourceFiles(srcRoot).flatMap((file) => + readFileSync(file, 'utf8') + .split('\n') + .flatMap((line, i) => + staticCall.test(line) ? [`${relative(srcRoot, file)}:${i + 1}: ${line.trim()}`] : [], + ), + ); + expect(offenders).toEqual([]); + }); +});