agent-ecosystem/docs/iterations/diff-view/continuous-scroll/phase-2-lazy-loading.md
iliya 0df816bba6 feat: enhance diff view with continuous scroll and lazy loading
- Introduced a continuous scroll mode for the diff view, allowing users to review multiple files in a single scrollable container.
- Added lazy loading functionality to improve performance by loading file content as it approaches the viewport.
- Implemented a new portion collapse feature to allow users to expand unchanged regions incrementally, enhancing context retention during reviews.
- Updated navigation to support smooth scrolling between files and improved keyboard shortcuts for file navigation.
- Enhanced the review toolbar to manage actions across all files, including bulk accept/reject options.
- Added new hooks and components to support the continuous scroll and lazy loading features, ensuring a seamless user experience.
2026-02-25 15:39:14 +02:00

790 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Фаза 2: Lazy Loading контента
## 1. Обзор
**Предпосылка:** В фазе 1 continuous scroll рендерит все файлы одновременно. Но контент файлов (`FileChangeWithContent`) загружается через IPC-вызов `fetchFileContent(teamName, memberName, filePath)` — это сетевой запрос к main process, который читает файл с диска, строит diff и возвращает `originalFullContent` + `modifiedFullContent`.
**Проблема:** При открытии review с 30+ файлами загрузка всех сразу:
- Блокирует main process 30 последовательными IPC-вызовами
- UI показывает 30 skeleton placeholders одновременно
- Пользователь видит контент только после загрузки всех файлов
- Для больших файлов (>10K строк) задержка ощутима
**Решение:** Lazy loading — контент загружается по мере приближения файла к viewport:
- Первые 5 файлов предзагружаются при mount (без ожидания scroll)
- Остальные файлы загружаются при пересечении rootMargin "200% 0px" (2 viewport-высоты до видимости)
- Максимум 3 параллельных загрузки (throttle) — не перегружать main process
- Приоритет: файлы ближе к viewport загружаются раньше
**Результат:** Пользователь видит первые файлы через ~200ms, остальные подгружаются бесшовно при скролле.
---
## 2. Новые файлы
### 2.1. `useLazyFileContent.ts`
**Путь:** `src/renderer/hooks/useLazyFileContent.ts`
**Назначение:** IntersectionObserver-based lazy loading контента файлов через `fetchFileContent` из changeReviewSlice.
#### Interface
```typescript
import type { RefObject } from 'react';
import type { FileChangeWithContent } from '@shared/types';
interface UseLazyFileContentOptions {
/** Имя команды (для fetchFileContent) */
teamName: string;
/** Имя участника (для fetchFileContent) */
memberName: string | undefined;
/** Список всех filePath в порядке рендеринга */
filePaths: string[];
/** Scroll container ref (ContinuousScrollView outer div) */
scrollContainerRef: RefObject<HTMLElement>;
/**
* Загруженный контент из store (для проверки: уже загружен?).
* Тип: Record<string, FileChangeWithContent> из changeReviewSlice.
*/
fileContents: Record<string, FileChangeWithContent>;
/** Флаги загрузки из store (для проверки: уже грузится?) */
fileContentsLoading: Record<string, boolean>;
/**
* Функция загрузки контента из store.
* Сигнатура точно как в changeReviewSlice.fetchFileContent:
* (teamName: string, memberName: string | undefined, filePath: string) => Promise<void>
*
* Внутри store уже есть guard от дубликатов (строка 264):
* if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
* Поэтому двойной вызов безопасен.
*/
fetchFileContent: (
teamName: string,
memberName: string | undefined,
filePath: string
) => Promise<void>;
/** Lazy loading включён (false = загрузить всё сразу, для fallback) */
enabled: boolean;
}
interface UseLazyFileContentReturn {
/**
* Регистрация file section для lazy-load наблюдения.
* Возвращает ref callback — передать в div section.
* Пример: <div ref={registerLazyRef(file.filePath)}>
*/
registerLazyRef: (filePath: string) => (element: HTMLElement | null) => void;
}
```
#### Полная реализация (описание)
```typescript
export function useLazyFileContent(
options: UseLazyFileContentOptions
): UseLazyFileContentReturn {
const {
teamName,
memberName,
filePaths,
scrollContainerRef,
fileContents,
fileContentsLoading,
fetchFileContent,
enabled,
} = options;
// === Throttle State ===
// Set: filePath текущих in-flight загрузок
const activeLoads = useRef(new Set<string>());
// Queue: filePath ожидающих загрузки (FIFO, но с приоритетом)
const pendingQueue = useRef<string[]>([]);
// Max параллельных загрузок
const MAX_CONCURRENT = 3;
// Observer ref
const observerRef = useRef<IntersectionObserver | null>(null);
// Element refs
const elementRefs = useRef(new Map<string, HTMLElement>());
// Stable refs для текущих значений (избежание stale closures)
const fileContentsRef = useRef(fileContents);
const fileContentsLoadingRef = useRef(fileContentsLoading);
useEffect(() => {
fileContentsRef.current = fileContents;
fileContentsLoadingRef.current = fileContentsLoading;
}, [fileContents, fileContentsLoading]);
// === Throttled Loader ===
/**
* Проверяет, нужно ли загружать filePath:
* - Не загружен (нет в fileContents)
* - Не грузится (нет в fileContentsLoading или false)
* - Не в activeLoads (не in-flight)
*
* ВАЖНО: проверяем fileContentsRef (ref), а не fileContents (prop) —
* чтобы callback IntersectionObserver видел актуальное состояние.
*/
const shouldLoad = useCallback((filePath: string): boolean => {
if (fileContentsRef.current[filePath]) return false;
if (fileContentsLoadingRef.current[filePath]) return false;
if (activeLoads.current.has(filePath)) return false;
return true;
}, []);
/**
* Запустить загрузку одного файла.
* Возвращает Promise (для chaining).
*/
const loadFile = useCallback(
async (filePath: string): Promise<void> => {
if (!shouldLoad(filePath)) return;
activeLoads.current.add(filePath);
try {
await fetchFileContent(teamName, memberName, filePath);
} finally {
activeLoads.current.delete(filePath);
// После завершения — попробовать следующий из очереди
processQueue();
}
},
[teamName, memberName, fetchFileContent, shouldLoad]
);
/**
* Обработать очередь: запустить загрузки пока slots < MAX_CONCURRENT.
*/
const processQueue = useCallback(() => {
while (
activeLoads.current.size < MAX_CONCURRENT &&
pendingQueue.current.length > 0
) {
const nextPath = pendingQueue.current.shift()!;
if (shouldLoad(nextPath)) {
void loadFile(nextPath);
}
// Если nextPath уже не нужен (загружен за время ожидания) — пропускаем, берём следующий
}
}, [shouldLoad, loadFile]);
/**
* Добавить filePath в очередь загрузки.
* Если есть свободные слоты — загрузить сразу.
* Если нет — добавить в pending queue.
*/
const enqueueLoad = useCallback(
(filePath: string) => {
if (!shouldLoad(filePath)) return;
if (activeLoads.current.size < MAX_CONCURRENT) {
// Есть свободный слот — загружаем сразу
void loadFile(filePath);
} else {
// Очередь заполнена — добавить в pending (если ещё нет)
if (!pendingQueue.current.includes(filePath)) {
pendingQueue.current.push(filePath);
}
}
},
[shouldLoad, loadFile]
);
// === Preload первых N файлов при mount ===
const PRELOAD_COUNT = 5;
useEffect(() => {
if (!enabled) return;
// Загрузить первые 5 файлов сразу
const toPreload = filePaths.slice(0, PRELOAD_COUNT);
for (const fp of toPreload) {
enqueueLoad(fp);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled]); // Намеренно: только при mount (enabled = true)
// === IntersectionObserver ===
useEffect(() => {
if (!enabled) return;
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const filePath = entry.target.getAttribute('data-lazy-file');
if (!filePath) continue;
enqueueLoad(filePath);
// После загрузки — перестать наблюдать (загружается один раз)
// Но мы не можем unobserve сразу (загрузка async) — unobserve когда контент загружен
// Проще: observer продолжает наблюдать, shouldLoad() вернёт false для загруженных
}
},
{
root: scrollContainerRef.current,
// 200% от viewport сверху и снизу — предзагрузка за 2 экрана
rootMargin: '200% 0px 200% 0px',
threshold: 0,
}
);
// Зарегистрировать все уже mounted элементы
for (const [, element] of elementRefs.current) {
observerRef.current.observe(element);
}
return () => {
observerRef.current?.disconnect();
observerRef.current = null;
};
}, [enabled, scrollContainerRef, enqueueLoad]);
// === Register ref callback ===
const registerLazyRef = useCallback((filePath: string) => {
return (element: HTMLElement | null) => {
const observer = observerRef.current;
// Cleanup previous
const prev = elementRefs.current.get(filePath);
if (prev && observer) {
observer.unobserve(prev);
}
elementRefs.current.delete(filePath);
// Register new
if (element) {
element.setAttribute('data-lazy-file', filePath);
elementRefs.current.set(filePath, element);
if (observer) {
observer.observe(element);
}
}
};
}, []);
return { registerLazyRef };
}
```
#### Ключевые аспекты
##### rootMargin "200% 0px 200% 0px"
IntersectionObserver `rootMargin` расширяет область наблюдения за пределы видимого viewport. `200%` означает 2x viewport-высоты сверху и снизу.
**Пример:** Viewport = 800px. rootMargin = 200% -> +1600px сверху и снизу. Файл начнёт загружаться когда его section находится в 1600px от видимой области.
**Почему 200%:** Smooth scroll на Chromium покрывает ~300-400px/сек. При viewport 800px пользователь доскроллит до следующей "зоны" за 2-4 секунды. Загрузка файла через IPC занимает ~50-200ms. 200% даёт достаточный запас для предзагрузки.
##### MAX_CONCURRENT = 3
**Почему 3, а не больше:**
- Electron main process обрабатывает IPC последовательно (single thread)
- Каждый `fetchFileContent` читает файл, парсит diff, возвращает контент
- 3 параллельных запроса = main process занят ~100% на файловых операциях
- Больше 3 = запросы встают в очередь IPC, но main process не ускоряется
- Бонус: оставляет "дышать" main process для других IPC (file watcher, config)
##### Preload первых 5 файлов
При mount (открытие диалога) загружаем первые 5 файлов немедленно (без ожидания IntersectionObserver).
**Почему 5:**
- Viewport обычно вмещает 2-3 file sections
- 5 = 2-3 видимых + 2 "за кадром" для плавного scroll
- Preload занимает ~200-500ms (3 параллельно + 2 в очереди)
**Timing:** Preload запускается одновременно с рендерингом DOM. К моменту первого paint IntersectionObserver ещё не успел сработать, но preload уже отправил запросы.
##### Приоритет в очереди
В текущей реализации очередь FIFO (first-in, first-out). Файлы добавляются в порядке пересечения rootMargin — ближайшие к viewport первыми.
**Возможное улучшение (если потребуется):** Реордеринг очереди при scroll event. Но FIFO достаточно для типичного use case (скролл сверху вниз).
##### Repeated observations
IntersectionObserver продолжает наблюдать все элементы, даже загруженные. Это ОК:
1. Callback вызовется для уже загруженного файла
2. `shouldLoad()` проверяет `fileContentsRef.current[filePath]` -> файл есть -> `return false`
3. `enqueueLoad` ничего не делает
Альтернатива `observer.unobserve()` после загрузки добавляет сложности (нужен callback из store, race conditions). Текущий подход проще и не имеет performance penalty (observer callback -- O(1) проверка).
---
## 3. Модификации существующих файлов
### 3.1. `changeReviewSlice.ts` -- НЕ требует изменений
**Решение: `prefetchFileContents` НЕ НУЖЕН.**
Изначально предполагался convenience-метод `prefetchFileContents` для batch-вызова. Однако при ревью обнаружено:
1. `useLazyFileContent` уже реализует preload первых 5 файлов через `enqueueLoad` в useEffect при mount -- это полностью покрывает потребность в batch preload.
2. `fetchFileContent` уже имеет внутренний guard от дубликатов (строка 262-264 в `changeReviewSlice.ts`):
```typescript
const state = get();
// Skip if already loaded or loading
if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
```
3. `useLazyFileContent.enqueueLoad` добавляет поверх store guard ещё `activeLoads` ref-трекинг для throttle -- т.е. тройная защита от дубликатов.
Добавление `prefetchFileContents` в store создаст дублирование с `useLazyFileContent` preload и не даст throttle (все запросы уйдут параллельно). **Оставляем store без изменений.**
---
### 3.2. `ContinuousScrollView.tsx`
#### Интеграция `useLazyFileContent`
**Новые props (добавляются к существующим props фазы 1):**
```typescript
interface ContinuousScrollViewProps {
// ... все существующие props из фазы 1 (см. phase-1 документ) ...
// === НОВЫЕ для фазы 2 ===
/** Имя команды */
teamName: string;
/** Имя участника */
memberName: string | undefined;
/**
* Функция загрузки контента из store.
* Сигнатура: (teamName: string, memberName: string | undefined, filePath: string) => Promise<void>
* Из changeReviewSlice.fetchFileContent
*/
fetchFileContent: (
teamName: string,
memberName: string | undefined,
filePath: string
) => Promise<void>;
}
```
**Интеграция в компоненте:**
```typescript
export const ContinuousScrollView = (props: ContinuousScrollViewProps) => {
const {
files,
fileContents,
fileContentsLoading,
teamName,
memberName,
fetchFileContent,
scrollContainerRef,
isProgrammaticScroll,
// ... rest из Phase 1
} = props;
const filePaths = useMemo(() => files.map((f) => f.filePath), [files]);
// Scroll-spy (фаза 1)
const { registerFileSectionRef } = useVisibleFileSection({
onVisibleFileChange: props.onVisibleFileChange,
scrollContainerRef,
isProgrammaticScroll,
});
// Lazy loading (фаза 2)
const { registerLazyRef } = useLazyFileContent({
teamName,
memberName,
filePaths,
scrollContainerRef,
fileContents,
fileContentsLoading,
fetchFileContent,
enabled: true,
});
// Комбинированный ref callback: регистрация в обоих observers
const combinedRef = useCallback(
(filePath: string) => {
const sectionRef = registerFileSectionRef(filePath);
const lazyRef = registerLazyRef(filePath);
return (element: HTMLElement | null) => {
sectionRef(element);
lazyRef(element);
};
},
[registerFileSectionRef, registerLazyRef]
);
// EditorView registration callback (Phase 1, без изменений)
const handleEditorViewReady = useCallback(
(filePath: string, view: EditorView | null) => {
if (view) {
props.editorViewMapRef.current.set(filePath, view);
} else {
props.editorViewMapRef.current.delete(filePath);
}
},
[props.editorViewMapRef]
);
return (
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
{files.map((file) => {
const filePath = file.filePath;
const content = fileContents[filePath] ?? null;
const isLoading = fileContentsLoading[filePath] ?? false;
const hasContent = content !== null;
return (
<div
key={filePath}
ref={combinedRef(filePath)} // <-- Комбинированный ref (Phase 1 scroll-spy + Phase 2 lazy)
className="border-b border-border"
>
<FileSectionHeader
file={file}
fileContent={content}
fileDecision={props.fileDecisions[filePath]}
hasEdits={filePath in props.editedContents}
applying={props.applying}
onDiscard={props.onDiscard}
onSave={props.onSave}
/>
{/* Контент ещё не загружен — placeholder */}
{!hasContent && isLoading && (
<FileSectionPlaceholder fileName={file.relativePath} />
)}
{/* Контент ещё не начал грузиться — тоже placeholder */}
{!hasContent && !isLoading && (
<FileSectionPlaceholder fileName={file.relativePath} />
)}
{/* Контент загружен — diff */}
{hasContent && (
<FileSectionDiff
file={file}
fileContent={content}
isLoading={false}
collapseUnchanged={props.collapseUnchanged}
onHunkAccepted={props.onHunkAccepted}
onHunkRejected={props.onHunkRejected}
onFullyViewed={props.onFullyViewed}
onContentChanged={props.onContentChanged}
onEditorViewReady={handleEditorViewReady}
discardCounter={props.discardCounter}
autoViewed={props.autoViewed}
isViewed={props.viewedSet.has(filePath)}
/>
)}
</div>
);
})}
{files.length === 0 && (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
No file changes detected
</div>
)}
</div>
);
};
```
**Отличие от Phase 1 ContinuousScrollView:** В Phase 1 `ref={registerFileSectionRef(filePath)}` использовался напрямую. В Phase 2 заменён на `ref={combinedRef(filePath)}`, который вызывает оба ref callback (scroll-spy + lazy). Phase 1 рендерил `FileSectionDiff` / `FileSectionPlaceholder` по условию `isLoading` (ternary). Phase 2 добавляет промежуточное состояние "не начал грузиться" (`!hasContent && !isLoading`).
#### Два placeholder состояния
| Состояние | `hasContent` | `isLoading` | Что показывать |
|-----------|-------------|-------------|----------------|
| Не начал грузиться | false | false | `FileSectionPlaceholder` |
| Грузится | false | true | `FileSectionPlaceholder` |
| Загружен | true | - | `FileSectionDiff` |
| Ошибка загрузки | false | false | `FileSectionPlaceholder` (потом retry) |
**Замечание:** "Не начал грузиться" -- файл ещё не попал в rootMargin IntersectionObserver. Placeholder показывается, но без индикатора загрузки. Визуально идентичен "грузится" -- это ОК, пользователь не различает.
**Ошибка загрузки:** `fetchFileContent` в store ставит `fileContentsLoading[fp] = false` (строка 279) и НЕ записывает в `fileContents` (строка 278 -- catch блок). Результат: `hasContent = false, isLoading = false` -- снова placeholder. IntersectionObserver при следующем пересечении вызовет `enqueueLoad` -- retry произойдёт автоматически (при re-scroll).
**Важно:** `shouldLoad()` в `useLazyFileContent` проверяет `fileContentsRef.current[filePath]` -- после ошибки этого ключа нет, поэтому повторный вызов пройдёт. Также `fileContentsLoadingRef.current[filePath]` будет `false` (store сбросил loading). Таким образом retry корректно сработает.
Если нужен явный retry без scroll: добавить кнопку "Retry" в placeholder. Но для фазы 2 автоматический retry через scroll достаточен.
#### `combinedRef` -- объединение двух ref callbacks
```typescript
const combinedRef = useCallback(
(filePath: string) => {
const sectionRef = registerFileSectionRef(filePath);
const lazyRef = registerLazyRef(filePath);
return (element: HTMLElement | null) => {
sectionRef(element);
lazyRef(element);
};
},
[registerFileSectionRef, registerLazyRef]
);
```
**Зачем:** Оба хука (`useVisibleFileSection`, `useLazyFileContent`) используют IntersectionObserver на одном и том же элементе (file section div). Вместо двух отдельных ref -- один объединённый.
**data attributes:** Каждый callback ставит свой атрибут:
- `registerFileSectionRef` -> `data-file-path`
- `registerLazyRef` -> `data-lazy-file`
Оба атрибута на одном элементе -- ОК, они используются разными observers.
---
### 3.3. `ChangeReviewDialog.tsx`
#### Убрать lazy-load useEffect
**Было** (строки 224-237 текущего файла):
```typescript
// Lazy-load file content when file selected
useEffect(() => {
if (!open || !selectedReviewFilePath) return;
if (fileContents[selectedReviewFilePath] || fileContentsLoading[selectedReviewFilePath]) return;
void fetchFileContent(teamName, memberName, selectedReviewFilePath);
}, [
open,
selectedReviewFilePath,
teamName,
memberName,
fileContents,
fileContentsLoading,
fetchFileContent,
]);
```
**Стало:** Удалить этот useEffect целиком. Загрузка контента теперь полностью делегирована `useLazyFileContent` внутри `ContinuousScrollView`:
- Preload первых 5 файлов при mount
- Остальные подгружаются по IntersectionObserver
#### Передать новые props в ContinuousScrollView
```tsx
<ContinuousScrollView
// ... props из фазы 1 ...
teamName={teamName}
memberName={memberName}
fetchFileContent={fetchFileContent}
/>
```
**Примечание:** `teamName` берётся из props `ChangeReviewDialogProps`, `memberName` оттуда же (optional prop). `fetchFileContent` берётся из `useStore()` (строка 77 текущего файла).
---
## 4. Throttle реализация: детали
### Структура данных
```
┌─────────────────┐
│ activeLoads │ Set<string> -- max 3 элемента
│ (in-flight) │
├─────────────────┤
│ pendingQueue │ string[] -- FIFO очередь
│ (waiting) │
└─────────────────┘
```
### Жизненный цикл загрузки
```
1. IntersectionObserver fires -> enqueueLoad(filePath)
2. shouldLoad() checks:
- fileContentsRef.current[fp]? -> skip (already loaded)
- fileContentsLoadingRef.current[fp]? -> skip (store knows about it)
- activeLoads.has(fp)? -> skip (our local tracking)
3. activeLoads.size < MAX_CONCURRENT?
-> YES: loadFile(fp) immediately
-> NO: pendingQueue.push(fp)
4. loadFile(fp):
- activeLoads.add(fp)
- await fetchFileContent(teamName, memberName, fp)
- activeLoads.delete(fp)
- processQueue() <-- проверить, есть ли ожидающие
5. processQueue():
- while (activeLoads.size < MAX_CONCURRENT && pendingQueue.length > 0)
- shift from queue, check shouldLoad, loadFile
```
### Диаграмма состояний
```
┌──────────┐
IO trigger ──> │ enqueue │
└────┬─────┘
┌────────v────────┐
│ slots available? │
└──┬─────────┬────┘
│ YES │ NO
┌──────v──┐ ┌──v───────┐
│ loadFile │ │ add to │
│ (async) │ │ pending │
└──────┬───┘ │ queue │
│ └──────────┘
┌──────v───┐ ^
│ complete │ │
└──────┬───┘ │
│ │
┌──────v────────┐ │
│ processQueue ├────┘
└───────────────┘
```
### Взаимодействие throttle с store guard
`fetchFileContent` в store (строки 261-282) имеет собственный guard:
```typescript
const state = get();
if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return;
```
`useLazyFileContent` добавляет `activeLoads` ref поверх. Зачем два уровня защиты:
1. **Store guard** предотвращает повторный IPC-вызов для загружаемого/загруженного файла -- но работает через `get()` (синхронный snapshot). Между двумя вызовами `fetchFileContent` в одном event loop tick `fileContentsLoading` ещё не обновлён (Zustand batch).
2. **`activeLoads` ref** покрывает этот micro-timing gap -- `activeLoads.add(fp)` происходит синхронно ДО await, а `shouldLoad()` проверяет ref мгновенно.
Таким образом:
- Store guard: macro-level (между renders)
- activeLoads ref: micro-level (между тиками в одном frame)
- Оба нужны для надёжности
### Приоритет загрузки
**Текущий подход:** FIFO. IntersectionObserver вызывает callbacks в порядке пересечения rootMargin. Для типичного скролла сверху вниз это означает: верхние файлы раньше нижних.
**Потенциальное улучшение (не для фазы 2):**
Если пользователь быстро скроллит вниз (skip middle files), можно реализовать priority queue:
```typescript
// Вместо string[] использовать priority queue:
interface PendingItem {
filePath: string;
priority: number; // расстояние от viewport center
}
// При каждом scroll event -- пересчитать priority для pending items
// Ближайшие к viewport -- выше приоритет
```
Но это оверинжиниринг для фазы 2. FIFO достаточно:
- IntersectionObserver с rootMargin 200% ловит файлы рано
- 3 параллельных загрузки покрывают типичную скорость скролла
- Даже при быстром скролле -- placeholder на 100-200ms, потом контент
### Refs для stale closure prevention
```typescript
const fileContentsRef = useRef(fileContents);
const fileContentsLoadingRef = useRef(fileContentsLoading);
useEffect(() => {
fileContentsRef.current = fileContents;
fileContentsLoadingRef.current = fileContentsLoading;
}, [fileContents, fileContentsLoading]);
```
**Зачем:** `shouldLoad()` замыкает `fileContentsRef` и `fileContentsLoadingRef`. Без ref-трюка callback IntersectionObserver "видит" stale `fileContents` из момента создания observer.
**Альтернатива:** Пересоздавать IntersectionObserver при каждом изменении `fileContents`. Но это = disconnect + observe all elements заново = bad performance.
### Edge case: диалог закрыт во время загрузки
`fetchFileContent` -- async. Если пользователь закроет диалог пока идёт загрузка:
1. `ContinuousScrollView` unmounts -> `useLazyFileContent` cleanup
2. IntersectionObserver disconnect
3. Но `fetchFileContent` всё ещё in-flight в store
4. Store обновит `fileContents` / `fileContentsLoading` -- ОК, store не зависит от компонента
5. `clearChangeReview()` вызывается в useEffect cleanup `ChangeReviewDialog` (строка 189) -- сбросит all state
**Вывод:** Нет утечек и race conditions. Store корректно очищается.
### Edge case: файл уже загружен при re-open
При повторном открытии того же review:
1. `clearChangeReview()` сбрасывает `fileContents = {}` (строка 160)
2. `fetchAgentChanges()` / `fetchTaskChanges()` загружает свежий changeSet
3. `useLazyFileContent` preload + observer начинают с нуля
4. Все файлы загружаются заново (свежие данные)
### Edge case: circular dependency loadFile <-> processQueue
`loadFile` вызывает `processQueue` в finally. `processQueue` вызывает `loadFile`. Потенциальный бесконечный цикл?
Нет -- `loadFile` начинается с `if (!shouldLoad(filePath)) return;`, а `activeLoads.add(fp)` происходит синхронно. `processQueue` берёт из очереди (shift), проверяет `shouldLoad`, и вызывает `loadFile` через `void` (fire-and-forget). Каждый `loadFile` -- это новый async task, не рекурсия в call stack. Queue конечна (max = количество файлов). Цикла нет.
---
## 5. Консистентность с Phase 3
Phase 3 (Navigation) зависит от Phase 2 в следующих аспектах:
1. **EditorView Map** -- Phase 2 использует `editorViewMapRef` из Phase 1. Phase 3 использует тот же Map через `ContinuousNavigationOptions.editorViewRefs`. Важно: Phase 3 `editorViewRefs` это `Map<string, EditorView>` (value из `.current`), а Phase 2 работает с `MutableRefObject<Map>`. Нет конфликта -- Phase 3 читает из `.current` напрямую.
2. **Lazy loading + cross-file navigation** -- когда Phase 3 `goToNextFile()` делает `scrollToFile(nextFilePath)`, файл может быть ещё не загружен. IntersectionObserver с rootMargin 200% должен сработать до того как scroll доедет до файла. Если файл далеко -- placeholder покажется на ~100-200ms, потом контент подгрузится. Это приемлемый UX.
3. **activeFilePath** -- Phase 2 НЕ управляет `activeFilePath`. Scroll-spy из Phase 1 (`useVisibleFileSection`) определяет activeFilePath. Phase 2 только загружает контент. Phase 3 использует activeFilePath для определения "текущего" файла в навигации.
---
## 6. Проверка
### Функциональная проверка
- [ ] Открыть review с 10+ файлами
- [ ] Первые 5 файлов показывают контент в первые ~500ms
- [ ] Файлы 6-10 показывают placeholder, потом контент при подскролле
- [ ] Scroll вниз -- файлы подгружаются бесшовно (placeholder -> diff)
- [ ] Scroll быстро вниз -- плейсхолдеры видны на ~200ms, потом контент
- [ ] Scroll обратно вверх -- уже загруженные файлы показывают diff мгновенно
- [ ] Кликнуть на файл 15 в tree -> smooth scroll + контент загружается
### Throttle проверка
- [ ] Открыть DevTools Network tab (или console log)
- [ ] Убедиться: максимум 3 одновременных IPC-вызова `getFileContent`
- [ ] Остальные ждут в очереди и выполняются последовательно по 3
### Edge cases
- [ ] 0 файлов -- нет ошибок в console
- [ ] 1 файл -- загружается мгновенно (preload)
- [ ] Файл с ошибкой загрузки (main process throw) -- placeholder остаётся, scroll retry работает
- [ ] Закрыть диалог во время загрузки -- нет ошибок, store очищен
- [ ] Переоткрыть диалог -- все файлы загружаются заново
### Performance
- [ ] 30 файлов -- UI не зависает при открытии
- [ ] Main process responsive (file watcher работает) во время загрузки 30 файлов
- [ ] Memory: placeholder -> diff transition не утекает (EditorView create/destroy)
- [ ] Scroll FPS > 30 при 20+ загруженных CodeMirror editors