From 2c943da3e0feb584616962d85c78518362a6a35d Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:58:51 +0800 Subject: [PATCH] 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 --- frontend/src/pages/xray/dns/DnsTab.tsx | 49 +++++------ frontend/src/test/dns-tab.test.tsx | 107 +++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 23 deletions(-) create mode 100644 frontend/src/test/dns-tab.test.tsx diff --git a/frontend/src/pages/xray/dns/DnsTab.tsx b/frontend/src/pages/xray/dns/DnsTab.tsx index 0eb8c1dbe..d37c38902 100644 --- a/frontend/src/pages/xray/dns/DnsTab.tsx +++ b/frontend/src/pages/xray/dns/DnsTab.tsx @@ -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(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 = {}; + 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 = {}; - 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)) { diff --git a/frontend/src/test/dns-tab.test.tsx b/frontend/src/test/dns-tab.test.tsx new file mode 100644 index 000000000..7592d5fd3 --- /dev/null +++ b/frontend/src/test/dns-tab.test.tsx @@ -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): 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(withHosts({ 'first.example': '1.1.1.1' })); + const updateTemplate: SetTemplate = (next) => { + setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next)); + }; + + return ; + } + + renderWithProviders( + , + ); + + 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(withHosts({ 'first.example': '1.1.1.1' })); + const updateTemplate: SetTemplate = (next) => { + setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next)); + }; + + return ; + } + + renderWithProviders(); + 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(withHosts({ 'first.example': '1.1.1.1' })); + const updateTemplate: SetTemplate = (next) => { + setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next)); + }; + + return ( + <> + + + + ); + } + + renderWithProviders(); + + 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(withHosts({ 'first.example': '1.1.1.1' })); + const updateTemplate: SetTemplate = (next) => { + setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next)); + }; + + return ( + <> + + + + + ); + } + + renderWithProviders(); + 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(); + }); +});