fix(frontend): keep DNS hosts synchronized (#6158)

* fix(frontend): keep DNS hosts synchronized

* fix(frontend): preserve incomplete DNS hosts

* fix(frontend): reset DNS host drafts when disabled

* fix(frontend): clear DNS host drafts when disabled

---------

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
This commit is contained in:
PathGao
2026-07-30 08:58:51 +08:00
committed by GitHub
parent f52c3c4837
commit 2c943da3e0
2 changed files with 133 additions and 23 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
import {
@@ -42,6 +42,23 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
const dnsEnabled = !!dns;
const sourceHosts = dns?.hosts;
const incomingHosts = JSON.stringify(sourceHosts ?? {});
const lastWrittenHostsRef = useRef<string | null>(null);
useEffect(() => {
if (!dns) {
lastWrittenHostsRef.current = '{}';
setHostsList([]);
return;
}
if (incomingHosts === lastWrittenHostsRef.current) return;
lastWrittenHostsRef.current = incomingHosts;
setHostsList(Object.entries(sourceHosts ?? {}).map(([domain, values]) => ({
domain,
values: Array.isArray(values) ? [...values] : [String(values)],
})));
}, [dnsEnabled, incomingHosts, sourceHosts]);
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
@@ -79,32 +96,18 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
});
}
useEffect(() => {
if (!dns) {
setHostsList([]);
return;
}
const src = dns.hosts || {};
setHostsList(
Object.entries(src).map(([domain, val]) => ({
domain,
values: Array.isArray(val) ? [...val] : [String(val)],
})),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dnsEnabled]);
function syncHosts(next: HostRow[]) {
const obj: Record<string, string | string[]> = {};
for (const row of next) {
if (!row.domain) continue;
const vals = (row.values || []).filter(Boolean);
if (vals.length === 0) continue;
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
}
lastWrittenHostsRef.current = JSON.stringify(obj);
setHostsList(next);
mutate((tt) => {
if (!tt.dns) return;
const obj: Record<string, string | string[]> = {};
for (const row of next) {
if (!row.domain) continue;
const vals = (row.values || []).filter(Boolean);
if (vals.length === 0) continue;
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
}
if (Object.keys(obj).length > 0) {
(tt.dns as DnsConfig).hosts = obj;
} else if ('hosts' in (tt.dns as DnsConfig)) {

View File

@@ -0,0 +1,107 @@
import { useState } from 'react';
import { describe, expect, it } from 'vitest';
import { fireEvent, screen } from '@testing-library/react';
import DnsTab from '@/pages/xray/dns/DnsTab';
import type { SetTemplate, XraySettingsValue } from '@/hooks/useXraySetting';
import { renderWithProviders } from './test-utils';
function withHosts(hosts: Record<string, string>): XraySettingsValue {
return {
dns: {
hosts,
servers: [],
},
} as unknown as XraySettingsValue;
}
describe('DnsTab', () => {
it('keeps an empty row after adding a host', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
}
renderWithProviders(
<Harness />,
);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
fireEvent.click(screen.getByRole('button', { name: /Add Host$/ }));
expect(screen.getAllByLabelText('Domain (e.g. domain:example.com)')).toHaveLength(2);
});
it('keeps a row visible while its domain is incomplete', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
}
renderWithProviders(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), { target: { value: '' } });
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('');
});
it('shows hosts from an externally refreshed configuration', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return (
<>
<button type="button" onClick={() => setTemplateSettings(withHosts({ 'second.example': '2.2.2.2' }))}>
Refresh hosts
</button>
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
</>
);
}
renderWithProviders(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('first.example');
fireEvent.click(screen.getByRole('button', { name: 'Refresh hosts' }));
expect((screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value).toBe('second.example');
});
it('clears an incomplete host draft when DNS is disabled', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(withHosts({ 'first.example': '1.1.1.1' }));
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return (
<>
<button type="button" onClick={() => setTemplateSettings({})}>Disable DNS</button>
<button type="button" onClick={() => setTemplateSettings(withHosts({}))}>Enable DNS</button>
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
</>
);
}
renderWithProviders(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: 'Disable DNS' }));
fireEvent.click(screen.getByRole('button', { name: 'Enable DNS' }));
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
expect(screen.queryByLabelText('Domain (e.g. domain:example.com)')).toBeNull();
});
});