This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 5a619f28b fix(web): stop stale async results from leaking across 
scopes (#6069)
5a619f28b is described below

commit 5a619f28b1ab20000409157d27ca059bd6726762
Author: Loyal-Young <[email protected]>
AuthorDate: Mon Oct 12 15:31:55 2026 +0800

    fix(web): stop stale async results from leaking across scopes (#6069)
    
    Five fixes from the same author for requests and cached state that survive 
the scope they
    were issued in — an unmounted page, a bumped request generation, a changed 
instance, a
    shrunk total, a replaced file — folded into a single commit.
    
    - #6051 `web/src/pages/ops/ResourceOperationTimelineDrawer.tsx:180` renders 
the timeline and
      its pager together only when `records.length > 0`, while `page` lives in 
the drawer component
      itself (`:79`) and `destroyOnHidden` (`:143`) destroys the content but 
not that component, so
      after audit retention or cleanup shrinks `total` below the page the user 
is on, the drawer
      shows an empty state with no pager and the remaining records stay 
permanently unreachable —
      only a date-range change resets to page 1 (`:169`); clamps `page` to
      `Math.max(1, Math.ceil(total / pageSize))` in the `listAuditRecords` 
then-handler right after
      the `requestVersion` check and lets the existing `page` effect dependency 
re-query, which is
      the idiom trunk already uses in `ops/alerts.tsx:375-380`,
      `settings/CloudCredentialTab.tsx:103-108`, 
`settings/DataSourceTab.tsx:160` and two more.
    - #6058 `web/src/components/QueueBrowser.tsx:64-76` gives the `[instanceId, 
topic]` scope effect
      no cleanup, and `requestSeqRef` is advanced only by the effect body 
(`:65`) and `loadQueues`
      (`:81`), so a queue load or message pull still in flight when the message 
page unmounts passes
      every `requestId === requestSeqRef.current` gate (`:101 :105 :134 :138`) 
and fires
      `message.error` — antd's global static API — on a page the user already 
left, along with
      `setLoading`/`setEntries` on an unmounted component; returns
      `() => { requestSeqRef.current += 1; }` from that effect, the unmount 
invalidation trunk
      already has in `settings/CloudCredentialTab.tsx:132-137`, 
`settings/DataSourceTab.tsx:186-191`
      and `studio/UserManagement.tsx:202-208`.
    - #6060 `web/src/components/MetricsExplorer.tsx:919` clears 
`pendingDataSource` only when the
      *activated* `dataSourceKey` drops out of `availableDataSources`, but a 
pending source is set by
      `handleDataSourceChange` (`:850-857`) and by the protected-restore path 
without ever touching
      `dataSourceKey`, so the credential modal can stay open on a source the 
current instance no
      longer offers and submitting it makes `activateDataSource` (`:841-843`) 
store a key that
      `runQuery` (`:614-622`) then discards, falling back to `''` and dropping 
the credentials while
      the UI still shows that source as selected; adds an effect that routes an 
out-of-scope
      `pendingDataSource` through the existing cancel path, re-validates the 
key at the top of
      `handleAuthSubmit` (`:860-861`), and syncs `rangeIdRef` when a replay 
checkpoint is restored
      (`:888`).
    - #6068 `web/src/services/instanceService.ts:40` cleans the in-flight dedup 
cache with an
      unconditional `finally(() => inflightListRequests.delete(key))`, so when
      `invalidateInflightListRequests()` (`:29-31`) clears the map after a 
mutation and a newer read
      repopulates the same key, the older request settling evicts the newer one 
and every later
      caller issues its own list GET — breaking the "a read after a write must 
not join a stale
      snapshot" invariant this module documents at `:26-28`; deletes only when 
the cached entry is
      still the same promise reference.
    - #6069 `web/src/pages/instance/consumer.tsx:783-802` and 
`web/src/pages/instance/topic.tsx:1289-1308`
      set the filename and open the import modal before awaiting `file.text()`, 
leaving the previous
      file's `importRows` in state; the OK button's disabled condition 
(`consumer.tsx:2480-2485`,
      `topic.tsx:1937-1942`) treats those pending rows as importable, so one 
click during the read
      window bulk-creates file A's payload under file B's name through
      `handleImportConsumerGroups`/`handleImportTopics`; clears 
`importRows`/`importErrors` and
      resets the file input before the await instead of in `finally`, adds an 
`importReadIdRef`
      generation checked on both the success and the failure path, and advances 
it from the modal's
      `onCancel` (`consumer.tsx:2473-2475`, `topic.tsx:1930-1932`) and on 
unmount.
    
    Eighteen new cases (2 for #6051, 4 for #6058, 3 for #6060, 3 for #6068, 6 
for #6069 across the
    Topic and Consumer page suites), all mutation-sensitive: reverting the page 
clamp means the third
    `listAuditRecords` call never happens and the `waitFor` times out, 
reverting the QueueBrowser
    cleanup makes the two abandoned cases toast once instead of never, 
reverting the pending-source
    effect leaves the credential dialog on screen for both the picker and the 
query-history entry
    points, reverting the conditional delete makes the post-mutation shared 
read issue a third list
    call, and reverting either the pre-await row clearing or the generation 
guard brings
    `previous-resource` back into the preview and lets the stale payload reach 
the bulk-create call —
    which the import cases assert on directly, checking the service receives 
the current file's rows
    and the selected instance. Every member also has a positive control (a 
mounted page still
    toasts, an in-scope source still submits, an older read still settles 
without leaving a residual
    cache entry, the current file still imports), so an over-applied guard 
fails as well.
    
    Folded: #6051, #6058, #6060, #6068 were closed as folded into this commit 
(same author).
---
 web/src/components/MetricsExplorer.tsx             |  19 +++-
 web/src/components/QueueBrowser.tsx                |   4 +
 .../components/__tests__/MetricsExplorer.test.tsx  | 112 ++++++++++++++++++++-
 web/src/components/__tests__/QueueBrowser.test.tsx |  62 +++++++++++-
 .../pages/instance/__tests__/ConsumerPage.test.tsx |  81 +++++++++++++++
 .../pages/instance/__tests__/TopicPage.test.tsx    |  83 +++++++++++++++
 web/src/pages/instance/consumer.tsx                |  22 +++-
 web/src/pages/instance/topic.tsx                   |  22 +++-
 .../pages/ops/ResourceOperationTimelineDrawer.tsx  |   5 +
 .../ResourceOperationTimelineDrawer.test.tsx       |  31 ++++++
 web/src/services/instanceService.test.ts           |  56 +++++++++++
 web/src/services/instanceService.ts                |   5 +-
 12 files changed, 491 insertions(+), 11 deletions(-)

diff --git a/web/src/components/MetricsExplorer.tsx 
b/web/src/components/MetricsExplorer.tsx
index 6c3d90b32..39811374a 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -859,6 +859,10 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
 
   const handleAuthSubmit = (values: AuthFormValues) => {
     if (!pendingDataSource) return;
+    if (!availableDataSources.some((source) => source.key === 
pendingDataSource.key)) {
+      handleAuthCancel();
+      return;
+    }
     const authMode = getDataSourceAuthMode(pendingDataSource.auth);
     const credentials =
       authMode === 'basic'
@@ -877,7 +881,7 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
     authForm.resetFields();
   };
 
-  const handleAuthCancel = () => {
+  const handleAuthCancel = useCallback(() => {
     const replay = pendingAuthReplayRef.current;
     pendingAuthReplayRef.current = null;
     if (replay) {
@@ -886,6 +890,7 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
       // previous source's data and nothing ever queries them.
       setProfileId(replay.checkpoint.profileId);
       setRangeId(replay.checkpoint.rangeId);
+      rangeIdRef.current = replay.checkpoint.rangeId;
       setCustomPromql(replay.checkpoint.customPromql);
       if (replay.checkpoint.storedProfileId === null) {
         localStorage.removeItem(PROFILE_STORAGE_KEY);
@@ -895,7 +900,17 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
     }
     setPendingDataSource(null);
     authForm.resetFields();
-  };
+  }, [authForm]);
+
+  useEffect(() => {
+    if (
+      pendingDataSource &&
+      !availableDataSources.some((source) => source.key === 
pendingDataSource.key)
+    ) {
+      // A pending source is separate from the activated key checked by the 
fallback below.
+      handleAuthCancel();
+    }
+  }, [availableDataSources, handleAuthCancel, pendingDataSource]);
 
   useEffect(() => {
     let cancelled = false;
diff --git a/web/src/components/QueueBrowser.tsx 
b/web/src/components/QueueBrowser.tsx
index 9d73e4181..26adcab79 100644
--- a/web/src/components/QueueBrowser.tsx
+++ b/web/src/components/QueueBrowser.tsx
@@ -73,6 +73,10 @@ export const useQueueBrowser = (instanceId?: string) => {
       pullingRef.current.clear();
       setPulling(new Set());
     });
+    return () => {
+      // Leaving the page invalidates pending loads and pulls just like a 
scope change.
+      requestSeqRef.current += 1;
+    };
   }, [instanceId, topic]);
 
   const loadQueues = useCallback(async () => {
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx 
b/web/src/components/__tests__/MetricsExplorer.test.tsx
index 3ea7711e1..2f92cbb08 100644
--- a/web/src/components/__tests__/MetricsExplorer.test.tsx
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { App } from 'antd';
+import { App, ConfigProvider } from 'antd';
 import { render, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import type React from 'react';
@@ -859,6 +859,116 @@ describe('MetricsExplorer', () => {
     
expect(vi.mocked(queryMetrics).mock.calls.length).toBe(queryMetricsCallsBefore);
   });
 
+  const authenticationView = (instanceId: string) => (
+    <App>
+      <ConfigProvider theme={{ token: { motion: false } }}>
+        <LangProvider>
+          <MetricsExplorer instanceId={instanceId} />
+        </LangProvider>
+      </ConfigProvider>
+    </App>
+  );
+
+  it.each(['picker', 'history'] as const)(
+    'cancelsPendingAuthenticationWhenItsSourceLeavesScopeTest (%s)',
+    async (origin) => {
+      const user = userEvent.setup();
+      vi.mocked(listDataSources).mockResolvedValue([
+        {
+          key: 'ds-basic',
+          name: 'Protected Prometheus',
+          type: 'Prometheus',
+          url: '',
+          auth: 'Basic Auth',
+          status: 'healthy',
+          instanceIds: ['instance-1'],
+        },
+      ]);
+      localStorage.setItem(
+        METRICS_QUERY_HISTORY_STORAGE_KEY,
+        JSON.stringify([
+          createHistoryEntry({ dataSourceKey: 'ds-basic', dataSourceName: 
'Protected Prometheus' }),
+        ]),
+      );
+      const view = render(authenticationView('instance-1'));
+      await screen.findByRole('img', { name: 'Message In TPS time series' });
+      if (origin === 'picker') {
+        await user.click(screen.getByRole('combobox', { name: '数据源' }));
+        await user.click(
+          await screen.findByText('Protected Prometheus', {
+            selector: '.ant-select-item-option-content',
+          }),
+        );
+      } else {
+        await user.click(screen.getByRole('button', { name: '查询历史' }));
+        const dialog = await screen.findByRole('dialog', { name: '指标查询历史' });
+        const row = within(dialog).getByText('Consumer Lag 
Messages').closest('.ant-list-item')!;
+        await user.click(within(row as HTMLElement).getByRole('button', { 
name: '恢复' }));
+      }
+      await screen.findByText('凭据仅用于当前数据源,离开该数据源后会被清除。');
+      view.rerender(authenticationView('instance-2'));
+      await waitFor(() =>
+        expect(screen.queryByRole('textbox', { name: '用户名' 
})).not.toBeInTheDocument(),
+      );
+      const profile = screen.getByRole('combobox', { name: '指标模板' 
}).closest('.ant-select')!;
+      expect(within(profile as HTMLElement).getByText('RocketMQ 5.x 
Native')).toBeInTheDocument();
+      expect(
+        
screen.getByLabelText('时间范围').querySelector('.ant-segmented-item-selected'),
+      ).toHaveTextContent('1h');
+      await waitFor(() =>
+        expect(queryMetrics).toHaveBeenLastCalledWith(
+          expect.objectContaining({ metric: profiles[0].metrics[0].promql }),
+        ),
+      );
+      const calls = vi.mocked(queryMetrics).mock.calls;
+      const query = calls[calls.length - 1][0];
+      expect(query.end! - query.start!).toBe(3600);
+      expect(queryByDataSource).not.toHaveBeenCalled();
+    },
+  );
+
+  it('canAuthenticateAGlobalSourceAfterTheInstanceChangesTest', async () => {
+    const user = userEvent.setup();
+    vi.mocked(listDataSources).mockResolvedValue([
+      {
+        key: 'global-basic',
+        name: 'Global Protected',
+        type: 'Prometheus',
+        url: '',
+        auth: 'Basic Auth',
+        status: 'healthy',
+      },
+    ]);
+    const view = renderWithProviders(<MetricsExplorer instanceId="instance-1" 
/>);
+    await user.click(await screen.findByRole('combobox', { name: '数据源' }));
+    await user.click(
+      await screen.findByText('Global Protected', {
+        selector: '.ant-select-item-option-content',
+      }),
+    );
+    await screen.findByLabelText('用户名');
+    view.rerender(
+      <App>
+        <LangProvider>
+          <MetricsExplorer instanceId="instance-2" />
+        </LangProvider>
+      </App>,
+    );
+    await user.type(screen.getByLabelText('用户名'), 'reader');
+    await user.type(screen.getByLabelText('密码'), 'test-password');
+    await user.click(screen.getByRole('button', { name: /连\s*接/ }));
+    await waitFor(() =>
+      expect(queryByDataSource).toHaveBeenCalledWith(
+        expect.objectContaining({
+          key: 'global-basic',
+          username: 'reader',
+          password: 'test-password',
+          instanceId: 'instance-2',
+        }),
+      ),
+    );
+  });
+
   it('prompts for basic credentials and supplies them only to the selected 
data source query', async () => {
     const user = userEvent.setup();
     vi.mocked(listDataSources).mockResolvedValue([
diff --git a/web/src/components/__tests__/QueueBrowser.test.tsx 
b/web/src/components/__tests__/QueueBrowser.test.tsx
index 5fbd0e679..0285171c3 100644
--- a/web/src/components/__tests__/QueueBrowser.test.tsx
+++ b/web/src/components/__tests__/QueueBrowser.test.tsx
@@ -17,7 +17,9 @@
 
 import { act, fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
 import userEvent from '@testing-library/user-event';
-import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 
'vitest';
+import { StrictMode } from 'react';
+import { message } from 'antd';
 import type { MessageRecord, QueueOffset } from '../../api/message';
 import { getQueueOffsets, pullMessageAtOffset } from '../../api/message';
 import { QueueBrowserResults, useQueueBrowser } from '../QueueBrowser';
@@ -130,6 +132,64 @@ describe('QueueBrowser request ownership', () => {
     vi.clearAllMocks();
   });
 
+  describe('page lifetime', () => {
+    afterEach(() => vi.restoreAllMocks());
+    it.each([
+      { operation: 'load', abandoned: true },
+      { operation: 'pull', abandoned: true },
+      { operation: 'load', abandoned: false },
+      { operation: 'pull', abandoned: false },
+    ])(
+      'onlyReportsFailuresOwnedByTheMountedPageTest ($operation/$abandoned)',
+      async ({ operation, abandoned }) => {
+        const errorToast = vi
+          .spyOn(message, 'error')
+          .mockImplementation(() => (() => {}) as ReturnType<typeof 
message.error>);
+        let reject!: (error: Error) => void;
+        const pending = new Promise<never>((_resolve, rejectPromise) => {
+          reject = rejectPromise;
+        });
+        vi.mocked(getQueueOffsets).mockResolvedValue([queue('broker-a')]);
+        
vi.mocked(pullMessageAtOffset).mockResolvedValue(messageRecord('fresh-message'));
+        if (operation === 'load') 
vi.mocked(getQueueOffsets).mockReturnValueOnce(pending);
+        else vi.mocked(pullMessageAtOffset).mockReturnValueOnce(pending);
+        const user = userEvent.setup();
+        const view = render(
+          <StrictMode>
+            <QueueBrowserProbe />
+          </StrictMode>,
+        );
+        await user.click(screen.getByRole('button', { name: 'topic-a' }));
+        await user.click(screen.getByRole('button', { name: 'load' }));
+        if (operation === 'pull') {
+          await waitFor(() =>
+            
expect(screen.getByLabelText('queues')).toHaveTextContent('broker-a'),
+          );
+          await user.click(screen.getByRole('button', { name: 'pull' }));
+        }
+        if (abandoned) view.unmount();
+        await act(async () => {
+          reject(new Error('queue request failed'));
+        });
+        expect(errorToast).toHaveBeenCalledTimes(abandoned ? 0 : 1);
+        if (!abandoned) view.unmount();
+        render(
+          <StrictMode>
+            <QueueBrowserProbe />
+          </StrictMode>,
+        );
+        await user.click(screen.getByRole('button', { name: 'topic-a' }));
+        await user.click(screen.getByRole('button', { name: 'load' }));
+        await waitFor(() => 
expect(screen.getByLabelText('queues')).toHaveTextContent('broker-a'));
+        await user.click(screen.getByRole('button', { name: 'pull' }));
+        await waitFor(() =>
+          
expect(screen.getByLabelText('entries')).toHaveTextContent('fresh-message'),
+        );
+        expect(screen.getByLabelText('pulling')).toHaveTextContent('false');
+      },
+    );
+  });
+
   it('keeps the newest topic queue load when an older request resolves later', 
async () => {
     const topicA = createDeferred<QueueOffset[]>();
     const topicB = createDeferred<QueueOffset[]>();
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx 
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index f318b74df..946436ed5 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -1591,6 +1591,87 @@ describe('Consumer page', () => {
     expect(await 
screen.findByText('订阅一致性检查失败,当前保留上次检查结果')).toBeInTheDocument();
   });
 
+  const previewCsv = (name: string) =>
+    '"Name","Subscription Mode","Consume Type","Retry Max Times","Subscription 
Data Type"\n"' +
+    name +
+    '","Push","CLUSTERING","16","NORMAL"';
+
+  it('clearsPreviousImportRowsBeforeReadingTheNextFileTest', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderWithProviders(<ConsumerPage />);
+    await waitFor(() =>
+      expect(consumerService.listConsumerGroupPage).toHaveBeenCalledWith(
+        expect.objectContaining({ instanceId: 'instance-1' }),
+      ),
+    );
+    const input = screen.getByTestId('consumer-group-import-file');
+    await user.upload(input, new File([previewCsv('previous-resource')], 
'first.csv'));
+    await screen.findByText('previous-resource');
+    await user.click(screen.getByRole('button', { name: /^关\s*闭$/ }));
+    let resolve!: (text: string) => void;
+    const next = new File([], 'second.csv');
+    Object.defineProperty(next, 'text', {
+      value: () =>
+        new Promise<string>((complete) => {
+          resolve = complete;
+        }),
+    });
+    await user.upload(input, next);
+    expect(screen.getByText('导入 Group:second.csv')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: '开始导入' })).toBeDisabled();
+    expect(screen.queryByText('previous-resource')).not.toBeInTheDocument();
+    expect(consumerService.importConsumerGroups).not.toHaveBeenCalled();
+    await act(async () => resolve(previewCsv('current-resource')));
+    await screen.findByText('current-resource');
+    await user.click(screen.getByRole('button', { name: '开始导入' }));
+    await waitFor(() =>
+      
expect(consumerService.importConsumerGroups).toHaveBeenCalledWith('instance-1', 
[
+        expect.objectContaining({ name: 'current-resource', instanceId: 
'instance-1' }),
+      ]),
+    );
+  });
+
+  it.each(['success', 'failure'] as const)(
+    'ignoresCancelledFileReadCompletionTest (%s)',
+    async (outcome) => {
+      const user = userEvent.setup({ pointerEventsCheck: 0 });
+      renderWithProviders(<ConsumerPage />);
+      await waitFor(() =>
+        expect(consumerService.listConsumerGroupPage).toHaveBeenCalledWith(
+          expect.objectContaining({ instanceId: 'instance-1' }),
+        ),
+      );
+      const input = screen.getByTestId('consumer-group-import-file');
+      let resolve!: (text: string) => void;
+      let reject!: (error: Error) => void;
+      const first = new File([], 'first.csv');
+      Object.defineProperty(first, 'text', {
+        value: () =>
+          new Promise<string>((complete, fail) => {
+            resolve = complete;
+            reject = fail;
+          }),
+      });
+      await user.upload(input, first);
+      await user.click(screen.getByRole('button', { name: /^关\s*闭$/ }));
+      await user.upload(input, new File([previewCsv('current-resource')], 
'second.csv'));
+      await screen.findByText('current-resource');
+      await act(async () => {
+        if (outcome === 'success') resolve(previewCsv('previous-resource'));
+        else reject(new Error('obsolete file read failed'));
+      });
+      expect(screen.getByText('current-resource')).toBeInTheDocument();
+      expect(screen.queryByText('previous-resource')).not.toBeInTheDocument();
+      expect(screen.queryByText('obsolete file read 
failed')).not.toBeInTheDocument();
+      await user.click(screen.getByRole('button', { name: '开始导入' }));
+      await waitFor(() =>
+        
expect(consumerService.importConsumerGroups).toHaveBeenCalledWith('instance-1', 
[
+          expect.objectContaining({ name: 'current-resource', instanceId: 
'instance-1' }),
+        ]),
+      );
+    },
+  );
+
   it('keeps per-row state when consumer group CSV import partially fails', 
async () => {
     
vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(groupPage([]));
     vi.mocked(consumerService.importConsumerGroups).mockResolvedValue({
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx 
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 67645b453..a0e1f7e51 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -993,6 +993,89 @@ describe('TopicPage', () => {
     expect(screen.getByText('10.0.2.21:8080')).toBeInTheDocument();
   });
 
+  const previewCsv = (name: string) =>
+    '"Name","Type","Write Queues","Read Queues","Permission"\n"' + name + 
'","NORMAL","4","6","RW"';
+
+  it('clearsPreviousImportRowsBeforeReadingTheNextFileTest', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    mockTopicsList([]);
+    instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
+    renderWithProviders('/instance/instance-proxy-1/topic');
+    await waitFor(() =>
+      expect(topicServiceMocks.listTopicsPage).toHaveBeenCalledWith(
+        expect.objectContaining({ instanceId: 'instance-proxy-1' }),
+      ),
+    );
+    const input = screen.getByTestId('topic-import-file');
+    await user.upload(input, new File([previewCsv('previous-resource')], 
'first.csv'));
+    await screen.findByText('previous-resource');
+    await user.click(screen.getByRole('button', { name: /^关\s*闭$/ }));
+    let resolve!: (text: string) => void;
+    const next = new File([], 'second.csv');
+    Object.defineProperty(next, 'text', {
+      value: () =>
+        new Promise<string>((complete) => {
+          resolve = complete;
+        }),
+    });
+    await user.upload(input, next);
+    expect(screen.getByText('导入 Topic:second.csv')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: '开始导入' })).toBeDisabled();
+    expect(screen.queryByText('previous-resource')).not.toBeInTheDocument();
+    expect(topicServiceMocks.importTopics).not.toHaveBeenCalled();
+    await act(async () => resolve(previewCsv('current-resource')));
+    await screen.findByText('current-resource');
+    await user.click(screen.getByRole('button', { name: '开始导入' }));
+    await waitFor(() =>
+      
expect(topicServiceMocks.importTopics).toHaveBeenCalledWith('instance-proxy-1', 
[
+        expect.objectContaining({ name: 'current-resource', instanceId: 
'instance-proxy-1' }),
+      ]),
+    );
+  });
+
+  it.each(['success', 'failure'] as const)(
+    'ignoresCancelledFileReadCompletionTest (%s)',
+    async (outcome) => {
+      const user = userEvent.setup({ pointerEventsCheck: 0 });
+      mockTopicsList([]);
+      instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
+      renderWithProviders('/instance/instance-proxy-1/topic');
+      await waitFor(() =>
+        expect(topicServiceMocks.listTopicsPage).toHaveBeenCalledWith(
+          expect.objectContaining({ instanceId: 'instance-proxy-1' }),
+        ),
+      );
+      const input = screen.getByTestId('topic-import-file');
+      let resolve!: (text: string) => void;
+      let reject!: (error: Error) => void;
+      const first = new File([], 'first.csv');
+      Object.defineProperty(first, 'text', {
+        value: () =>
+          new Promise<string>((complete, fail) => {
+            resolve = complete;
+            reject = fail;
+          }),
+      });
+      await user.upload(input, first);
+      await user.click(screen.getByRole('button', { name: /^关\s*闭$/ }));
+      await user.upload(input, new File([previewCsv('current-resource')], 
'second.csv'));
+      await screen.findByText('current-resource');
+      await act(async () => {
+        if (outcome === 'success') resolve(previewCsv('previous-resource'));
+        else reject(new Error('obsolete file read failed'));
+      });
+      expect(screen.getByText('current-resource')).toBeInTheDocument();
+      expect(screen.queryByText('previous-resource')).not.toBeInTheDocument();
+      expect(screen.queryByText('obsolete file read 
failed')).not.toBeInTheDocument();
+      await user.click(screen.getByRole('button', { name: '开始导入' }));
+      await waitFor(() =>
+        
expect(topicServiceMocks.importTopics).toHaveBeenCalledWith('instance-proxy-1', 
[
+          expect.objectContaining({ name: 'current-resource', instanceId: 
'instance-proxy-1' }),
+        ]),
+      );
+    },
+  );
+
   it('imports valid topic CSV rows through the backend batch service with the 
selected instance', async () => {
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     mockTopicsList([]);
diff --git a/web/src/pages/instance/consumer.tsx 
b/web/src/pages/instance/consumer.tsx
index c118f45ad..59294a7ca 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -289,6 +289,13 @@ const ConsumerPageContent = ({
   const [stackError, setStackError] = useState<string | null>(null);
   const [selectedStackClient, setSelectedStackClient] = 
useState<ConsumerInstance | null>(null);
   const importInputRef = useRef<HTMLInputElement>(null);
+  const importReadIdRef = useRef(0);
+  useEffect(
+    () => () => {
+      importReadIdRef.current += 1;
+    },
+    [],
+  );
   const [importModalOpen, setImportModalOpen] = useState(false);
   const [importFilename, setImportFilename] = useState('');
   const [importRows, setImportRows] = 
useState<ResourceImportRow<Partial<ConsumerGroup>>[]>([]);
@@ -796,19 +803,25 @@ const ConsumerPageContent = ({
       message.error('请先选择实例');
       return;
     }
+    const readId = ++importReadIdRef.current;
+    // Clear the previous preview before the file read yields: those rows must 
not be imported
+    // under this file's name while its contents are still loading.
+    setImportRows([]);
+    setImportErrors([]);
+    if (importInputRef.current) importInputRef.current.value = '';
     setImportFilename(file.name);
     setImporting(false);
     setImportModalOpen(true);
     try {
       const records = parseCsvTable(await file.text());
+      if (readId !== importReadIdRef.current) return;
       const validation = validateConsumerGroupCsvImport(records, 
selectedInstanceId || undefined);
       setImportRows(validation.rows);
       setImportErrors(validation.errors);
     } catch (error) {
+      if (readId !== importReadIdRef.current) return;
       setImportRows([]);
       setImportErrors([error instanceof Error ? error.message : 'CSV 解析失败']);
-    } finally {
-      if (importInputRef.current) importInputRef.current.value = '';
     }
   };
 
@@ -2484,7 +2497,10 @@ const ConsumerPageContent = ({
         title={`导入 Group${importFilename ? `:${importFilename}` : ''}`}
         open={importModalOpen}
         onCancel={() => {
-          if (!importing) setImportModalOpen(false);
+          if (!importing) {
+            importReadIdRef.current += 1;
+            setImportModalOpen(false);
+          }
         }}
         onOk={() => void handleImportConsumerGroups()}
         okText={importRows.some((row) => row.status === 'failed') ? '重试失败项' : 
'开始导入'}
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index cb62bbbe4..1be6ee7a4 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -399,6 +399,13 @@ const TopicPageContent = ({
     MessagePropertyInput[] | undefined;
   const { modal } = App.useApp();
   const importInputRef = useRef<HTMLInputElement>(null);
+  const importReadIdRef = useRef(0);
+  useEffect(
+    () => () => {
+      importReadIdRef.current += 1;
+    },
+    [],
+  );
   const [importModalOpen, setImportModalOpen] = useState(false);
   const [importFilename, setImportFilename] = useState('');
   const [importRows, setImportRows] = 
useState<ResourceImportRow<Partial<Topic>>[]>([]);
@@ -1291,19 +1298,25 @@ const TopicPageContent = ({
       message.error('请先选择实例');
       return;
     }
+    const readId = ++importReadIdRef.current;
+    // Clear the previous preview before the file read yields: those rows must 
not be imported
+    // under this file's name while its contents are still loading.
+    setImportRows([]);
+    setImportErrors([]);
+    if (importInputRef.current) importInputRef.current.value = '';
     setImportFilename(file.name);
     setImporting(false);
     setImportModalOpen(true);
     try {
       const records = parseCsvTable(await file.text());
+      if (readId !== importReadIdRef.current) return;
       const validation = validateTopicCsvImport(records, selectedInstanceId || 
undefined);
       setImportRows(validation.rows);
       setImportErrors(validation.errors);
     } catch (error) {
+      if (readId !== importReadIdRef.current) return;
       setImportRows([]);
       setImportErrors([error instanceof Error ? error.message : 'CSV 解析失败']);
-    } finally {
-      if (importInputRef.current) importInputRef.current.value = '';
     }
   };
 
@@ -1928,7 +1941,10 @@ const TopicPageContent = ({
         title={`导入 Topic${importFilename ? `:${importFilename}` : ''}`}
         open={importModalOpen}
         onCancel={() => {
-          if (!importing) setImportModalOpen(false);
+          if (!importing) {
+            importReadIdRef.current += 1;
+            setImportModalOpen(false);
+          }
         }}
         onOk={() => void handleImportTopics()}
         okText={importRows.some((row) => row.status === 'failed') ? '重试失败项' : 
'开始导入'}
diff --git a/web/src/pages/ops/ResourceOperationTimelineDrawer.tsx 
b/web/src/pages/ops/ResourceOperationTimelineDrawer.tsx
index d2904b530..330793593 100644
--- a/web/src/pages/ops/ResourceOperationTimelineDrawer.tsx
+++ b/web/src/pages/ops/ResourceOperationTimelineDrawer.tsx
@@ -98,6 +98,11 @@ const ResourceOperationTimelineDrawer = ({
     void listAuditRecords({ page, pageSize, ...filter })
       .then((result) => {
         if (requestVersion.current !== version) return;
+        const lastPage = Math.max(1, Math.ceil(result.total / pageSize));
+        if (page > lastPage) {
+          setPage(lastPage);
+          return;
+        }
         setResponse({
           queryKey,
           records: result.items,
diff --git 
a/web/src/pages/ops/__tests__/ResourceOperationTimelineDrawer.test.tsx 
b/web/src/pages/ops/__tests__/ResourceOperationTimelineDrawer.test.tsx
index eb30fabba..fca63859c 100644
--- a/web/src/pages/ops/__tests__/ResourceOperationTimelineDrawer.test.tsx
+++ b/web/src/pages/ops/__tests__/ResourceOperationTimelineDrawer.test.tsx
@@ -169,6 +169,37 @@ describe('ResourceOperationTimelineDrawer', () => {
     );
   });
 
+  describe('retention pagination', () => {
+    afterEach(() => vi.mocked(opsService.listAuditRecords).mockReset());
+
+    it.each([0, 5])('recoversAfterRetentionRemovesTheCurrentPageTest 
(total=%s)', async (total) => {
+      const user = userEvent.setup();
+      vi.mocked(opsService.listAuditRecords)
+        .mockResolvedValueOnce({ items: [timelineRecord], total: 21, page: 1, 
size: 20 })
+        .mockResolvedValueOnce({ items: [], total, page: 2, size: 20 })
+        .mockResolvedValueOnce({
+          items: total ? [{ ...timelineRecord, operator: 'remaining-user' }] : 
[],
+          total,
+          page: 1,
+          size: 20,
+        });
+      renderDrawer();
+      await screen.findByText(/ops-user/);
+      await user.click(screen.getByTitle('2'));
+      await waitFor(() => 
expect(opsService.listAuditRecords).toHaveBeenCalledTimes(3));
+      expect(opsService.listAuditRecords).toHaveBeenLastCalledWith({
+        page: 1,
+        pageSize: 20,
+        resourceType: 'TOPIC',
+        target: 'topic-a',
+        clusterId: 'prod-cn',
+      });
+      if (total) expect(await 
screen.findByText(/remaining-user/)).toBeInTheDocument();
+      else expect(screen.queryByText(/ops-user/)).not.toBeInTheDocument();
+      expect(opsService.listAuditRecords).toHaveBeenCalledTimes(3);
+    });
+  });
+
   it('applies the selected date range to the resource query and export', async 
() => {
     const user = userEvent.setup();
     renderDrawer();
diff --git a/web/src/services/instanceService.test.ts 
b/web/src/services/instanceService.test.ts
index 50b78ca92..f119a4310 100644
--- a/web/src/services/instanceService.test.ts
+++ b/web/src/services/instanceService.test.ts
@@ -259,6 +259,62 @@ describe('instanceService dedupe invalidation after 
mutations', () => {
     return holder as { resolve: (value: Instance[]) => void };
   }
 
+  it.each(['success', 'failure'] as const)(
+    'keepsThePostMutationRequestSharedWhenAnOlderReadSettlesTest (%s)',
+    async (outcome) => {
+      let resolveOld!: (value: Instance[]) => void;
+      let rejectOld!: (error: Error) => void;
+      let resolveFresh!: (value: Instance[]) => void;
+      const old = new Promise<Instance[]>((resolve, reject) => {
+        resolveOld = resolve;
+        rejectOld = reject;
+      });
+      const fresh = new Promise<Instance[]>((resolve) => {
+        resolveFresh = resolve;
+      });
+      const expected = [instanceFixture('kept', 'updated')];
+      instanceApiMock.listInstances
+        .mockReturnValueOnce(old)
+        .mockReturnValueOnce(fresh)
+        .mockResolvedValue(expected);
+      const first = listInstances().catch((error: unknown) => error);
+      await updateInstance({ instanceId: 'kept', remark: 'updated' });
+      const second = listInstances();
+      if (outcome === 'success') resolveOld([instanceFixture('kept', 'old')]);
+      else rejectOld(new Error('obsolete read failed'));
+      await first;
+      const third = listInstances();
+      // Settle all callers before asserting so a failing regression leaves no 
pending entries.
+      resolveFresh(expected);
+      const [secondResult, thirdResult] = await Promise.all([second, third]);
+      expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(2);
+      expect(secondResult).toEqual(expected);
+      expect(thirdResult).toEqual(expected);
+      expect(secondResult).not.toBe(thirdResult);
+      expect(secondResult[0]).not.toBe(thirdResult[0]);
+      secondResult[0].remark = 'caller-local change';
+      expect(thirdResult[0].remark).toBe('updated');
+      await listInstances();
+      expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(3);
+    },
+  );
+
+  it('retriesAfterTheCurrentSharedReadFailsTest', async () => {
+    let reject!: (error: Error) => void;
+    const pending = new Promise<Instance[]>((_resolve, fail) => {
+      reject = fail;
+    });
+    
instanceApiMock.listInstances.mockReturnValueOnce(pending).mockResolvedValue([]);
+    const first = listInstances();
+    const second = listInstances();
+    const settled = Promise.allSettled([first, second]);
+    reject(new Error('current read failed'));
+    expect((await settled).map((result) => 
result.status)).toEqual(['rejected', 'rejected']);
+    expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(1);
+    await expect(listInstances()).resolves.toEqual([]);
+    expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(2);
+  });
+
   it('does not serve the pre-create snapshot to a list request issued after 
createInstance', async () => {
     const before = [instanceFixture('kept')];
     const after = [instanceFixture('kept'), instanceFixture('created')];
diff --git a/web/src/services/instanceService.ts 
b/web/src/services/instanceService.ts
index 8e66addcb..f7a950532 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -37,7 +37,10 @@ export function listInstances(query: InstanceQuery = {}): 
Promise<Instance[]> {
   if (inflight) {
     return inflight.then((items) => items.map(copyInstance));
   }
-  const request = fetchInstances(query, mockMode).finally(() => 
inflightListRequests.delete(key));
+  const request = fetchInstances(query, mockMode).finally(() => {
+    // A mutation may have replaced this request; only remove the entry we 
installed.
+    if (inflightListRequests.get(key) === request) 
inflightListRequests.delete(key);
+  });
   inflightListRequests.set(key, request);
   return request.then((items) => items.map(copyInstance));
 }

Reply via email to