commit
eb82b5e153
310
docs/AB_TEST_MULTIPLE_CALLS_FIX.md
Normal file
310
docs/AB_TEST_MULTIPLE_CALLS_FIX.md
Normal 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-тестов
|
||||||
@ -133,21 +133,44 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
|||||||
registerScreen(currentScreen.id);
|
registerScreen(currentScreen.id);
|
||||||
}, [currentScreen.id, registerScreen]);
|
}, [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 тестами
|
// Отправляем impression события в GA когда пользователь видит экран с AB тестами
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentScreenFlags.length === 0) {
|
if (currentScreenFlags.length === 0) {
|
||||||
return; // Нет AB тестов на этом экране
|
return; // Нет AB тестов на этом экране
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем что все флаги загружены
|
||||||
|
const allFlagsLoaded = currentScreenFlags.every(flag => {
|
||||||
|
const variant = activeVariants[flag];
|
||||||
|
return variant !== undefined && variant !== "loading";
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allFlagsLoaded) {
|
||||||
|
// Ждем пока все флаги загрузятся
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Отправляем impression для каждого флага на этом экране
|
// Отправляем impression для каждого флага на этом экране
|
||||||
currentScreenFlags.forEach((flag) => {
|
currentScreenFlags.forEach((flag) => {
|
||||||
// Получаем вариант для флага из контекста (он уже загружен через FunnelUnleashWrapper)
|
|
||||||
const variant = activeVariants[flag];
|
const variant = activeVariants[flag];
|
||||||
|
|
||||||
// Отправляем событие (внутри есть защита от дубликатов через sessionStorage)
|
|
||||||
sendUnleashImpression(flag, variant);
|
sendUnleashImpression(flag, variant);
|
||||||
});
|
});
|
||||||
}, [currentScreenFlags, activeVariants]);
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentScreen.id, currentFlagsKey]);
|
||||||
|
|
||||||
const historyWithCurrent = useMemo(() => {
|
const historyWithCurrent = useMemo(() => {
|
||||||
if (history.length === 0) {
|
if (history.length === 0) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user