Keep-Alive Tabs — Звіт про реалізацію¶
Проблема¶
При кожному переключенні між вкладками (tabs) в DOP-інтерфейсі React повністю розмонтовував попередній компонент і монтував новий з нуля. Це означало:
- Втрата стану компонента — позиція скролу, стан форм, фільтри, сортування
- Повторні API-запити — кожне відкриття = новий запит до бекенду (навіть якщо React Query кешує дані, компонент проходить через loading state)
- Час монтування — важкі компоненти (WaybillList: 735 рядків з virtualizer, resize handlers) монтуються заново кожен раз
- Помітна затримка — навіть з 1 записом у базі, різниця між миттєвим показом і 200-500мс затримки (mount + API + render) відчутна
Для порівняння: 1С, SAP тримають відкриті вкладки в пам'яті — переключення між ними миттєве.
Рішення: KeepAliveOutlet¶
Архітектура¶
Замість стандартного <Outlet /> React Router, який рендерить лише один активний маршрут, створено <KeepAliveOutlet />, який:
- Кешує outlet-елементи для кожної відвіданої вкладки
- Приховує неактивні вкладки через
display: none(замість unmount) - Заморожує контекст роутера для прихованих вкладок через
UNSAFE_RouteContext+UNSAFE_LocationContext— щобuseParams()/useLocation()всередині них повертали правильні значення, а не значення поточного URL - Показує звичайний
<Outlet />для сторінок без вкладок (dashboard, settings, chat, profile)
Потік даних¶
Користувач відкриває /fleet/documents/waybills
→ React Router матчить SectionPage
→ KeepAliveOutlet:
1. useOutlet() → отримує <SectionPage /> елемент
2. Знаходить відповідну вкладку в tabsStore
3. Зберігає в кеш: { element, routeCtx, locationCtx }
4. Рендерить: активний outlet (display: contents)
Користувач перемикається на /fleet/fleet/drivers
→ React Router матчить SectionPage (для drivers)
→ KeepAliveOutlet:
1. Кешує drivers outlet
2. waybills tab: display: none + FrozenContext(waybills params)
3. drivers tab: display: contents (активний, свіжий outlet)
→ WaybillList НЕ розмонтовується! Зберігає скрол, стан, дані.
Користувач повертається на waybills
→ KeepAliveOutlet:
1. waybills tab: display: contents (миттєво видимий!)
2. drivers tab: display: none + FrozenContext(drivers params)
→ Нуль затримки, нуль API-запитів, нуль перемонтування.
Захист від витоків пам'яті¶
- MAX_CACHED_TABS = 12 — LRU-обмеження, найстаріші вкладки витісняються
- Синхронізація з tabsStore — при
closeTab()запис видаляється з кешу → компонент unmount - closeAllTabs() — очищає весь кеш
Змінені файли¶
| Файл | Зміна |
|---|---|
frontend/erp/src/components/Layout/KeepAliveOutlet.tsx |
НОВИЙ — KeepAlive обгортка |
frontend/erp/src/components/Layout/AppLayout.tsx |
Замінено <Outlet /> → <KeepAliveOutlet /> |
backend/fleet/views.py |
Додано trailer до select_related (N+1 fix) |
backend/fleet/models/waybill.py |
Додано DB індекси для сортування |
frontend/erp/src/components/Fleet/Waybill/WaybillList.tsx |
staleTime: 5хв, gcTime: 10хв |
Технічні деталі¶
Чому UNSAFE_RouteContext?¶
React Router v7 надає UNSAFE_RouteContext і UNSAFE_LocationContext — внутрішні контексти, через які useParams() і useLocation() отримують свої значення. Обгортаючи приховані вкладки у Provider з замороженими значеннями цих контекстів, ми гарантуємо що:
useParams()повертає params на момент останнього активного рендеру вкладкиuseLocation()повертає location на момент останнього активного рендеру вкладки- Жоден з 28 компонентів, що використовують
useParams(), не потребує змін
Префікс UNSAFE_ означає що API може змінитись у майбутніх версіях React Router, але він стабільний з v6.4+ і присутній у v7.13 (поточна версія проекту).
Чому display: none, а не відсутність у DOM?¶
display: noneзберігає компонент змонтованим — React не виконує unmount/mount- Стан (useState, useRef), підписки (useEffect), кеш (React Query) — все зберігається
- Хуки не викликаються повторно при показі — компонент просто стає видимим
- Альтернативи (React.memo, портали) не забезпечують повного збереження стану
Чому не потрібні зміни в tabsStore?¶
KeepAliveOutlet читає tabs[] з tabsStore на кожному рендері. Коли вкладку закривають:
1. closeTab(id) видаляє tab з масиву
2. React перерендерює KeepAliveOutlet
3. KeepAliveOutlet бачить що tab відсутній → видаляє з кешу
4. React розмонтовує компонент (key більше не існує)
Що ще було виправлено (бонус)¶
Backend: N+1 запити¶
Проблема: WaybillViewSet.get_queryset() не включав trailer у select_related. Серіалізер WaybillListSerializer викликав str(obj.trailer) для кожного рядка → при 500 путівках це 500 окремих SQL-запитів.
Виправлення: Додано trailer до select_related('vehicle', 'trailer', 'driver', 'route', 'department', 'created_by').
Backend: DB індекси¶
Додано композитні індекси на таблицю Waybill:
- (-date, -number) — покриває дефолтне сортування ordering = ['-date', '-number']
- (tenant, -date) — покриває фільтрацію по tenant + сортування
Frontend: React Query кешування¶
WaybillList useInfiniteQuery:
- staleTime: 1 хв → 5 хв (відповідає глобальному default)
- gcTime: додано 10 хв (кеш живе після unmount)
Очікуваний ефект¶
| Сценарій | До | Після |
|---|---|---|
| Переключення між вкладками | ~300-500мс (unmount + mount + API + render) | Миттєво (display toggle) |
| Повернення на давно відкриту вкладку | ~300-500мс (повний remount) | Миттєво (компонент не розмонтовувався) |
| Список путівок (500 записів, backend) | N+1 queries (500 зайвих) | 1 JOIN-запит |
| Перше відкриття списку | Без змін | Без змін (потрібен API-запит) |