Explorar o código

fix(inbound): keep persisted node share strategy on edit (#5375)

Opening the edit modal silently reverted shareAddrStrategy from 'node' to
'listen'. The downgrade effect fires before the form settles: availableNodes
is an empty placeholder until /nodes/list resolves, and Form.useWatch('protocol')
is briefly empty on the first edit render — both transiently make the node
option look unavailable, so the effect clobbered the saved value.

Gate the downgrade on availableNodesFetched (threaded from useNodesQuery through
InboundsPage) and on the protocol watch being settled, so a persisted strategy
is only downgraded when the node option is genuinely unavailable. Adds a
rerender-based regression test covering the nodes-loading race.
MHSanaei hai 12 horas
pai
achega
a0ea9c9f45

+ 2 - 1
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -100,7 +100,7 @@ export default function InboundsPage() {
   const [messageApi, messageContextHolder] = message.useMessage();
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
 
-  const { nodes: nodesList } = useNodesQuery();
+  const { nodes: nodesList, fetched: nodesFetched } = useNodesQuery();
   const nodesById = useMemo(() => {
     const map = new Map<number, ReturnType<typeof useNodesQuery>['nodes'][number]>();
     for (const n of nodesList || []) map.set(n.id, n);
@@ -647,6 +647,7 @@ export default function InboundsPage() {
             dbInbound={formDbInbound}
             dbInbounds={dbInbounds}
             availableNodes={nodesList}
+            availableNodesFetched={nodesFetched}
           />
         </LazyMount>
         <LazyMount when={infoOpen}>

+ 11 - 1
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -138,6 +138,7 @@ interface InboundFormModalProps {
   dbInbound: DBInbound | null;
   dbInbounds: DBInbound[];
   availableNodes?: NodeRecord[];
+  availableNodesFetched?: boolean;
 }
 
 function buildAddModeValues(): InboundFormValues {
@@ -167,6 +168,7 @@ export default function InboundFormModal({
   dbInbound,
   dbInbounds,
   availableNodes,
+  availableNodesFetched = true,
 }: InboundFormModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
@@ -373,14 +375,22 @@ export default function InboundFormModal({
   // offered (no node, or a protocol that can't deploy to one) fall back to
   // `listen`, which yields the same link for a local inbound. Mirrors how the
   // protocol reset drops a nodeId that no longer applies.
+  // Only downgrade once the inputs this decision depends on are settled, so a
+  // persisted `node` strategy is never clobbered by transient mount state (#5375):
+  //  - `availableNodesFetched`: an empty `availableNodes` during the async
+  //    /nodes/list fetch is a placeholder, not "no nodes".
+  //  - `protocol`: `Form.useWatch('protocol')` is briefly empty on the first
+  //    edit render before initialValues apply, which would momentarily make the
+  //    node option look unavailable.
   useEffect(() => {
     if (!open) return;
+    if (!availableNodesFetched || !protocol) return;
     const current = form.getFieldValue('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
     if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
       form.setFieldValue('shareAddrStrategy', 'listen');
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [open, nodeShareOptionAvailable, shareAddrStrategy]);
+  }, [open, availableNodesFetched, protocol, nodeShareOptionAvailable, shareAddrStrategy]);
 
   // Why: protocol picker reset cascades through the form — clearing the
   // settings DU branch and dropping a nodeId that no longer applies. The

+ 52 - 1
frontend/src/test/inbound-form-modal.test.tsx

@@ -1,8 +1,9 @@
 import { describe, it, expect } from 'vitest';
-import { screen, act } from '@testing-library/react';
+import { screen, act, render, cleanup } from '@testing-library/react';
 
 import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
 import { DBInbound } from '@/models/dbinbound';
+import { ThemeProvider } from '@/hooks/useTheme';
 import {
   renderWithProviders,
   fieldLabels,
@@ -90,4 +91,54 @@ describe('InboundFormModal', () => {
     const shareAddrInput = await screen.findByDisplayValue('edge.example.test');
     expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test');
   });
+
+  it('keeps the persisted node share strategy through the nodes-loading race (#5375)', async () => {
+    const node = { id: 1, name: 'arm2', enable: true, status: 'online' } as never;
+    const buildInbound = () => new DBInbound({
+      id: 1,
+      port: 23456,
+      listen: '',
+      protocol: 'vless',
+      remark: 'noded',
+      enable: true,
+      settings: { clients: [] },
+      streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
+      sniffing: { enabled: false },
+      nodeId: 1,
+      shareAddrStrategy: 'node',
+    });
+    const flush = async () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); };
+    const strategyItem = (title: string) =>
+      document.querySelector(`.ant-select-content[title="${title}"]`);
+    const modal = (nodes: never[], fetched: boolean) => (
+      <ThemeProvider>
+        <InboundFormModal
+          open
+          mode="edit"
+          dbInbound={buildInbound()}
+          dbInbounds={[]}
+          availableNodes={nodes}
+          availableNodesFetched={fetched}
+          onClose={() => {}}
+          onSaved={() => {}}
+        />
+      </ThemeProvider>
+    );
+
+    // Baseline: nodes already loaded, so the node option is offered and selected.
+    render(modal([node], true));
+    await flush();
+    expect(strategyItem('Node address')).toBeTruthy();
+    cleanup();
+
+    // Race: the modal mounts before /nodes/list resolves (empty placeholder),
+    // then nodes arrive. The persisted 'node' strategy must survive the gap and
+    // stay selected once the option reappears — not silently revert to listen.
+    const { rerender } = render(modal([], false));
+    await flush();
+    rerender(modal([node], true));
+    await flush();
+    expect(strategyItem('Node address')).toBeTruthy();
+    expect(strategyItem('Inbound listen')).toBeFalsy();
+  });
 });