diff --git a/docs/AB_TEST_MULTIPLE_CALLS_FIX.md b/docs/AB_TEST_MULTIPLE_CALLS_FIX.md new file mode 100644 index 0000000..086daec --- /dev/null +++ b/docs/AB_TEST_MULTIPLE_CALLS_FIX.md @@ -0,0 +1,310 @@ +# 🐛 Исправление: Множественные вызовы AB Test Impression + +## 🔴 Проблема + +При запуске воронки в консоли браузера появлялись **множественные логи** для одного AB-теста: + +``` +[GA] 🧪 AB Test Impression SKIPPED +[GA] 🧪 AB Test Impression Event Sent +[GA] 🧪 AB Test Impression SKIPPED (Already Sent) +[GA] 🧪 AB Test Impression SKIPPED (Already Sent) +[GA] 🧪 AB Test Impression SKIPPED (Already Sent) +``` + +**Ожидаемое поведение:** Только **один вызов** `sendUnleashImpression()` для каждого экрана. + +**Фактическое поведение:** **5 вызовов** `sendUnleashImpression()` для одного экрана. + +--- + +## 🔍 Причина + +### Исходный код + +```typescript +// FunnelRuntime.tsx (ДО исправления) +useEffect(() => { + currentScreenFlags.forEach((flag) => { + const variant = activeVariants[flag]; + sendUnleashImpression(flag, variant); + }); +}, [currentScreenFlags, activeVariants]); // ← Проблема! +``` + +### Почему это происходило + +`activeVariants` - это **объект**, который: +1. Приходит из React Context (`UnleashContext`) +2. Обновляется при загрузке вариантов флагов +3. Может иметь **новую ссылку** при каждом рендере компонента + +**Результат:** `useEffect` срабатывал при каждом изменении ссылки на объект, даже если содержимое оставалось прежним. + +### Последовательность событий + +``` +T+0ms FunnelRuntime монтируется + activeVariants = {} (пустой) + ↓ + useEffect срабатывает #1 + variant = undefined + → SKIPPED (invalid variant) + +T+100ms activeVariants обновляется + activeVariants = { "soulmate-onboarding-image": "v1" } + ↓ + useEffect срабатывает #2 + variant = "v1" + → Event Sent ✅ + +T+110ms activeVariants получает новую ссылку (тот же контент) + activeVariants = { "soulmate-onboarding-image": "v1" } (новая ссылка) + ↓ + useEffect срабатывает #3 + → SKIPPED (Already Sent) + +T+120ms activeVariants снова обновляется + ↓ + useEffect срабатывает #4 + → SKIPPED (Already Sent) + +T+130ms activeVariants снова обновляется + ↓ + useEffect срабатывает #5 + → SKIPPED (Already Sent) +``` + +--- + +## ✅ Решение + +### 1. Создание стабильного ключа + +```typescript +// Создаем мемоизированную строку вариантов +const currentFlagsKey = useMemo(() => { + if (currentScreenFlags.length === 0) { + return ""; + } + + // Строка вида "flag1:variant1,flag2:variant2" + return currentScreenFlags + .map(flag => `${flag}:${activeVariants[flag] || "loading"}`) + .sort() + .join(","); +}, [currentScreenFlags, activeVariants]); +``` + +**Пример значений:** +- `""` - нет флагов +- `"soulmate-onboarding-image:loading"` - флаг загружается +- `"soulmate-onboarding-image:v1"` - флаг загружен + +### 2. Использование стабильного ключа в useEffect + +```typescript +useEffect(() => { + if (currentScreenFlags.length === 0) { + return; + } + + // Проверяем что все флаги загружены + const allFlagsLoaded = currentScreenFlags.every(flag => { + const variant = activeVariants[flag]; + return variant !== undefined && variant !== "loading"; + }); + + if (!allFlagsLoaded) { + return; // Ждем загрузки + } + + // Отправляем impression + currentScreenFlags.forEach((flag) => { + const variant = activeVariants[flag]; + sendUnleashImpression(flag, variant); + }); + +}, [currentScreen.id, currentFlagsKey]); // ← Стабильная зависимость! +``` + +### 3. Как это работает + +**Старая логика:** +- useEffect зависит от `activeVariants` (объект) +- При каждом изменении **ссылки** на объект → useEffect срабатывает +- **Результат:** 5 вызовов + +**Новая логика:** +- useEffect зависит от `currentFlagsKey` (строка) +- При изменении **значений** вариантов → строка меняется → useEffect срабатывает +- **Результат:** 1 вызов + +--- + +## 📊 Ожидаемое поведение после исправления + +### В консоли браузера + +``` +▼ [GA] 🧪 AB Test Impression Event Sent + 🕐 Timestamp: 2025-10-23T19:15:42.456Z + 🏷️ Flag: soulmate-onboarding-image + 🎯 Variant: v1 + 📦 Event Name: experiment_impression + 📦 Payload: { ... } + ✅ Status: Successfully sent to Google Analytics +``` + +**Только ОДИН лог** для каждого экрана с AB-тестом! + +### Timeline событий + +``` +T+0ms FunnelRuntime монтируется + currentFlagsKey = "" + ↓ + useEffect: нет флагов, return + +T+100ms Флаги загружаются + currentFlagsKey = "soulmate-onboarding-image:loading" + ↓ + useEffect: флаги не загружены, return + +T+200ms Флаги загружены + currentFlagsKey = "soulmate-onboarding-image:v1" + ↓ + useEffect срабатывает (ОДИН РАЗ) + → Event Sent ✅ + +T+300ms activeVariants получает новую ссылку (тот же контент) + currentFlagsKey = "soulmate-onboarding-image:v1" (не изменился!) + ↓ + useEffect НЕ срабатывает (ключ не изменился) +``` + +**Результат:** Только **1 вызов** `sendUnleashImpression()` ✅ + +--- + +## 🧪 Как проверить + +### 1. Очистить кеш браузера + +```javascript +// В консоли браузера: +sessionStorage.clear(); +``` + +### 2. Открыть воронку в Incognito + +``` +Chrome: Ctrl+Shift+N (Windows) / Cmd+Shift+N (Mac) +Firefox: Ctrl+Shift+P (Windows) / Cmd+Shift+P (Mac) +``` + +### 3. Открыть первый экран + +``` +http://localhost:3000/soulmate/onboarding +``` + +### 4. Проверить консоль + +**Ожидаемый результат:** + +``` +[GA] 📊 Page View Event Sent +[YM] 📊 Page View Event Sent +[GA] 🧪 AB Test Impression Event Sent ← ТОЛЬКО ОДИН РАЗ! +``` + +**НЕ должно быть:** +- ❌ Множественных "SKIPPED" логов +- ❌ Множественных "Event Sent" логов + +--- + +## 🔒 Дополнительная защита + +Даже при множественных вызовах `sendUnleashImpression()`, **события НЕ отправляются повторно** благодаря защите через `sessionStorage`: + +```typescript +// В sendImpression.ts +const storageKey = `unleash_impression_${flag}_${variant}`; +const alreadySent = sessionStorage.getItem(storageKey); + +if (alreadySent) { + // Уже отправляли - пропускаем + return; +} + +// Отправка... +sessionStorage.setItem(storageKey, "true"); +``` + +**Два уровня защиты:** +1. ✅ **useEffect оптимизация** - предотвращает лишние вызовы функции +2. ✅ **sessionStorage проверка** - предотвращает дубликаты в GA (на случай багов) + +--- + +## 📝 Сводка изменений + +### Файл: `src/components/funnel/FunnelRuntime.tsx` + +**Добавлено:** +```typescript +// Создание стабильного ключа +const currentFlagsKey = useMemo(() => { + if (currentScreenFlags.length === 0) return ""; + + return currentScreenFlags + .map(flag => `${flag}:${activeVariants[flag] || "loading"}`) + .sort() + .join(","); +}, [currentScreenFlags, activeVariants]); +``` + +**Изменено:** +```typescript +// Использование стабильного ключа в dependencies +useEffect(() => { + // ... логика отправки +}, [currentScreen.id, currentFlagsKey]); // ← Было: activeVariants +``` + +**Добавлено:** +```typescript +// Проверка что все флаги загружены +const allFlagsLoaded = currentScreenFlags.every(flag => { + const variant = activeVariants[flag]; + return variant !== undefined && variant !== "loading"; +}); + +if (!allFlagsLoaded) { + return; // Ждем загрузки +} +``` + +--- + +## ✅ Итог + +**Проблема:** 5 вызовов `sendUnleashImpression()` для одного экрана + +**Причина:** `activeVariants` объект менял ссылку → useEffect срабатывал многократно + +**Решение:** Создали стабильный ключ `currentFlagsKey` → useEffect срабатывает только при изменении значений + +**Результат:** ✅ Только 1 вызов для каждого экрана с AB-тестом + +**Дополнительно:** ✅ sessionStorage защита на случай багов + +--- + +## 🔗 Связанные документы + +- `ENHANCED_LOGGING_GUIDE.md` - Как читать логи в консоли +- `GA_AB_TEST_ANALYSIS.md` - Техническая документация системы +- `SOULMATE_AB_TESTS_TIMELINE.md` - Timeline AB-тестов diff --git a/src/components/funnel/FunnelRuntime.tsx b/src/components/funnel/FunnelRuntime.tsx index 91bc8f1..40eac92 100644 --- a/src/components/funnel/FunnelRuntime.tsx +++ b/src/components/funnel/FunnelRuntime.tsx @@ -133,21 +133,44 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { registerScreen(currentScreen.id); }, [currentScreen.id, registerScreen]); + // Создаем стабильный ключ для текущих вариантов флагов + const currentFlagsKey = useMemo(() => { + if (currentScreenFlags.length === 0) { + return ""; + } + + // Создаем строку вида "flag1:variant1,flag2:variant2" + return currentScreenFlags + .map(flag => `${flag}:${activeVariants[flag] || "loading"}`) + .sort() + .join(","); + }, [currentScreenFlags, activeVariants]); + // Отправляем impression события в GA когда пользователь видит экран с AB тестами useEffect(() => { if (currentScreenFlags.length === 0) { return; // Нет AB тестов на этом экране } + // Проверяем что все флаги загружены + const allFlagsLoaded = currentScreenFlags.every(flag => { + const variant = activeVariants[flag]; + return variant !== undefined && variant !== "loading"; + }); + + if (!allFlagsLoaded) { + // Ждем пока все флаги загрузятся + return; + } + // Отправляем impression для каждого флага на этом экране currentScreenFlags.forEach((flag) => { - // Получаем вариант для флага из контекста (он уже загружен через FunnelUnleashWrapper) const variant = activeVariants[flag]; - - // Отправляем событие (внутри есть защита от дубликатов через sessionStorage) sendUnleashImpression(flag, variant); }); - }, [currentScreenFlags, activeVariants]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentScreen.id, currentFlagsKey]); const historyWithCurrent = useMemo(() => { if (history.length === 0) {