diff --git a/frontend/src/api/queries/useAllSettings.ts b/frontend/src/api/queries/useAllSettings.ts index 89f03bccd..8664f2846 100644 --- a/frontend/src/api/queries/useAllSettings.ts +++ b/frontend/src/api/queries/useAllSettings.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { HttpUtil, Msg } from '@/utils'; @@ -6,8 +6,13 @@ import { parseMsg } from '@/utils/zodValidate'; import { AllSetting } from '@/models/setting'; import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting'; import { keys } from '@/api/queryKeys'; +import { useServerDraft } from '@/hooks/useServerDraft'; type SettingSavePayload = Partial & Record; +type SettingSaveResult = { + msg: Msg; + saved?: AllSetting; +}; async function fetchAllSetting(): Promise { const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true }); @@ -18,7 +23,6 @@ async function fetchAllSetting(): Promise { export function useAllSettings() { const queryClient = useQueryClient(); - const [draft, setDraft] = useState(() => new AllSetting()); const [extraSpinning, setExtraSpinning] = useState(false); const query = useQuery({ @@ -28,41 +32,54 @@ export function useAllSettings() { }); const server = useMemo(() => new AllSetting(query.data), [query.data]); - - useEffect(() => { - if (query.data !== undefined) { - setDraft(new AllSetting(query.data)); - } - }, [query.data]); + const { draft, setDraft, isDirty, markSaved } = useServerDraft( + query.data === undefined ? undefined : server, + (setting) => new AllSetting(setting), + (left, right) => left.equals(right), + ); + const allSetting = draft ?? server; const updateSetting = useCallback((patch: Partial) => { setDraft((prev) => { - const next = new AllSetting(prev); + const next = new AllSetting(prev ?? server); Object.assign(next, patch); return next; }); - }, []); + }, [server, setDraft]); const saveMut = useMutation({ - mutationFn: async (next: SettingSavePayload): Promise> => { - const payload = { ...next }; - const body = AllSettingSchema.partial().safeParse(payload); + mutationFn: async ({ payload, saved }: { payload: SettingSavePayload; saved?: AllSetting }): Promise => { + const next = { ...payload }; + const body = AllSettingSchema.partial().safeParse(next); if (!body.success) { console.warn('[zod] setting/update body failed validation', body.error.issues); } - return HttpUtil.post('/panel/api/setting/update', body.success ? { ...payload, ...body.data } : payload); + const msg = await HttpUtil.post('/panel/api/setting/update', body.success ? { ...next, ...body.data } : next); + return { msg, saved }; }, - onSuccess: (msg) => { - if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() }); + onSuccess: ({ msg, saved }) => { + if (!msg?.success) return; + if (saved) markSaved(saved); + queryClient.invalidateQueries({ queryKey: keys.settings.all() }); }, }); - const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]); - const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]); - const saveDisabled = useMemo(() => server.equals(draft), [server, draft]); + const saveAll = useCallback(async () => { + const saved = new AllSetting(allSetting); + return (await saveMut.mutateAsync({ payload: { ...saved }, saved })).msg; + }, [allSetting, saveMut]); + const savePayload = useCallback( + async (payload: SettingSavePayload) => { + const saved = new AllSetting(allSetting); + Object.assign(saved, payload); + return (await saveMut.mutateAsync({ payload, saved })).msg; + }, + [allSetting, saveMut], + ); + const saveDisabled = !isDirty; return { - allSetting: draft, + allSetting, updateSetting, fetched: query.data !== undefined, spinning: extraSpinning || saveMut.isPending, diff --git a/frontend/src/hooks/useServerDraft.ts b/frontend/src/hooks/useServerDraft.ts new file mode 100644 index 000000000..acfb3cf41 --- /dev/null +++ b/frontend/src/hooks/useServerDraft.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +export function useServerDraft(server: T | undefined, clone: (value: T) => T, equals: (left: T, right: T) => boolean) { + const cloneRef = useRef(clone); + const equalsRef = useRef(equals); + cloneRef.current = clone; + equalsRef.current = equals; + + const [draft, setDraft] = useState(); + const [baseline, setBaseline] = useState(); + const draftRef = useRef(draft); + const baselineRef = useRef(baseline); + draftRef.current = draft; + baselineRef.current = baseline; + + useEffect(() => { + if (server === undefined) return; + const currentDraft = draftRef.current; + const currentBaseline = baselineRef.current; + const isDirty = currentDraft !== undefined + && (currentBaseline === undefined || !equalsRef.current(currentDraft, currentBaseline)); + setBaseline(server); + if (isDirty && !equalsRef.current(currentDraft, server)) return; + setDraft(cloneRef.current(server)); + }, [server]); + + const markSaved = useCallback((value: T) => { + setBaseline(cloneRef.current(value)); + }, []); + + const isDirty = useMemo( + () => draft !== undefined && (baseline === undefined || !equalsRef.current(draft, baseline)), + [baseline, draft], + ); + + return { draft, setDraft, isDirty, markSaved }; +} diff --git a/frontend/src/hooks/useXraySetting.ts b/frontend/src/hooks/useXraySetting.ts index fb25fbaa4..d1f636feb 100644 --- a/frontend/src/hooks/useXraySetting.ts +++ b/frontend/src/hooks/useXraySetting.ts @@ -14,7 +14,6 @@ import { type OutboundTrafficRow, } from '@/schemas/xray'; -const DIRTY_POLL_MS = 1000; const DEFAULT_TEST_URL = 'https://www.google.com/generate_204'; // One HTTP-mode batch request tests this many outbounds through a single // shared temp xray instance; chunking keeps responses bounded (~30s worst @@ -22,6 +21,10 @@ const DEFAULT_TEST_URL = 'https://www.google.com/generate_204'; // results progressively. const HTTP_BATCH_CHUNK = 16; +function normalizeOutboundTestUrl(url: string) { + return url || DEFAULT_TEST_URL; +} + export function isUdpOutbound(outbound: unknown): boolean { const o = outbound as { protocol?: string; streamSettings?: { network?: string } } | null | undefined; const p = o?.protocol; @@ -125,10 +128,11 @@ export function useXraySetting(): UseXraySettingResult { staleTime: Infinity, }); - const [saveDisabled, setSaveDisabled] = useState(true); const [xraySetting, setXraySettingState] = useState(''); const [templateSettings, setTemplateSettingsState] = useState(null); const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL); + const [savedXraySetting, setSavedXraySetting] = useState(''); + const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL); const [inboundTags, setInboundTags] = useState([]); const [clientReverseTags, setClientReverseTags] = useState([]); const [subscriptionOutbounds, setSubscriptionOutbounds] = useState([]); @@ -139,38 +143,40 @@ export function useXraySetting(): UseXraySettingResult { const [subscriptionTestStates, setSubscriptionTestStates] = useState>({}); const [testingAll, setTestingAll] = useState(false); - const oldXraySettingRef = useRef(''); - const oldOutboundTestUrlRef = useRef(''); const syncingRef = useRef(false); const xraySettingRef = useRef(''); const outboundTestUrlRef = useRef(outboundTestUrl); + const savedXraySettingRef = useRef(savedXraySetting); + const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl); const templateSettingsRef = useRef(null); const subscriptionOutboundsRef = useRef([]); xraySettingRef.current = xraySetting; outboundTestUrlRef.current = outboundTestUrl; + savedXraySettingRef.current = savedXraySetting; + savedOutboundTestUrlRef.current = savedOutboundTestUrl; templateSettingsRef.current = templateSettings; subscriptionOutboundsRef.current = subscriptionOutbounds; - // Seed local editor state from the config query. Runs on first fetch and - // every time the query refetches (e.g. after a successful save). useEffect(() => { if (!configQuery.data) return; const obj = configQuery.data; const pretty = JSON.stringify(obj.xraySetting, null, 2); - syncingRef.current = true; - setXraySettingState(pretty); - setTemplateSettingsState(obj.xraySetting); - oldXraySettingRef.current = pretty; - syncingRef.current = false; + const nextUrl = normalizeOutboundTestUrl(obj.outboundTestUrl || ''); setInboundTags(obj.inboundTags || []); setClientReverseTags(obj.clientReverseTags || []); setSubscriptionOutbounds(obj.subscriptionOutbounds || []); setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []); - const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL; + const isDirty = savedXraySettingRef.current !== xraySettingRef.current + || savedOutboundTestUrlRef.current !== normalizeOutboundTestUrl(outboundTestUrlRef.current); + if (isDirty) return; + syncingRef.current = true; + setXraySettingState(pretty); + setTemplateSettingsState(obj.xraySetting); + setSavedXraySetting(pretty); + syncingRef.current = false; setOutboundTestUrlState(nextUrl); - oldOutboundTestUrlRef.current = nextUrl; - setSaveDisabled(true); + setSavedOutboundTestUrl(nextUrl); }, [configQuery.data]); const fetched = configQuery.data !== undefined || configQuery.isError; @@ -220,7 +226,7 @@ export function useXraySetting(): UseXraySettingResult { const saveMut = useMutation({ mutationFn: async () => { const sentXraySetting = xraySettingRef.current; - const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL; + const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current); const msg = await HttpUtil.post('/panel/api/xray/update', { xraySetting: sentXraySetting, outboundTestUrl: sentTestUrl, @@ -229,9 +235,8 @@ export function useXraySetting(): UseXraySettingResult { }, onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => { if (!msg?.success) return; - oldXraySettingRef.current = sentXraySetting; - oldOutboundTestUrlRef.current = sentTestUrl; - setSaveDisabled(true); + setSavedXraySetting(sentXraySetting); + setSavedOutboundTestUrl(sentTestUrl); queryClient.invalidateQueries({ queryKey: keys.xray.config() }); }, }); @@ -425,14 +430,8 @@ export function useXraySetting(): UseXraySettingResult { } }, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]); - useEffect(() => { - const timer = window.setInterval(() => { - const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current; - const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current; - setSaveDisabled(!(dirtyXray || dirtyUrl)); - }, DIRTY_POLL_MS); - return () => window.clearInterval(timer); - }, []); + const saveDisabled = savedXraySetting === xraySetting + && savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl); const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]); diff --git a/frontend/src/test/use-all-settings.test.tsx b/frontend/src/test/use-all-settings.test.tsx index f4c50c040..bf64a7a40 100644 --- a/frontend/src/test/use-all-settings.test.tsx +++ b/frontend/src/test/use-all-settings.test.tsx @@ -1,9 +1,10 @@ import type { ReactNode } from 'react'; -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { QueryClientProvider } from '@tanstack/react-query'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { useAllSettings } from '@/api/queries/useAllSettings'; +import { keys } from '@/api/queryKeys'; import { makeTestQueryClient } from '@/test/test-utils'; import { HttpUtil, Msg } from '@/utils'; @@ -25,4 +26,80 @@ describe('useAllSettings', () => { await waitFor(() => expect(result.current.fetched).toBe(true)); expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex); }); + + it('keeps an edited setting when a refetch returns older server data', async () => { + const values = [ + { webPort: 2053 }, + { webPort: 2054 }, + ]; + let index = 0; + vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', values[index++])); + const queryClient = makeTestQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useAllSettings(), { wrapper }); + + await waitFor(() => expect(result.current.fetched).toBe(true)); + act(() => result.current.updateSetting({ webPort: 3000 })); + await queryClient.invalidateQueries({ queryKey: keys.settings.all() }); + + await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2)); + expect(result.current.allSetting.webPort).toBe(3000); + expect(result.current.saveDisabled).toBe(false); + }); + + it('hydrates redacted secrets after a successful save', async () => { + let fetchCount = 0; + vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => { + if (url === '/panel/api/setting/all') { + fetchCount += 1; + return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' }); + } + return new Msg(true, ''); + }); + const queryClient = makeTestQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useAllSettings(), { wrapper }); + + await waitFor(() => expect(result.current.fetched).toBe(true)); + act(() => result.current.updateSetting({ tgBotToken: 'secret' })); + await act(async () => { + await result.current.saveAll(); + }); + + await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3)); + expect(result.current.allSetting.tgBotToken).toBe(''); + expect(result.current.allSetting.hasTgBotToken).toBe(true); + expect(result.current.saveDisabled).toBe(true); + }); + + it('establishes a saved baseline for a full-payload security save', async () => { + let fetchCount = 0; + vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => { + if (url === '/panel/api/setting/all') { + fetchCount += 1; + return new Msg(true, '', fetchCount === 1 ? { hasTgBotToken: false } : { hasTgBotToken: true, tgBotToken: '' }); + } + return new Msg(true, ''); + }); + const queryClient = makeTestQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useAllSettings(), { wrapper }); + + await waitFor(() => expect(result.current.fetched).toBe(true)); + act(() => result.current.updateSetting({ tgBotToken: 'secret' })); + await act(async () => { + await result.current.savePayload({ ...result.current.allSetting, twoFactorEnable: false, twoFactorToken: '' }); + }); + + await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(3)); + expect(result.current.allSetting.tgBotToken).toBe(''); + expect(result.current.allSetting.hasTgBotToken).toBe(true); + expect(result.current.saveDisabled).toBe(true); + }); }); diff --git a/frontend/src/test/use-xray-setting.test.tsx b/frontend/src/test/use-xray-setting.test.tsx new file mode 100644 index 000000000..f2ee49258 --- /dev/null +++ b/frontend/src/test/use-xray-setting.test.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useXraySetting } from '@/hooks/useXraySetting'; +import { makeTestQueryClient } from '@/test/test-utils'; +import { HttpUtil, Msg } from '@/utils'; + +function xrayPayload(overrides: Record = {}) { + return { + xraySetting: {}, + inboundTags: [], + clientReverseTags: [], + outboundTestUrl: 'https://test.example', + subscriptionOutbounds: [], + subscriptionOutboundTags: [], + ...overrides, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +beforeEach(() => { + vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', [])); +}); + +describe('useXraySetting', () => { + it('refreshes server-derived outbounds while the editor is dirty', async () => { + let payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'before' }] }); + vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => { + if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload)); + return new Msg(true, ''); + }); + const queryClient = makeTestQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useXraySetting(), { wrapper }); + + await waitFor(() => expect(result.current.fetched).toBe(true)); + act(() => result.current.setXraySetting('{"outbounds":[]}')); + payload = xrayPayload({ subscriptionOutbounds: [{ tag: 'after' }] }); + await act(async () => result.current.fetchAll()); + + await waitFor(() => expect(result.current.subscriptionOutbounds).toEqual([{ tag: 'after' }])); + expect(result.current.xraySetting).toBe('{"outbounds":[]}'); + }); + + it('keeps the outbound test URL input empty when it is cleared', async () => { + const payload = xrayPayload({ outboundTestUrl: 'https://www.google.com/generate_204' }); + vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => { + if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload)); + return new Msg(true, ''); + }); + const queryClient = makeTestQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useXraySetting(), { wrapper }); + + await waitFor(() => expect(result.current.fetched).toBe(true)); + act(() => result.current.setOutboundTestUrl('')); + + expect(result.current.outboundTestUrl).toBe(''); + expect(result.current.saveDisabled).toBe(true); + }); +}); diff --git a/frontend/src/test/useServerDraft.test.tsx b/frontend/src/test/useServerDraft.test.tsx new file mode 100644 index 000000000..93d3d5d42 --- /dev/null +++ b/frontend/src/test/useServerDraft.test.tsx @@ -0,0 +1,86 @@ +import { StrictMode } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { useServerDraft } from '@/hooks/useServerDraft'; + +describe('useServerDraft', () => { + it('keeps an edited draft when the server refetches', () => { + const { result, rerender } = renderHook( + ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value), + { initialProps: { server: { value: 'one' } } }, + ); + + act(() => result.current.setDraft({ value: 'edited' })); + rerender({ server: { value: 'two' } }); + + expect(result.current.draft).toEqual({ value: 'edited' }); + expect(result.current.isDirty).toBe(true); + }); + + it('accepts a refetch that matches the saved draft', () => { + const { result, rerender } = renderHook( + ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value), + { initialProps: { server: { value: 'one' } } }, + ); + + act(() => result.current.setDraft({ value: 'saved' })); + rerender({ server: { value: 'saved' } }); + + expect(result.current.draft).toEqual({ value: 'saved' }); + expect(result.current.isDirty).toBe(false); + }); + + it('compares a preserved draft with the latest server value', () => { + const { result, rerender } = renderHook( + ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value), + { initialProps: { server: { value: 'one' } } }, + ); + + act(() => result.current.setDraft({ value: 'later' })); + rerender({ server: { value: 'saved' } }); + act(() => result.current.setDraft({ value: 'one' })); + + expect(result.current.isDirty).toBe(true); + }); + + it('hydrates clean drafts under StrictMode', () => { + const { result, rerender } = renderHook( + ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value), + { + initialProps: { server: { value: 'one' } }, + wrapper: StrictMode, + }, + ); + + rerender({ server: { value: 'two' } }); + + expect(result.current.draft).toEqual({ value: 'two' }); + expect(result.current.isDirty).toBe(false); + }); + + it('preserves an edit made before the first server response', () => { + const { result, rerender } = renderHook( + ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value), + { initialProps: { server: undefined as { value: string } | undefined } }, + ); + + act(() => result.current.setDraft({ value: 'edited' })); + rerender({ server: { value: 'one' } }); + + expect(result.current.draft).toEqual({ value: 'edited' }); + expect(result.current.isDirty).toBe(true); + }); + + it('marks a sent draft clean before its refetch arrives', () => { + const { result } = renderHook( + ({ server }) => useServerDraft(server, (value) => ({ ...value }), (left, right) => left.value === right.value), + { initialProps: { server: { value: 'one' } } }, + ); + + act(() => result.current.setDraft({ value: 'saved' })); + act(() => result.current.markSaved({ value: 'saved' })); + + expect(result.current.isDirty).toBe(false); + }); +});