add
This commit is contained in:
parent
6196b4b0e2
commit
3e735ab0f6
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);
|
||||
}, [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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user