mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-06 22:22:08 +03:00
fix(frontend): show toasts through message.useMessage, never the static API
Invariant: an antd toast lives inside the React tree of the component that raised it, so it takes the theme from ConfigProvider and unmounts with it. Four components called antd's static message.* (Happ settings, TUIC form, routing tab, command palette); the other 40 use message.useMessage. A static toast renders in a detached root that RTL's cleanup never unmounts, so its rAF-driven close timer kept updating state for ~3s after a test file ended. happ-routing-editor.test.tsx logged 419 act() warnings, 226 of them after its last test; on a slow runner the worker closed first and vitest failed the job with "Closing rpc while onUserConsoleLog was pending" (CI runs 37075337071, 37303631843). In the product the same toasts ignored the dark/ultra theme. The command palette keeps its holder mounted while closed: restartXray closes the palette before its success toast. The Happ editor test now asserts its toast unmounts with the component (red on the static API), and no-static-message.test.ts fails on any static call in src. The spy that silenced message.success in happ-settings-presets.test.tsx intercepted nothing after the change and is gone.
This commit is contained in:
@@ -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 (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
{messageContextHolder}
|
||||
<div
|
||||
className={`command-palette-backdrop ${themeModeClass}`}
|
||||
role="presentation"
|
||||
|
||||
@@ -21,6 +21,7 @@ import { HttpUtil } from '@/utils';
|
||||
|
||||
export default function TuicFields() {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const { control, setValue } = useFormContext();
|
||||
const [loadingPanelCert, setLoadingPanelCert] = useState(false);
|
||||
|
||||
@@ -36,7 +37,7 @@ export default function TuicFields() {
|
||||
const autofillFromSni = () => {
|
||||
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}
|
||||
<Form.Item label={t('pages.inbounds.publicKey')}>
|
||||
<AutoComplete
|
||||
value={certificate}
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function HappSettingsContent({
|
||||
remoteSourceBadge,
|
||||
}: HappSettingsContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
// Generator choices stay local until Apply updates the draft; page Save persists it.
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>('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}
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappAutoDetect')}
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function RoutingTab({
|
||||
isMobile,
|
||||
}: RoutingTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<RoutingRule | null>(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}
|
||||
<Tabs
|
||||
defaultActiveKey="basic"
|
||||
items={[
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, screen, within } from '@testing-library/react';
|
||||
import { act, cleanup, fireEvent, screen, within } from '@testing-library/react';
|
||||
import { EditorView } from 'codemirror';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
@@ -319,4 +319,19 @@ describe('Happ routing editor', () => {
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = /(?<![\w.$])message\.(success|error|warning|info|loading|open)\(/;
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user