fix(web): раздел «Эмодзи» обновляется событием, а не перезапросом
`GUILD_EMOJIS_UPDATE` несёт набор эмодзи целиком, но раздел настроек читает
REST-кэш, а клиент на событие запрос инвалидировал. Под нагрузкой повторный
`GET /guilds/{id}/emojis` попадает под лимит частоты (429), а запрос списка идёт
с `retry: 0` — список оставался старым до перезахода. Тот же дефект, что у
раздела «Звуки» (коммит 4175b14), и та же правка: событие кладёт набор в кэш
запроса через `setQueryData`, а ошибка перезапроса больше не прячет уже
полученный список.
Регрессионный тест `web/tests/guildEmojisSettings.test.tsx` («показывает эмодзи
из GUILD_EMOJIS_UPDATE, даже если список по REST не отдался») падает на старом
коде.
This commit is contained in:
@@ -40,9 +40,10 @@ type LocalError = { kind: 'file.too_large' | 'emoji.invalid_type' | 'emoji.inval
|
|||||||
* «Эмодзи»: загрузка, переименование и удаление кастомных эмодзи сервера
|
* «Эмодзи»: загрузка, переименование и удаление кастомных эмодзи сервера
|
||||||
* (AGENT.md 7.12, право MANAGE_EMOJIS).
|
* (AGENT.md 7.12, право MANAGE_EMOJIS).
|
||||||
*
|
*
|
||||||
* Список читается по REST (`GET /guilds/{id}/emojis`), а после операций
|
* Список читается по REST (`GET /guilds/{id}/emojis`), но событие шлюза
|
||||||
* инвалидируется: снапшот обновляет и событие шлюза `GUILD_EMOJIS_UPDATE`,
|
* `GUILD_EMOJIS_UPDATE` несёт набор целиком — стор шлюза кладёт его прямо в
|
||||||
* и точечные правки в сторе сессии — лента и пикер видят эмодзи сразу.
|
* кэш запроса (как у звуков, AGENT.md 7.12), поэтому повторный запрос нужен
|
||||||
|
* только после собственных операций.
|
||||||
*/
|
*/
|
||||||
export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) {
|
export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@@ -63,6 +64,9 @@ export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const emojis = list.data ?? [];
|
const emojis = list.data ?? [];
|
||||||
|
// Ошибка перезапроса не должна прятать уже полученный список: под нагрузкой
|
||||||
|
// ручка отвечает 429, а список из `GUILD_EMOJIS_UPDATE` при этом свежий (7.12).
|
||||||
|
const failed = list.isError && list.data === undefined;
|
||||||
const full = emojis.length >= EMOJI_LIMIT_PER_GUILD;
|
const full = emojis.length >= EMOJI_LIMIT_PER_GUILD;
|
||||||
|
|
||||||
/** Инвалидация кэша: снапшот придёт событием шлюза либо правкой стора. */
|
/** Инвалидация кэша: снапшот придёт событием шлюза либо правкой стора. */
|
||||||
@@ -173,7 +177,7 @@ export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) {
|
|||||||
|
|
||||||
{list.isPending ? (
|
{list.isPending ? (
|
||||||
<SkeletonLines className="mt-4" rows={3} />
|
<SkeletonLines className="mt-4" rows={3} />
|
||||||
) : list.isError ? (
|
) : failed ? (
|
||||||
<ErrorNotice className="mt-4" error={list.error} onRetry={() => void list.refetch()} />
|
<ErrorNotice className="mt-4" error={list.error} onRetry={() => void list.refetch()} />
|
||||||
) : emojis.length === 0 ? (
|
) : emojis.length === 0 ? (
|
||||||
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.emojis.empty')}</p>
|
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.emojis.empty')}</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
import { guildEmojisQueryKey } from '@/api/emojis';
|
import { guildEmojisQueryKey, type GuildEmoji } from '@/api/emojis';
|
||||||
import {
|
import {
|
||||||
connectGateway,
|
connectGateway,
|
||||||
disconnectGateway,
|
disconnectGateway,
|
||||||
@@ -314,12 +314,12 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'GUILD_EMOJIS_UPDATE': {
|
case 'GUILD_EMOJIS_UPDATE': {
|
||||||
// Набор эмодзи сервера изменился: заменяем его в снапшоте и помечаем
|
// Набор эмодзи сервера изменился целиком: кладём его и в снапшот, и в
|
||||||
// REST-кэш устаревшим — форма списка одна и та же.
|
// кэш REST-запроса раздела настроек — повторный запрос не нужен (7.12).
|
||||||
const update = parseGuildEmojisUpdate(event.d);
|
const update = parseGuildEmojisUpdate(event.d);
|
||||||
if (update !== null) {
|
if (update !== null) {
|
||||||
session.setGuildEmojis(update.guild_id, update.emojis);
|
session.setGuildEmojis(update.guild_id, update.emojis);
|
||||||
void invalidateEmojiQueries(update.guild_id);
|
void applyGuildEmojis(update.guild_id, update.emojis);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -569,12 +569,16 @@ async function invalidateGuildQueries(guildId: string): Promise<void> {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Инвалидация списка эмодзи сервера после `GUILD_EMOJIS_UPDATE`. */
|
/**
|
||||||
async function invalidateEmojiQueries(guildId: string): Promise<void> {
|
* Кладёт набор эмодзи из `GUILD_EMOJIS_UPDATE` в кэш REST-запроса: событие
|
||||||
|
* несёт список целиком, поэтому повторный запрос не нужен, а его отказ под
|
||||||
|
* нагрузкой (429) оставлял раздел настроек со старым списком (AGENT.md 7.12).
|
||||||
|
*/
|
||||||
|
async function applyGuildEmojis(guildId: string, emojis: GuildEmoji[]): Promise<void> {
|
||||||
// Ключ запроса берём из статического импорта: `api/emojis` не зависит от
|
// Ключ запроса берём из статического импорта: `api/emojis` не зависит от
|
||||||
// сторов, поэтому цикла «стор → query-client → стор» здесь нет.
|
// сторов, поэтому цикла «стор → query-client → стор» здесь нет.
|
||||||
const { getQueryClient } = await import('@/lib/queryClient');
|
const { getQueryClient } = await import('@/lib/queryClient');
|
||||||
await getQueryClient().invalidateQueries({ queryKey: guildEmojisQueryKey(guildId) });
|
getQueryClient().setQueryData(guildEmojisQueryKey(guildId), emojis);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
apiError,
|
apiError,
|
||||||
findRequest,
|
findRequest,
|
||||||
installFetch,
|
installFetch,
|
||||||
|
installGatewaySocket,
|
||||||
json,
|
json,
|
||||||
|
makeReadySnapshot,
|
||||||
makeUser,
|
makeUser,
|
||||||
recordedRequests,
|
recordedRequests,
|
||||||
renderApp,
|
renderApp,
|
||||||
@@ -18,8 +20,9 @@ import {
|
|||||||
/**
|
/**
|
||||||
* Секция «Эмодзи» в настройках сервера (AGENT.md 7.12): список загруженных
|
* Секция «Эмодзи» в настройках сервера (AGENT.md 7.12): список загруженных
|
||||||
* эмодзи со счётчиком, загрузка с клиентскими проверками, переименование и
|
* эмодзи со счётчиком, загрузка с клиентскими проверками, переименование и
|
||||||
* удаление. Лента и пикер видят изменения сразу — запросы эмодзи
|
* удаление. Лента и пикер видят изменения сразу: событие `GUILD_EMOJIS_UPDATE`
|
||||||
* инвалидируются, а снапшот обновляет событие `GUILD_EMOJIS_UPDATE`.
|
* несёт набор целиком и попадает прямо в кэш запроса, а свои операции
|
||||||
|
* инвалидируют список.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const user = makeUser();
|
const user = makeUser();
|
||||||
@@ -351,4 +354,31 @@ describe('настройки сервера: секция «Эмодзи»', ()
|
|||||||
expect(findRequest(fetchMock, { url: '/emojis/e-1', method: 'DELETE' })).toBeDefined();
|
expect(findRequest(fetchMock, { url: '/emojis/e-1', method: 'DELETE' })).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it('показывает эмодзи из GUILD_EMOJIS_UPDATE, даже если список по REST не отдался', async () => {
|
||||||
|
// Под нагрузкой ручка списка отвечает 429 (AGENT.md 8.6), а раздел должен
|
||||||
|
// обновляться снапшотом из события шлюза, а не повтором запроса (7.12).
|
||||||
|
const gateway = installGatewaySocket(
|
||||||
|
makeReadySnapshot([{ id: 'g-1', name: 'Сервер', my_permissions: ['MANAGE_EMOJIS'] }]),
|
||||||
|
);
|
||||||
|
installFetch(
|
||||||
|
routes({
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds/g-1/emojis',
|
||||||
|
method: 'GET',
|
||||||
|
response: () => apiError('ratelimit.hit', 429),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp('/settings/servers/g-1');
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||||
|
await gateway.greet();
|
||||||
|
|
||||||
|
const fresh = { id: 'e-9', name: 'fresh', file_id: '920', animated: false, url: '/files/920' };
|
||||||
|
gateway.dispatch('GUILD_EMOJIS_UPDATE', { guild_id: 'g-1', emojis: [pepe, fresh] });
|
||||||
|
|
||||||
|
expect(await within(dialog).findByTestId('guild-emoji-e-1')).toHaveTextContent(':pepe:');
|
||||||
|
expect(within(dialog).getByTestId('guild-emoji-e-9')).toHaveTextContent(':fresh:');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user