fix: slashcommand query incorrectly removing commands from UI (#37654)

Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com>
This commit is contained in:
Douglas Gubert 2025-12-04 12:38:50 -03:00 committed by GitHub
parent 38bd32c5b2
commit 7809e0401a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 141 additions and 45 deletions

View File

@ -0,0 +1,6 @@
---
'@rocket.chat/mock-providers': patch
'@rocket.chat/meteor': patch
---
Fixes an issue that could cause slashcommands to disappear for the user in certain high-availability scenarios

View File

@ -1,5 +1,6 @@
import type { SlashCommand } from '@rocket.chat/core-typings'; import type { SlashCommand } from '@rocket.chat/core-typings';
import { mockAppRoot, type StreamControllerRef } from '@rocket.chat/mock-providers'; import { mockAppRoot, type StreamControllerRef } from '@rocket.chat/mock-providers';
import { QueryClient } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { useAppSlashCommands } from './useAppSlashCommands'; import { useAppSlashCommands } from './useAppSlashCommands';
@ -33,9 +34,18 @@ const mockApiResponse = {
describe('useAppSlashCommands', () => { describe('useAppSlashCommands', () => {
let mockGetSlashCommands: jest.Mock; let mockGetSlashCommands: jest.Mock;
let queryClient: QueryClient;
beforeEach(() => { beforeEach(() => {
mockGetSlashCommands = jest.fn().mockResolvedValue(mockApiResponse); mockGetSlashCommands = jest.fn().mockResolvedValue(mockApiResponse);
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
jest.spyOn(queryClient, 'invalidateQueries');
slashCommands.commands = {}; slashCommands.commands = {};
}); });
@ -69,6 +79,7 @@ describe('useAppSlashCommands', () => {
renderHook(() => useAppSlashCommands(), { renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot() wrapper: mockAppRoot()
.withJohnDoe() .withJohnDoe()
.withQueryClient(queryClient)
.withStream('apps', streamRef) .withStream('apps', streamRef)
.withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands) .withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands)
.build(), .build(),
@ -83,7 +94,37 @@ describe('useAppSlashCommands', () => {
streamRef.controller?.emit('apps', [['command/removed', ['/test']]]); streamRef.controller?.emit('apps', [['command/removed', ['/test']]]);
expect(slashCommands.commands['/test']).toBeUndefined(); expect(slashCommands.commands['/test']).toBeUndefined();
expect(slashCommands.commands['/weather']).toBeDefined();
await waitFor(() => {
expect(queryClient.invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['apps', 'slashCommands'] }));
});
});
it('should handle command/disabled event by invalidating queries', async () => {
const streamRef: StreamControllerRef<'apps'> = {};
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot()
.withJohnDoe()
.withQueryClient(queryClient)
.withStream('apps', streamRef)
.withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands)
.build(),
});
expect(streamRef.controller).toBeDefined();
await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length);
});
streamRef.controller?.emit('apps', [['command/disabled', ['/test']]]);
expect(slashCommands.commands['/test']).toBeUndefined();
await waitFor(() => {
expect(queryClient.invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['apps', 'slashCommands'] }));
});
}); });
it('should handle command/added event by invalidating queries', async () => { it('should handle command/added event by invalidating queries', async () => {
@ -132,6 +173,7 @@ describe('useAppSlashCommands', () => {
renderHook(() => useAppSlashCommands(), { renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot() wrapper: mockAppRoot()
.withJohnDoe() .withJohnDoe()
.withQueryClient(queryClient)
.withStream('apps', streamRef) .withStream('apps', streamRef)
.withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands) .withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands)
.build(), .build(),
@ -145,29 +187,10 @@ describe('useAppSlashCommands', () => {
streamRef.controller?.emit('apps', [['command/updated', ['/test']]]); streamRef.controller?.emit('apps', [['command/updated', ['/test']]]);
expect(slashCommands.commands['/test']).toBeUndefined();
expect(slashCommands.commands['/weather']).toBeDefined();
});
it('should ignore command/disabled event', async () => {
const streamRef: StreamControllerRef<'apps'> = {};
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot()
.withJohnDoe()
.withStream('apps', streamRef)
.withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands)
.build(),
});
expect(streamRef.controller).toBeDefined();
await waitFor(() => { await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length); expect(queryClient.invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['apps', 'slashCommands'] }));
}); });
streamRef.controller?.emit('apps', [['command/disabled', ['/test']]]);
expect(slashCommands.commands['/test']).toBeDefined(); expect(slashCommands.commands['/test']).toBeDefined();
expect(slashCommands.commands['/weather']).toBeDefined(); expect(slashCommands.commands['/weather']).toBeDefined();
}); });
@ -182,4 +205,31 @@ describe('useAppSlashCommands', () => {
expect(streamRef.controller).toBeDefined(); expect(streamRef.controller).toBeDefined();
expect(streamRef.controller?.has('apps')).toBe(false); expect(streamRef.controller?.has('apps')).toBe(false);
}); });
it('should fetch all commands in batches if total exceeds count', async () => {
const largeMockCommands: SlashCommand[] = Array.from({ length: 120 }, (_, i) => ({
command: `/command${i + 1}`,
description: `Description for command ${i + 1}`,
params: '',
clientOnly: false,
providesPreview: false,
appId: `app-${i + 1}`,
permission: undefined,
}));
mockGetSlashCommands.mockImplementation(({ offset, count }) => {
return Promise.resolve({
commands: largeMockCommands.slice(offset, offset + count),
total: largeMockCommands.length,
});
});
renderHook(() => useAppSlashCommands(), {
wrapper: mockAppRoot().withJohnDoe().withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands).build(),
});
await waitFor(() => {
expect(Object.keys(slashCommands.commands)).toHaveLength(largeMockCommands.length);
});
});
}); });

View File

@ -5,6 +5,7 @@ import { useQueryClient, useQuery } from '@tanstack/react-query';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { slashCommands } from '../../app/utils/client/slashCommand'; import { slashCommands } from '../../app/utils/client/slashCommand';
import { appsQueryKeys } from '../lib/queryKeys';
type SlashCommandBasicInfo = Pick<SlashCommand, 'clientOnly' | 'command' | 'description' | 'params' | 'providesPreview' | 'appId'>; type SlashCommandBasicInfo = Pick<SlashCommand, 'clientOnly' | 'command' | 'description' | 'params' | 'providesPreview' | 'appId'>;
@ -17,7 +18,7 @@ export const useAppSlashCommands = () => {
const invalidate = useDebouncedCallback( const invalidate = useDebouncedCallback(
() => { () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['apps', 'slashCommands'], queryKey: appsQueryKeys.slashCommands(),
}); });
}, },
100, 100,
@ -29,19 +30,24 @@ export const useAppSlashCommands = () => {
return; return;
} }
return apps('apps', ([key, [command]]) => { return apps('apps', ([key, [command]]) => {
if (['command/added', 'command/updated', 'command/removed'].includes(key)) { if (!key.startsWith('command/')) {
if (typeof command === 'string') { return;
delete slashCommands.commands[command];
}
invalidate();
} }
if (['command/removed', 'command/disabled'].includes(key) && typeof command === 'string') {
delete slashCommands.commands[command];
}
invalidate();
}); });
}, [apps, uid, invalidate]); }, [apps, uid, invalidate]);
const getSlashCommands = useEndpoint('GET', '/v1/commands.list'); const getSlashCommands = useEndpoint('GET', '/v1/commands.list');
const { data } = useQuery({ const { data } = useQuery({
queryKey: ['apps', 'slashCommands'] as const, queryKey: appsQueryKeys.slashCommands(),
enabled: !!uid,
structuralSharing: false,
queryFn: async () => { queryFn: async () => {
const fetchBatch = async (currentOffset: number, accumulator: SlashCommandBasicInfo[] = []): Promise<SlashCommandBasicInfo[]> => { const fetchBatch = async (currentOffset: number, accumulator: SlashCommandBasicInfo[] = []): Promise<SlashCommandBasicInfo[]> => {
const count = 50; const count = 50;
@ -58,14 +64,18 @@ export const useAppSlashCommands = () => {
return fetchBatch(0); return fetchBatch(0);
}, },
enabled: !!uid,
}); });
useEffect(() => { /**
if (!data) { * We're deliberately not using `useEffect` here because we want the forEach to run on every call
return; *
} * What we considered:
*
data.forEach((command) => slashCommands.add(command)); * 1. Slash command list is really small (< 100 items)
}, [data]); * 2. `slashCommands.add` is idempotent
* 3. `slashCommands.add` doesn't trigger re-renders
*
* @TODO the `slashCommands` singleton should be refactored to fit the React data flow
*/
data?.forEach((command) => slashCommands.add(command));
}; };

View File

@ -73,7 +73,7 @@ export const processSlashCommand = async (chat: ChatAPI, message: IMessage): Pro
return true; return true;
} }
await sdk.rest.post('/v1/statistics.telemetry', { void sdk.rest.post('/v1/statistics.telemetry', {
params: [{ eventName: 'slashCommandsStats', timestamp: Date.now(), command: commandName }], params: [{ eventName: 'slashCommandsStats', timestamp: Date.now(), command: commandName }],
}); });

View File

@ -118,3 +118,8 @@ export const teamsQueryKeys = {
[...teamsQueryKeys.team(teamId), 'rooms-of-user', userId, options] as const, [...teamsQueryKeys.team(teamId), 'rooms-of-user', userId, options] as const,
listUserTeams: (userId: IUser['_id']) => [...teamsQueryKeys.all, 'listUserTeams', userId] as const, listUserTeams: (userId: IUser['_id']) => [...teamsQueryKeys.all, 'listUserTeams', userId] as const,
}; };
export const appsQueryKeys = {
all: ['apps'] as const,
slashCommands: () => [...appsQueryKeys.all, 'slashCommands'] as const,
};

View File

@ -260,6 +260,20 @@ export class MockedAppRootBuilder {
permissionStatus: undefined, permissionStatus: undefined,
}; };
private _providedQueryClient: QueryClient | undefined;
private get queryClient(): QueryClient {
return (
this._providedQueryClient ||
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
);
}
wrap(wrapper: (children: ReactNode) => ReactNode): this { wrap(wrapper: (children: ReactNode) => ReactNode): this {
this.wrappers.push(wrapper); this.wrappers.push(wrapper);
return this; return this;
@ -634,15 +648,26 @@ export class MockedAppRootBuilder {
return this; return this;
} }
build(): JSXElementConstructor<{ children: ReactNode }> { withQueryClient(client: QueryClient): this {
const queryClient = new QueryClient({ this._providedQueryClient = client;
defaultOptions: { return this;
queries: { retry: false }, }
mutations: { retry: false },
},
});
const { server, router, settings, user, userPresence, videoConf, i18n, authorization, wrappers, deviceContext, authentication } = this; build(): JSXElementConstructor<{ children: ReactNode }> {
const {
queryClient,
server,
router,
settings,
user,
userPresence,
videoConf,
i18n,
authorization,
wrappers,
deviceContext,
authentication,
} = this;
const reduceTranslation = (translation?: ContextType<typeof TranslationContext>): ContextType<typeof TranslationContext> => { const reduceTranslation = (translation?: ContextType<typeof TranslationContext>): ContextType<typeof TranslationContext> => {
return { return {