Перейти до змісту

Keep-Alive Tabs — Звіт про реалізацію

Проблема

При кожному переключенні між вкладками (tabs) в DOP-інтерфейсі React повністю розмонтовував попередній компонент і монтував новий з нуля. Це означало:

  1. Втрата стану компонента — позиція скролу, стан форм, фільтри, сортування
  2. Повторні API-запити — кожне відкриття = новий запит до бекенду (навіть якщо React Query кешує дані, компонент проходить через loading state)
  3. Час монтування — важкі компоненти (WaybillList: 735 рядків з virtualizer, resize handlers) монтуються заново кожен раз
  4. Помітна затримка — навіть з 1 записом у базі, різниця між миттєвим показом і 200-500мс затримки (mount + API + render) відчутна

Для порівняння: 1С, SAP тримають відкриті вкладки в пам'яті — переключення між ними миттєве.

Рішення: KeepAliveOutlet

Архітектура

Замість стандартного <Outlet /> React Router, який рендерить лише один активний маршрут, створено <KeepAliveOutlet />, який:

  1. Кешує outlet-елементи для кожної відвіданої вкладки
  2. Приховує неактивні вкладки через display: none (замість unmount)
  3. Заморожує контекст роутера для прихованих вкладок через UNSAFE_RouteContext + UNSAFE_LocationContext — щоб useParams() / useLocation() всередині них повертали правильні значення, а не значення поточного URL
  4. Показує звичайний <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-запит)