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

Фаза 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

Верифікація

  1. Відкрити 5+ вкладок (довідники, форми, документи)
  2. Перемикатися — має бути миттєво, без скелетону
  3. Ввести дані у форму, перемкнутися, повернутися — дані на місці
  4. Проскролити список, перемкнутися, повернутися — скрол збережений
  5. Лічильник рендерів — перемикання на кешовану вкладку = 0-1 рендерів
  6. Browser back/forward — працює коректно
  7. Закрити вкладку — компонент розмонтується
  8. Не-вкладкові сторінки (дашборд, налаштування) — без змін

🔮 Deferred / Ideas

Автоматичне вивантаження кешованих вкладок (LRU)

Мотивація: якщо користувач відкриває 10+ вкладок з важкими компонентами (карти, великі списки), RAM росте лінійно Чому відкладено: Phase 1 (manual tab close) покриває 90% кейсів; жодної скарги на RAM досі не було Trigger: >3 скарги на повільність браузера при великій кількості відкритих вкладок

Persist cache між сесіями (sessionStorage)

Мотивація: при reload сторінки всі вкладки відновлюються, але стан форм втрачається Чому відкладено: складність серіалізації (refs, функції, DOM-позиції) перевищує вигоду Trigger: feature request від >5 користувачів