Фаза 2: Tab Keep-Alive — план реалізації¶
Цей план зберігається для майбутньої реалізації коли з'являться проблеми з продуктивністю. Промт для Claude: "Реалізуй план з файлу docs/dop/features/tab-keepalive-plan.md"
Проблема¶
React Router розмонтовує/перемонтовує компоненти при кожній зміні URL. Перемикання вкладки = повний unmount старого + mount нового компонента. Навіть з кешем React Query цикл mount→init→render займає 50-200мс. Стан форми, позиція скролу, фільтри — все втрачається.
Рішення: display:none Keep-Alive¶
Рендеримо ВСІ відкриті компоненти вкладок одночасно.
Активна — видима, неактивні — display:none.
Компоненти залишаються змонтованими — нульова затримка при поверненні.
Патерн використовується у VS Code, Chrome DevTools, Gmail.
Архітектура¶
Зараз: <Outlet /> рендерить один компонент за URL.
Після: <TabContentManager /> рендерить ВСІ вкладки; <Outlet /> лише для не-вкладкових сторінок.
Головна проблема: useParams()¶
Коли вкладка A прихована і вкладка B активна, useParams() всередині A повертає параметри B.
Рішення: useTabAwareParams() — замінник що читає з контексту вкладки.
Нові файли¶
1. src/hooks/useTabAwareParams.ts¶
import { createContext, useContext } from 'react';
import { useParams as useRouterParams } from 'react-router-dom';
export const TabParamsContext = createContext<Record<string, string> | null>(null);
export function useTabAwareParams<T extends Record<string, string | undefined>>(): T {
const tabParams = useContext(TabParamsContext);
const routerParams = useRouterParams<T>();
return (tabParams ?? routerParams) as T;
}
2. src/components/Layout/TabContentManager.tsx¶
import { Box } from '@mantine/core';
import { useTabsStore } from '@/store/tabsStore';
import { TabParamsContext } from '@/hooks/useTabAwareParams';
import { SectionContent } from '@/pages/SectionPage';
function parsePathParams(path: string) {
const parts = path.split('/').filter(Boolean);
return {
sectionCode: parts[0],
groupCode: parts[1],
itemCode: parts[2],
recordId: parts[3],
};
}
export function TabContentManager() {
const tabs = useTabsStore((s) => s.tabs);
const activeTabId = useTabsStore((s) => s.activeTabId);
return (
<>
{tabs.map(tab => {
const params = parsePathParams(tab.path);
return (
<TabParamsContext.Provider key={tab.id} value={params}>
<Box
style={{
display: tab.id === activeTabId ? 'flex' : 'none',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
}}
>
<SectionContent {...params} />
</Box>
</TabParamsContext.Provider>
);
})}
</>
);
}
Модифіковані файли¶
3. src/pages/SectionPage.tsx¶
Розділити на:
- SectionContent (export) — приймає sectionCode/groupCode/itemCode/recordId як пропси, без useParams(), без реєстрації вкладок. Вся логіка рендерингу (MasterDataPage, TransactionPage, тощо).
- SectionPage (export default) — залишається route handler для прямого URL-доступу та browser back/forward. Викликає openTab() + рендерить <SectionContent>.
4. src/components/Layout/AppLayout.tsx¶
// Замінити:
<Box style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
<Outlet />
</Box>
// На:
<Box style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{!activeTabId && (
<Box style={{ height: '100%', overflow: 'auto' }}>
<Outlet />
</Box>
)}
<TabContentManager />
</Box>
5. src/store/tabsStore.ts¶
Додати метод:
updateTabPath: (id: string, path: string) => set(state => ({
tabs: state.tabs.map(t => t.id === id ? { ...t, path } : t),
})),
6. Заміна імпортів useParams у 12 файлах¶
Проста зміна імпорту — без змін логіки:
// Було:
import { useParams } from 'react-router-dom';
// Стало:
import { useTabAwareParams as useParams } from '@/hooks/useTabAwareParams';
Файли:
- src/components/MasterData/MasterDataPage.tsx
- src/components/MasterData/MasterDataList.tsx
- src/components/MasterData/MasterDataForm.tsx
- src/components/MasterData/MasterDataPrint.tsx
- src/components/TransactionData/TransactionPage.tsx
- src/components/TransactionData/TransactionList.tsx
- src/components/TransactionData/TransactionForm/TransactionForm.tsx
- src/components/TransactionData/TransactionPrint.tsx
- src/components/Fleet/Waybill/WaybillPage.tsx
- src/components/Fleet/Waybill/WaybillList.tsx
- src/components/Fleet/Waybill/WaybillForm.tsx
- src/pages/SectionPage.tsx
Що це дає¶
| Зараз | Після |
|---|---|
| Перемикання: unmount/remount (50-2000мс) | Миттєво (toggle display:none, <1мс) |
| Стан форми втрачається | Зберігається |
| Позиція скролу втрачається | Зберігається |
| Фільтри/сортування скидаються | Зберігаються |
| Скелетон при поверненні | Без скелетону |
Ризики¶
- Пам'ять: всі вкладки змонтовані (макс 10 — допустимо)
useLocation()в кешованих вкладках повертає чужий URL (2-3 файли — перевірити)- Рекомендація: робити в окремій гілці
feature/tab-keepalive
Верифікація¶
- Відкрити 5+ вкладок (довідники, форми, документи)
- Перемикатися — має бути миттєво, без скелетону
- Ввести дані у форму, перемкнутися, повернутися — дані на місці
- Проскролити список, перемкнутися, повернутися — скрол збережений
- Лічильник рендерів — перемикання на кешовану вкладку = 0-1 рендерів
- Browser back/forward — працює коректно
- Закрити вкладку — компонент розмонтується
- Не-вкладкові сторінки (дашборд, налаштування) — без змін
🔮 Deferred / Ideas¶
Автоматичне вивантаження кешованих вкладок (LRU)¶
Мотивація: якщо користувач відкриває 10+ вкладок з важкими компонентами (карти, великі списки), RAM росте лінійно Чому відкладено: Phase 1 (manual tab close) покриває 90% кейсів; жодної скарги на RAM досі не було Trigger: >3 скарги на повільність браузера при великій кількості відкритих вкладок
Persist cache між сесіями (sessionStorage)¶
Мотивація: при reload сторінки всі вкладки відновлюються, але стан форм втрачається Чому відкладено: складність серіалізації (refs, функції, DOM-позиції) перевищує вигоду Trigger: feature request від >5 користувачів