fix(chat-v2): исправлена загрузка истории - silent mode + prepend

- ScrollManagerV2: использует prepend + height difference вместо полного ре-рендера
- MessageLoaderV2: фильтрует новые сообщения, возвращает только их
- MessageStoreV2: silent mode для загрузки истории (без событий)
- MessageRendererV2: детальное логирование для диагностики

Проблема: Store emit'ил события → appendMessage добавлял в конец → prependMessages пропускал дубликаты
Решение: silent mode отключает события → отрисовка только через prepend → сообщения в начало
This commit is contained in:
Игорь Надеин 2026-01-13 09:06:34 +03:00
parent 0375337543
commit fcee247256
4 changed files with 217 additions and 25 deletions

View File

@ -184,7 +184,21 @@ export class MessageLoaderV2 {
const messages = this._extractMessages(result);
if (messages.length > 0) {
this._processMessages(chatId, messages);
// Определяем какие сообщения НОВЫЕ (не были в Store)
const existingIds = new Set(
this.store.getMessagesForChat(chatId).map(m => m.id)
);
const newMessages = messages.filter(m => !existingIds.has(m.id));
console.log('[MessageLoaderV2] Filtering messages:', {
total: messages.length,
existing: messages.length - newMessages.length,
new: newMessages.length
});
// Добавляем в Store БЕЗ событий (silent mode)
// Отрисовка будет через prependMessages
this._processMessages(chatId, messages, { silent: true });
// Обновляем границы
const oldestMessage = this.store.getOldestMessage(chatId);
@ -192,12 +206,15 @@ export class MessageLoaderV2 {
hasMoreBefore: result.has_more !== false,
oldestId: oldestMessage?.id || null
});
// Возвращаем только НОВЫЕ сообщения
return newMessages;
} else {
this._updateBoundaries(chatId, { hasMoreBefore: false });
}
console.log('[MessageLoaderV2] History loaded:', messages.length, 'messages');
return messages;
return [];
} catch (error) {
if (error.name === 'AbortError') {
@ -447,12 +464,22 @@ export class MessageLoaderV2 {
requestKey
);
const messages = this._extractMessages(result);
// ВАЖНО: Сначала добавляем в Store, затем берем границы из Store
this._processMessages(chatId, messages);
const oldestMessage = this.store.getOldestMessage(chatId);
const newestMessage = this.store.getNewestMessage(chatId);
return {
messages: this._extractMessages(result),
messages,
anchorId: result.anchor_id || aroundId,
anchorIndex: result.anchor_index || null,
hasMoreBefore: result.has_more_before !== false,
hasMoreAfter: result.has_more_after !== false
hasMoreAfter: result.has_more_after !== false,
oldestId: oldestMessage?.id || null,
newestId: newestMessage?.id || null
};
}
@ -468,11 +495,22 @@ export class MessageLoaderV2 {
requestKey
);
const messages = this._extractMessages(result);
// ВАЖНО: Сначала добавляем в Store, затем берем границы из Store
// Store гарантирует правильную сортировку по timestamp
this._processMessages(chatId, messages);
const oldestMessage = this.store.getOldestMessage(chatId);
const newestMessage = this.store.getNewestMessage(chatId);
return {
messages: this._extractMessages(result),
messages,
anchorId: null,
hasMoreBefore: result.has_more !== false,
hasMoreAfter: false
hasMoreAfter: false,
oldestId: oldestMessage?.id || null,
newestId: newestMessage?.id || null
};
}
@ -575,8 +613,12 @@ export class MessageLoaderV2 {
/**
* Обрабатывает и сохраняет сообщения в Store
* @private
* @param {number} chatId
* @param {Array} messages
* @param {Object} [options]
* @param {boolean} [options.silent=false] - Не emit'ить события
*/
_processMessages(chatId, messages) {
_processMessages(chatId, messages, options = {}) {
// Добавляем chat_id если отсутствует
messages.forEach(msg => {
if (!msg.chat_id) {
@ -584,7 +626,7 @@ export class MessageLoaderV2 {
}
});
this.store.addMessages(messages);
this.store.addMessages(messages, options);
}
/**

View File

@ -135,38 +135,147 @@ export class ScrollManagerV2 {
* @returns {Promise<Array>}
*/
async loadMoreHistory() {
console.log('[ScrollManagerV2] ======================================');
console.log('[ScrollManagerV2] loadMoreHistory called');
if (this._isLoadingHistory || this._destroyed) {
console.log('[ScrollManagerV2] ❌ Skipped: already loading or destroyed');
return [];
}
if (!this.loader.hasMoreHistory(this.chatId)) {
console.log('[ScrollManagerV2] ❌ No more history available');
return [];
}
this._isLoadingHistory = true;
try {
// Сохраняем позицию
const savedPosition = this._saveScrollPosition();
// Отключаем smooth scroll для мгновенных изменений
const originalScrollBehavior = this.scrollEl.style.scrollBehavior;
this.scrollEl.style.scrollBehavior = 'auto';
// Сохраняем высоты ДО загрузки
const oldScrollHeight = this.scrollEl.scrollHeight;
const oldScrollTop = this.scrollEl.scrollTop;
const messagesBefore = this.scrollEl.querySelectorAll('.msg[data-message-id]').length;
console.log('[ScrollManagerV2] BEFORE load:', {
oldScrollHeight,
oldScrollTop,
messagesBefore
});
// Загружаем историю
const messages = await this.loader.loadHistory(this.chatId);
if (messages.length > 0) {
// Ре-рендерим
await this.renderer.render(this.chatId);
// Восстанавливаем позицию
this._restoreScrollPosition(savedPosition);
// Обновляем observer
this._updateObserverTarget();
console.log('[ScrollManagerV2] AFTER loader.loadHistory:', {
messagesLoaded: messages.length,
messageIds: messages.map(m => m.id)
});
// Проверяем есть ли еще история ПОСЛЕ загрузки
const stillHasMore = this.loader.hasMoreHistory(this.chatId);
if (messages.length === 0 || !stillHasMore) {
console.log('[ScrollManagerV2] ⚠️ Stopping: no messages or no more history', {
messagesLength: messages.length,
stillHasMore
});
this.scrollEl.style.scrollBehavior = originalScrollBehavior;
return [];
}
console.log('[ScrollManagerV2] ✅ History loaded:', messages.length, 'messages');
// КЛЮЧЕВОЙ МОМЕНТ: НЕ делаем полный render!
// Создаем fragment с новыми сообщениями
const fragment = this.renderer.prependMessages(messages, this.chatId);
console.log('[ScrollManagerV2] Fragment created:', {
hasChildNodes: fragment.hasChildNodes(),
childCount: fragment.childElementCount
});
// Если fragment пустой (все сообщения уже отрендерены), выходим
if (!fragment.hasChildNodes()) {
console.log('[ScrollManagerV2] ⚠️ All messages already rendered');
this.scrollEl.style.scrollBehavior = originalScrollBehavior;
return messages;
}
// ВАЖНО: Проверяем и удаляем потенциальные дубликаты divider
// ПЕРЕД prepend, чтобы избежать визуальных скачков
const firstFragmentChild = fragment.firstElementChild;
if (firstFragmentChild && firstFragmentChild.classList.contains('day-divider')) {
const firstContainerChild = this.scrollEl.firstElementChild;
if (firstContainerChild && firstContainerChild.classList.contains('day-divider')) {
const fragmentDividerText = firstFragmentChild.textContent.trim();
const containerDividerText = firstContainerChild.textContent.trim();
// Если dividers одинаковые, удаляем из fragment
if (fragmentDividerText === containerDividerText) {
fragment.removeChild(firstFragmentChild);
console.log('[ScrollManagerV2] Removed duplicate divider:', fragmentDividerText);
}
}
}
// Prepend fragment в НАЧАЛО контейнера (как в Telegram)
this.scrollEl.prepend(fragment);
const messagesAfterPrepend = this.scrollEl.querySelectorAll('.msg[data-message-id]').length;
console.log('[ScrollManagerV2] AFTER prepend:', {
messagesAfterPrepend,
added: messagesAfterPrepend - messagesBefore
});
// КЛЮЧЕВОЕ ОТЛИЧИЕ Telegram: ДВОЙНОЙ RAF для стабильности!
// Первый RAF - браузер обновляет layout
// Второй RAF - браузер гарантированно применил все изменения
await new Promise(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// КЛЮЧЕВОЙ МОМЕНТ: вычисляем разницу в высоте
const newScrollHeight = this.scrollEl.scrollHeight;
const heightDifference = newScrollHeight - oldScrollHeight;
// Корректируем scrollTop на разницу высоты
// Это сохраняет визуальную позицию пользователя ТОЧНО там же
// БЕЗ КАКИХ-ЛИБО ПРЫЖКОВ!
this.scrollEl.scrollTop = oldScrollTop + heightDifference;
console.log('[ScrollManagerV2] Scroll restored using height difference:', {
oldScrollHeight,
newScrollHeight,
heightDifference,
oldScrollTop,
newScrollTop: this.scrollEl.scrollTop
});
// Обновляем observer на новое первое сообщение
this._updateObserverTarget();
// Восстанавливаем scroll-behavior
this.scrollEl.style.scrollBehavior = originalScrollBehavior;
const finalMessages = this.scrollEl.querySelectorAll('.msg[data-message-id]').length;
console.log('[ScrollManagerV2] ✅ FINAL STATE:', {
finalMessages,
totalAdded: finalMessages - messagesBefore
});
console.log('[ScrollManagerV2] ======================================');
resolve();
});
});
});
return messages;
} catch (error) {
console.error('[ScrollManagerV2] History load failed:', error);
console.error('[ScrollManagerV2] ❌ History load failed:', error);
console.log('[ScrollManagerV2] ======================================');
return [];
} finally {

View File

@ -118,8 +118,16 @@ export class MessageRendererV2 {
* @returns {DocumentFragment} Fragment с элементами для prepend
*/
prependMessages(messages, chatId) {
console.log('[MessageRendererV2] ======================================');
console.log('[MessageRendererV2] prependMessages called:', {
messagesCount: messages?.length || 0,
chatId,
messageIds: messages?.map(m => m.id) || []
});
if (!messages || messages.length === 0) {
console.log('[MessageRendererV2] No messages to prepend');
console.log('[MessageRendererV2] ❌ No messages to prepend');
console.log('[MessageRendererV2] ======================================');
return document.createDocumentFragment();
}
@ -143,19 +151,39 @@ export class MessageRendererV2 {
items.push({ type: 'message', message: msg });
});
console.log('[MessageRendererV2] Items to render:', {
totalItems: items.length,
dividers: items.filter(i => i.type === 'day-divider').length,
messages: items.filter(i => i.type === 'message').length
});
// Создаем элементы
let skipped = 0;
let added = 0;
items.forEach(item => {
if (item.type === 'day-divider') {
fragment.appendChild(this._createDayDivider(item.text));
added++;
} else if (item.type === 'message') {
if (!this.renderedMessages.has(item.message.id)) {
const messageEl = this._createMessageElement(item.message);
fragment.appendChild(messageEl);
this.renderedMessages.add(item.message.id);
added++;
} else {
skipped++;
console.log('[MessageRendererV2] ⚠️ Skipped duplicate:', item.message.id);
}
}
});
console.log('[MessageRendererV2] Fragment created:', {
added,
skipped,
fragmentChildren: fragment.childElementCount
});
console.log('[MessageRendererV2] ======================================');
return fragment;
}

View File

@ -65,6 +65,7 @@ export class MessageStoreV2 {
// Batch mode
this._batchMode = false;
this._silentMode = false;
this._pendingNotifications = [];
console.log('[MessageStoreV2] Initialized');
@ -111,15 +112,21 @@ export class MessageStoreV2 {
/**
* Добавляет множество сообщений (batch)
* @param {Array<Message>} messages - Сообщения
* @param {Object} [options] - Опции
* @param {boolean} [options.silent=false] - Не emit'ить события
* @returns {number} Количество добавленных
*/
addMessages(messages) {
addMessages(messages, options = {}) {
if (!Array.isArray(messages) || messages.length === 0) {
return 0;
}
const { silent = false } = options;
// Включаем batch mode
this._batchMode = true;
const oldSilent = this._silentMode;
this._silentMode = silent;
let addedCount = 0;
try {
@ -131,11 +138,17 @@ export class MessageStoreV2 {
} finally {
// Выключаем batch mode и отправляем все уведомления
this._batchMode = false;
this._flushNotifications();
this._silentMode = oldSilent;
if (!silent) {
this._flushNotifications();
}
}
// Отправляем групповое уведомление
this._notify(STORE_EVENTS.MESSAGES_LOADED, { count: addedCount });
// Отправляем групповое уведомление только если не silent
if (!silent) {
this._notify(STORE_EVENTS.MESSAGES_LOADED, { count: addedCount });
}
return addedCount;
}