Преглед на файлове

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 <[email protected]>
PathGao преди 15 часа
родител
ревизия
2c943da3e0
променени са 2 файла, в които са добавени 133 реда и са изтрити 23 реда
  1. 26 23
      frontend/src/pages/xray/dns/DnsTab.tsx
  2. 107 0
      frontend/src/test/dns-tab.test.tsx

+ 26 - 23
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<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)) {

+ 107 - 0
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<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();
+  });
+});