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 1226f0131 fix(users): refresh the query on screen after a row mutation
(#6523)
1226f0131 is described below
commit 1226f0131d383d0694000f27ea9001adcf26452e
Author: Apulupie <[email protected]>
AuthorDate: Thu Oct 15 18:14:02 2026 +0800
fix(users): refresh the query on screen after a row mutation (#6523)
Two mutations on the users page refreshed the list by awaiting the
`loadUsers` captured when the
operator confirmed them: the row status switch (`setEnabled`) and the
session revoke popovers
(`revokeSessions`, from the row and from the drawer). Both leave the page
interactive, so the search
and the page can change while the request is on the wire; the continuation
then re-issued the old
query (`search: undefined`, the old page) and wrote its rows and total over
the newer result, leaving
the table showing rows the search box no longer selects.
`#6198` fixed the same shape for the two settings tabs.
Both refreshes now bump a `refreshVersion` the list effect depends on, so
they run against the query
on screen; the request-sequence guard still decides which response may
write. The modal-confirmed
paths (create user, password reset) keep awaiting the loader: their modals
block the page, so the
query cannot change while they run.
---
web/src/pages/studio/UserManagement.tsx | 11 +++--
.../pages/studio/__tests__/UserManagement.test.tsx | 48 ++++++++++++++++++++++
2 files changed, 56 insertions(+), 3 deletions(-)
diff --git a/web/src/pages/studio/UserManagement.tsx
b/web/src/pages/studio/UserManagement.tsx
index 61662e3af..e33972a0a 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -139,6 +139,7 @@ const UserManagementPage = () => {
const [passwordTarget, setPasswordTarget] = useState<StudioUser |
null>(null);
const [userExporting, setUserExporting] = useState(false);
const [mutatingUserIds, setMutatingUserIds] = useState<Set<number>>(() =>
new Set());
+ const [refreshVersion, setRefreshVersion] = useState(0);
const [createForm] = Form.useForm<CreateFormValues>();
const [passwordForm] = Form.useForm<PasswordFormValues>();
const requestSeqRef = useRef(0);
@@ -195,12 +196,16 @@ const UserManagementPage = () => {
}
}, [admin, debouncedSearch, page, pageSize, roleFilter, statusFilter, t]);
+ // Mutations refresh through the effect (bumping `refreshVersion`) instead
of awaiting the
+ // `loadUsers` captured when the operator confirmed: a row's status switch
and the session
+ // revoke popovers leave the list interactive, so the search or the page can
change while the
+ // request is on the wire, and the refresh must re-query what is on screen
then.
useEffect(() => {
const timer = window.setTimeout(() => {
void loadUsers();
}, 0);
return () => window.clearTimeout(timer);
- }, [loadUsers]);
+ }, [loadUsers, refreshVersion]);
useEffect(
() => () => {
@@ -304,7 +309,7 @@ const UserManagementPage = () => {
async () => {
await setStudioUserEnabled(record.id, enabled);
message.success(enabled ? t('userMgmt.userEnabled') :
t('userMgmt.userDisabled'));
- await loadUsers();
+ setRefreshVersion((version) => version + 1);
},
t('userMgmt.updateStatusFailed'),
);
@@ -351,7 +356,7 @@ const UserManagementPage = () => {
if (sessionModalUserIdRef.current === record.id) {
await loadSessionDetails({ ...record, activeSessionCount: 0 });
}
- await loadUsers();
+ setRefreshVersion((version) => version + 1);
},
t('userMgmt.revokeFailed'),
);
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx
b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index 72ea55605..6b3459987 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -106,6 +106,16 @@ const sessionDetails: StudioUserSessionDetail[] = [
},
];
+const deferred = <T,>() => {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+};
+
const renderPage = () =>
render(
<MemoryRouter>
@@ -295,6 +305,44 @@ describe('UserManagementPage', () => {
expect(listStudioUsers).toHaveBeenCalledTimes(2);
});
+ it('refreshes the query on screen when a revocation finishes after the
search changes', async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ const pendingRevoke = deferred<{ userId: number; revokedSessionCount:
number }>();
+
vi.mocked(revokeStudioUserSessions).mockReturnValueOnce(pendingRevoke.promise);
+ const narrowedPage = {
+ items: [{ ...studioUserPage.items[0], id: 8, username: 'current-user' }],
+ total: 1,
+ page: 1,
+ size: 20,
+ };
+ vi.mocked(listStudioUsers).mockImplementation(async (params) =>
+ params?.search === 'current' ? narrowedPage : studioUserPage,
+ );
+ renderPage();
+ await screen.findByText('operator');
+
+ await user.click(screen.getByRole('button', { name: '注销' }));
+ await screen.findByText('注销 operator 的活跃会话?');
+ await confirmRevokePopover(user);
+ await waitFor(() =>
expect(revokeStudioUserSessions).toHaveBeenCalledWith(7));
+
+ // The operator narrows the list while the revocation is still on the
wire. The refresh that
+ // follows must re-query what is on screen, not the search captured when
the popover was
+ // confirmed - otherwise the table snaps back to rows the search box no
longer selects.
+ await user.type(screen.getByPlaceholderText('搜索用户名'), 'current');
+ await screen.findByText('current-user');
+
+ await act(async () => pendingRevoke.resolve({ userId: 7,
revokedSessionCount: 2 }));
+
+ await waitFor(() =>
+ expect(vi.mocked(listStudioUsers).mock.lastCall?.[0]).toEqual(
+ expect.objectContaining({ search: 'current' }),
+ ),
+ );
+ expect(screen.getByText('current-user')).toBeInTheDocument();
+ expect(screen.queryByText('operator')).not.toBeInTheDocument();
+ });
+
it('renders session timestamps as UTC values in the viewer timezone', async
() => {
// The user/session APIs serialize UTC LocalDateTime values without an
offset, so
// the table and drawer must not parse them as browser-local wall times.
Pin the