diff --git a/IMPLEMENTATION_LOG.md b/IMPLEMENTATION_LOG.md
index 20c5ab0..97561ec 100644
--- a/IMPLEMENTATION_LOG.md
+++ b/IMPLEMENTATION_LOG.md
@@ -230,3 +230,17 @@
- Как проверялось: vitest 96/96; `npm run check` чисто; caller'ы дерева (роут /tree, MCP) — формат не изменился.
- Влияние на поиск/UX: нет.
- Подводные камни: raw CTE под PostgreSQL — при переименовании колонок tasks/forms обновить вручную (drizzle не проверяет); getRelatedTasks (граф task_relations) сознательно оставлен — кандидат на отдельный шаг.
+
+---
+
+## [1.3] Виртуализация списков (@tanstack/react-virtual)
+
+- Статус: ✅ done
+- Зачем: длинные реестры/чаты не рендерят тысячи DOM-узлов (Фаза 1).
+- Что изменено:
+ - `@tanstack/react-virtual@3.14.11` в dependencies (+6-7 kB gzip).
+ - `client/src/components/TaskRegistry.tsx` — виртуализация строк таблицы (padding-строки в нативном tbody, динамическое измерение высоты через ResizeObserver, overscan 10, getItemKey=task.id). Порог 30 строк — меньше рендер 1:1 прежний. Массовое выделение (Shift+клик через anchorTaskId), сортировка, мгновенный клиентский поиск (инвариант), DnD колонок, inline-редакторы — работают по полному массиву, не по DOM.
+ - `client/src/components/TaskChat.tsx` — виртуализация сообщений (плоский список: дата-разделители + сообщения, динамическая высота, overscan 10). Порог 100 элементов. Автоскролл к новому, scrollToMessage с fallback scrollToIndex, оптимистичная отправка, реакции/опросы/вложения сохранены.
+- Как проверялось: `npm run check` чисто; vitest 96/96; `npm run build` собирается.
+- Влияние на поиск/UX: поиск в реестре мгновенный как и был (фильтрация до виртуализации).
+- Подводные камни: в виртуальном чате дата-разделители не sticky; возможна «игра» ширин колонок реестра при table-layout:auto; уход редактируемой строки за overscan размонтирует несохранённый ввод (стандарт виртсписков). Ручная проверка в браузере: реестр с длинными значениями, чат >100 сообщений (автоскролл, скролл к ответу).
diff --git a/client/src/components/TaskChat.tsx b/client/src/components/TaskChat.tsx
index 37b6313..2bbb941 100644
--- a/client/src/components/TaskChat.tsx
+++ b/client/src/components/TaskChat.tsx
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
+import { useVirtualizer } from '@tanstack/react-virtual';
import { useForm } from 'react-hook-form';
import { useLocation } from 'wouter';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -186,6 +187,15 @@ const formatDateHeader = (date: Date) => {
}
};
+// Порог виртуализации чата: при меньшем числе элементов (сообщения + разделители дат)
+// рендерим как обычно, чтобы не менять поведение коротких чатов
+const CHAT_VIRTUALIZATION_THRESHOLD = 100;
+
+// Элемент виртуализированного списка чата: разделитель даты или сообщение
+type ChatListItem =
+ | { type: 'header'; key: string; date: Date }
+ | { type: 'message'; key: string; message: TaskMessage };
+
const TaskChat = ({ taskId }: TaskChatProps) => {
const { toast } = useToast();
const { user } = useAuth();
@@ -359,6 +369,28 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
const messages: TaskMessage[] = (messagesData as any)?.messages || [];
const groupedMessages = groupMessagesByDate(messages);
+ // Плоский список для виртуализации: разделители дат + сообщения
+ const chatItems: ChatListItem[] = [];
+ groupedMessages.forEach((group, groupIndex) => {
+ chatItems.push({ type: 'header', key: `header-${groupIndex}-${format(group.date, 'yyyy-MM-dd')}`, date: group.date });
+ group.messages.forEach(message => {
+ chatItems.push({ type: 'message', key: `msg-${message.id}`, message });
+ });
+ });
+
+ // Виртуализация длинных чатов: рендерятся только видимые сообщения + overscan.
+ // Динамическая высота измеряется через measureElement (ResizeObserver).
+ const shouldVirtualizeChat = chatItems.length > CHAT_VIRTUALIZATION_THRESHOLD;
+ const chatVirtualizer = useVirtualizer({
+ count: chatItems.length,
+ getScrollElement: () =>
+ (scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null) ?? null,
+ estimateSize: (index) => (chatItems[index]?.type === 'header' ? 28 : 64),
+ overscan: 10,
+ getItemKey: (index) => chatItems[index]?.key ?? index,
+ enabled: shouldVirtualizeChat,
+ });
+
// Загрузка реакций и опросов (batch)
useQuery({
queryKey: ['/api/tasks', taskId, 'reactions'],
@@ -388,17 +420,35 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
// Функция для скролла к оригинальному сообщению
const scrollToMessage = (messageId: number) => {
+ const highlightElement = (el: Element) => {
+ // Подсвечиваем сообщение на короткое время с помощью стилей
+ el.classList.add('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500');
+ setTimeout(() => {
+ el.classList.remove('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500');
+ }, 2000);
+ };
+
const messageElement = document.querySelector(`[data-message-id="${messageId}"]`);
if (messageElement) {
- messageElement.scrollIntoView({
- behavior: 'smooth',
- block: 'center'
+ messageElement.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center'
});
- // Подсвечиваем сообщение на короткое время с помощью стилей
- messageElement.classList.add('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500');
- setTimeout(() => {
- messageElement.classList.remove('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500');
- }, 2000);
+ highlightElement(messageElement);
+ return;
+ }
+
+ // Виртуализированный список: целевое сообщение может быть не отрендерено —
+ // прокручиваем к его индексу через virtualizer и подсвечиваем после рендера
+ if (shouldVirtualizeChat) {
+ const index = chatItems.findIndex(item => item.type === 'message' && item.message.id === messageId);
+ if (index >= 0) {
+ chatVirtualizer.scrollToIndex(index, { align: 'center', behavior: 'smooth' });
+ setTimeout(() => {
+ const el = document.querySelector(`[data-message-id="${messageId}"]`);
+ if (el) highlightElement(el);
+ }, 400);
+ }
}
};
@@ -432,6 +482,11 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
// Функция для прокрутки к последнему сообщению
const scrollToBottom = () => {
+ if (shouldVirtualizeChat) {
+ // В виртуализированном списке прокручиваем к последнему элементу через virtualizer
+ chatVirtualizer.scrollToIndex(chatItems.length - 1, { align: 'end' });
+ return;
+ }
const scrollElement = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]');
if (scrollElement) {
scrollElement.scrollTop = scrollElement.scrollHeight;
@@ -1318,6 +1373,308 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
return ;
};
+ // Рендер одного сообщения (общий для обычного и виртуализированного списка)
+ const renderMessage = (message: TaskMessage) => {
+ const isMyMessage = user && message.author && message.author.id === user.id;
+ return (
+
+ {isMyMessage ? (
+ // Мое сообщение - справа без аватара как в WhatsApp
+
+
+
+ {/* Показываем информацию о родительском сообщении если это ответ */}
+ {message.replyToMessage && (
+
{
+ if (message.replyToMessage?.id) {
+ scrollToMessage(message.replyToMessage.id);
+ }
+ }}
+ >
+
+ Ответ на сообщение {message.replyToMessage.author.firstName} {message.replyToMessage.author.middleName || ''} {message.replyToMessage.author.lastName}
+
+
+ {message.replyToMessage.message.length > 50
+ ? message.replyToMessage.message.substring(0, 50) + '...'
+ : message.replyToMessage.message}
+
+
+ )}
+
+ {/* Текст сообщения или форма редактирования */}
+ {editingMessage?.id === message.id ? (
+
+ ) : (
+
+ {renderMessageWithMentions(message.message, message.mentionedUserIds)}
+
+ )}
+
+ {/* Время и галочка прочтения внизу справа */}
+
+ {message.pending && (
+
+ ожидает отправки
+
+ )}
+
+ {format(new Date(message.createdAt), 'HH:mm')}
+
+ {message.pending ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Кнопки действий для моих сообщений - слева от пузыря */}
+
+ startReply(message)}
+ title="Ответить"
+ data-testid={`reply-btn-${message.id}`}
+ >
+
+
+ startEdit(message)}
+ title="Редактировать"
+ data-testid={`edit-btn-${message.id}`}
+ >
+
+
+
+
+ {message.attachments && message.attachments.length > 0 && (
+
+ )}
+ {/* Опрос прикреплённый к сообщению */}
+ {pollsMap[message.id] && (
+
setPollsMap(prev => ({ ...prev, [message.id]: p }))}
+ isOwn={true}
+ />
+ )}
+ {/* Реакции */}
+
+ setReactionsMap(prev => ({ ...prev, [message.id]: r }))}
+ />
+
+
+
+ ) : message.messageType === 'bot' && message.bot ? (
+ // Сообщение от бота - слева с иконкой бота
+
+
+ {message.bot.avatarUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ message.bot && openBotDirectMutation.mutate(message.bot.id)}
+ >
+ {message.bot.name}
+
+
+
+ {format(new Date(message.createdAt), 'HH:mm')}
+
+
+
+ {/* Текст сообщения от бота */}
+
+
+ {message.message}
+
+
+ {/* Кнопки бота */}
+ {message.botButtons && message.botButtons.length > 0 && (
+
+ {message.botButtons.map((button, btnIndex) => (
+ botCallbackMutation.mutate({
+ messageId: message.id,
+ callbackData: button.callbackData
+ })}
+ disabled={botCallbackMutation.isPending}
+ data-testid={`bot-button-${message.id}-${btnIndex}`}
+ >
+ {button.text}
+
+ ))}
+
+ )}
+
+
+
+ ) : (
+ // Сообщение от другого пользователя - слева с аватаром
+
+
+
+
+
+ {/* Имя + Время на одной строке с обрезкой */}
+
+
+ message.author && openDirectMutation.mutate(message.author.id)}
+ >
+ {`${message.author?.firstName || 'Система'} ${message.author?.middleName || ''} ${message.author?.lastName || ''}`.trim()}
+
+ {message.messageType !== 'comment' && (
+
+ {message.messageType === 'system' ? 'Система' : 'Статус'}
+
+ )}
+
+
+ {format(new Date(message.createdAt), 'HH:mm')}
+
+
+ startReply(message)}
+ data-testid={`reply-button-${message.id}`}
+ >
+
+
+
+
+
+ {/* Показываем информацию о родительском сообщении если это ответ */}
+ {message.replyToMessage && (
+
{
+ if (message.replyToMessage?.id) {
+ scrollToMessage(message.replyToMessage.id);
+ }
+ }}
+ >
+
+ Ответ на сообщение {message.replyToMessage.author.firstName} {message.replyToMessage.author.middleName || ''} {message.replyToMessage.author.lastName}
+
+
+ {message.replyToMessage.message.length > 50
+ ? message.replyToMessage.message.substring(0, 50) + '...'
+ : message.replyToMessage.message}
+
+
+ )}
+
+ {/* Текст сообщения (без редактирования для чужих сообщений) */}
+
+
+ {renderMessageWithMentions(message.message, message.mentionedUserIds)}
+
+
+ {message.attachments && message.attachments.length > 0 && (
+
+ )}
+ {/* Опрос прикреплённый к сообщению */}
+ {pollsMap[message.id] && (
+
setPollsMap(prev => ({ ...prev, [message.id]: p }))}
+ isOwn={false}
+ />
+ )}
+ {/* Реакции */}
+ setReactionsMap(prev => ({ ...prev, [message.id]: r }))}
+ />
+
+
+ )}
+
+ );
+ };
+
if (taskId === 0) {
return (
@@ -1341,6 +1698,44 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
+ ) : shouldVirtualizeChat ? (
+ // Виртуализированный список: рендерятся только видимые элементы.
+ // Разделители дат здесь НЕ sticky (абсолютное позиционирование строк).
+
+ {chatVirtualizer.getVirtualItems().map(virtualItem => {
+ const item = chatItems[virtualItem.index];
+ return (
+
+ {item.type === 'header' ? (
+
+
+
+ {formatDateHeader(item.date)}
+
+
+
+ ) : (
+ renderMessage(item.message)
+ )}
+
+ );
+ })}
+
) : (
{groupedMessages.map((group, groupIndex) => (
@@ -1356,307 +1751,7 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
{/* Сообщения в группе */}
- {group.messages.map((message) => {
- const isMyMessage = user && message.author && message.author.id === user.id;
-
- return (
-
- {isMyMessage ? (
- // Мое сообщение - справа без аватара как в WhatsApp
-
-
-
- {/* Показываем информацию о родительском сообщении если это ответ */}
- {message.replyToMessage && (
-
{
- if (message.replyToMessage?.id) {
- scrollToMessage(message.replyToMessage.id);
- }
- }}
- >
-
- Ответ на сообщение {message.replyToMessage.author.firstName} {message.replyToMessage.author.middleName || ''} {message.replyToMessage.author.lastName}
-
-
- {message.replyToMessage.message.length > 50
- ? message.replyToMessage.message.substring(0, 50) + '...'
- : message.replyToMessage.message}
-
-
- )}
-
- {/* Текст сообщения или форма редактирования */}
- {editingMessage?.id === message.id ? (
-
- ) : (
-
- {renderMessageWithMentions(message.message, message.mentionedUserIds)}
-
- )}
-
- {/* Время и галочка прочтения внизу справа */}
-
- {message.pending && (
-
- ожидает отправки
-
- )}
-
- {format(new Date(message.createdAt), 'HH:mm')}
-
- {message.pending ? (
-
- ) : (
-
- )}
-
-
- {/* Кнопки действий для моих сообщений - слева от пузыря */}
-
- startReply(message)}
- title="Ответить"
- data-testid={`reply-btn-${message.id}`}
- >
-
-
- startEdit(message)}
- title="Редактировать"
- data-testid={`edit-btn-${message.id}`}
- >
-
-
-
-
- {message.attachments && message.attachments.length > 0 && (
-
- )}
- {/* Опрос прикреплённый к сообщению */}
- {pollsMap[message.id] && (
-
setPollsMap(prev => ({ ...prev, [message.id]: p }))}
- isOwn={true}
- />
- )}
- {/* Реакции */}
-
- setReactionsMap(prev => ({ ...prev, [message.id]: r }))}
- />
-
-
-
- ) : message.messageType === 'bot' && message.bot ? (
- // Сообщение от бота - слева с иконкой бота
-
-
- {message.bot.avatarUrl ? (
-
- ) : (
-
- )}
-
-
-
-
- message.bot && openBotDirectMutation.mutate(message.bot.id)}
- >
- {message.bot.name}
-
-
-
- {format(new Date(message.createdAt), 'HH:mm')}
-
-
-
- {/* Текст сообщения от бота */}
-
-
- {message.message}
-
-
- {/* Кнопки бота */}
- {message.botButtons && message.botButtons.length > 0 && (
-
- {message.botButtons.map((button, btnIndex) => (
- botCallbackMutation.mutate({
- messageId: message.id,
- callbackData: button.callbackData
- })}
- disabled={botCallbackMutation.isPending}
- data-testid={`bot-button-${message.id}-${btnIndex}`}
- >
- {button.text}
-
- ))}
-
- )}
-
-
-
- ) : (
- // Сообщение от другого пользователя - слева с аватаром
-
-
-
-
-
- {/* Имя + Время на одной строке с обрезкой */}
-
-
- message.author && openDirectMutation.mutate(message.author.id)}
- >
- {`${message.author?.firstName || 'Система'} ${message.author?.middleName || ''} ${message.author?.lastName || ''}`.trim()}
-
- {message.messageType !== 'comment' && (
-
- {message.messageType === 'system' ? 'Система' : 'Статус'}
-
- )}
-
-
- {format(new Date(message.createdAt), 'HH:mm')}
-
-
- startReply(message)}
- data-testid={`reply-button-${message.id}`}
- >
-
-
-
-
-
- {/* Показываем информацию о родительском сообщении если это ответ */}
- {message.replyToMessage && (
-
{
- if (message.replyToMessage?.id) {
- scrollToMessage(message.replyToMessage.id);
- }
- }}
- >
-
- Ответ на сообщение {message.replyToMessage.author.firstName} {message.replyToMessage.author.middleName || ''} {message.replyToMessage.author.lastName}
-
-
- {message.replyToMessage.message.length > 50
- ? message.replyToMessage.message.substring(0, 50) + '...'
- : message.replyToMessage.message}
-
-
- )}
-
- {/* Текст сообщения (без редактирования для чужих сообщений) */}
-
-
- {renderMessageWithMentions(message.message, message.mentionedUserIds)}
-
-
- {message.attachments && message.attachments.length > 0 && (
-
- )}
- {/* Опрос прикреплённый к сообщению */}
- {pollsMap[message.id] && (
-
setPollsMap(prev => ({ ...prev, [message.id]: p }))}
- isOwn={false}
- />
- )}
- {/* Реакции */}
- setReactionsMap(prev => ({ ...prev, [message.id]: r }))}
- />
-
-
- )}
-
- );
- })}
+ {group.messages.map((message) => renderMessage(message))}
))}
diff --git a/client/src/components/TaskRegistry.tsx b/client/src/components/TaskRegistry.tsx
index ecf74a9..a837f47 100644
--- a/client/src/components/TaskRegistry.tsx
+++ b/client/src/components/TaskRegistry.tsx
@@ -1,5 +1,6 @@
-import { useState, useMemo, useEffect, useCallback } from 'react';
+import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useQueries } from '@tanstack/react-query';
+import { useVirtualizer } from '@tanstack/react-virtual';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@@ -261,6 +262,11 @@ const REGISTRY_INLINE_TYPES = new Set([
// Типы полей, которые переключаются по одиночному клику
const REGISTRY_QUICK_TOGGLE_TYPES = new Set(['checkbox', 'toggle']);
+// Порог виртуализации реестра: при меньшем числе строк рендерим как обычно
+const REGISTRY_VIRTUALIZATION_THRESHOLD = 30;
+// Оценка высоты строки до измерения (h-10 по UI Style Guide)
+const REGISTRY_ROW_ESTIMATED_HEIGHT = 40;
+
const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [], roles = [], formId, onFilteredCountChange, onTaskClick, filters, sort, setFilter, clearFilter, clearAll, setSort, clearSort, hasActiveFilters, searches, onAddSearch, onRemoveSearch, isAdmin, selectedTaskIds, anchorTaskId, onToggleSelected, onToggleSelectAll, onSelectRange, onSetAnchorTaskId, onClearSelection, onRequestBulkDelete }: TaskRegistryProps) => {
const { toast } = useToast();
@@ -547,6 +553,31 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
});
}, [tasks, searches, inputValue, showCompleted, statusMap, taskMatchesSearch]);
+ // Виртуализация строк реестра: рендерятся только видимые строки + overscan.
+ // Фильтрация/сортировка/выделение работают по полному filteredTasks и не затронуты.
+ const tableScrollRef = useRef
(null);
+ const shouldVirtualize = filteredTasks.length > REGISTRY_VIRTUALIZATION_THRESHOLD;
+ const rowVirtualizer = useVirtualizer({
+ count: filteredTasks.length,
+ getScrollElement: () => tableScrollRef.current,
+ estimateSize: () => REGISTRY_ROW_ESTIMATED_HEIGHT,
+ overscan: 10,
+ getItemKey: (index) => filteredTasks[index]?.id ?? index,
+ enabled: shouldVirtualize,
+ });
+ const virtualRows = rowVirtualizer.getVirtualItems();
+ const virtualPaddingTop = shouldVirtualize && virtualRows.length > 0 ? virtualRows[0].start : 0;
+ const virtualPaddingBottom =
+ shouldVirtualize && virtualRows.length > 0
+ ? rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end
+ : 0;
+ // Строки к рендеру: при виртуализации — только видимое окно, иначе все
+ const rowsToRender: Array<{ task: TaskData; virtualIndex: number | null }> = shouldVirtualize
+ ? virtualRows.map(v => ({ task: filteredTasks[v.index], virtualIndex: v.index }))
+ : filteredTasks.map(task => ({ task, virtualIndex: null }));
+ // Полное число колонок (для colSpan padding-строк виртуализации)
+ const totalColumnCount = (isAdmin ? 1 : 0) + columnOrder.filter(key => visibleColumns[key]).length;
+
const fetchTaskColumnSuggestions = useCallback((column: string) => async (search: string): Promise => {
const params = new URLSearchParams({ column, search });
const context = Object.fromEntries(Object.entries(filters).filter(([k, v]) => k !== column && !!v));
@@ -815,7 +846,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
{/* Таблица задач */}
-
+
{filteredTasks.length === 0 ? (
{tasks.length === 0 ? (
@@ -937,7 +968,13 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
- {filteredTasks.map((task) => {
+ {/* Верхняя padding-строка виртуализации: замещает невидимые строки выше окна */}
+ {virtualPaddingTop > 0 && (
+
+
+
+ )}
+ {rowsToRender.map(({ task, virtualIndex }) => {
const taskStatus = statusMap.get(task.currentStatusId);
const isCompleted = task.isCompleted || taskStatus?.isFinal || false;
const isOverdue = !isCompleted && !!task.dueDate && new Date(task.dueDate) < new Date();
@@ -945,6 +982,8 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
return (
handleRowClick(task, e)}
@@ -1166,6 +1205,12 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
);
})}
+ {/* Нижняя padding-строка виртуализации: замещает невидимые строки ниже окна */}
+ {virtualPaddingBottom > 0 && (
+
+
+
+ )}
diff --git a/package-lock.json b/package-lock.json
index 0d35e33..ab91a90 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -54,6 +54,7 @@
"@radix-ui/react-tooltip": "^1.2.0",
"@sendgrid/mail": "^8.1.5",
"@tanstack/react-query": "^5.60.5",
+ "@tanstack/react-virtual": "^3.14.11",
"@tiptap/extension-color": "^3.27.0",
"@tiptap/extension-highlight": "^3.27.0",
"@tiptap/extension-image": "^3.27.0",
@@ -5693,6 +5694,33 @@
"react": "^18 || ^19"
}
},
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.14.11",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.11.tgz",
+ "integrity": "sha512-SStWf8bYdTgAquYqG4Pi2+d1XimQKoCIJ94H3jsm0D6UA0yqffvWuvUzrrQ4idewFWq8BSPpahnmLVosJIAs6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.17.9"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.17.9",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.9.tgz",
+ "integrity": "sha512-M8Bzy7CCMvUjRiuoHVH9mRjyUVczRs8v8RcUEphLFGc445ZP4KQ15Y1sLqhQqJi/HgGJrxfCHnEnIX0x9mVrBg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@tiptap/core": {
"version": "3.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.1.tgz",
diff --git a/package.json b/package.json
index 4639697..fb46313 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,7 @@
"@radix-ui/react-tooltip": "^1.2.0",
"@sendgrid/mail": "^8.1.5",
"@tanstack/react-query": "^5.60.5",
+ "@tanstack/react-virtual": "^3.14.11",
"@tiptap/extension-color": "^3.27.0",
"@tiptap/extension-highlight": "^3.27.0",
"@tiptap/extension-image": "^3.27.0",