This commit is contained in:
dev.daminik00 2025-10-24 00:05:07 +02:00
parent 6196b4b0e2
commit 3e735ab0f6
2 changed files with 337 additions and 4 deletions

View File

@ -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-тестов

View File

@ -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) {