Skip to content

Commit 31259dc

Browse files
author
Juarez Mota
committed
Add view deduplication to prevent duplicate Gandalf sign-in gate tracking
The mandatory popup now records its VIEW event exactly once per treatment identity (treatmentId + treatmentTrackingId) by tracking the last recorded view in a ref. Visibility changes, equivalent treatment objects supplied as new props, and StrictMode effect replays no longer duplicate the view count, Ophan event, or Auxia interaction. A new treatment identity or component remount still records a fresh view as intended.
1 parent 2a9362a commit 31259dc

2 files changed

Lines changed: 203 additions & 1 deletion

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { storage } from '@guardian/libs';
2+
import { cleanup, render, screen as testScreen } from '@testing-library/react';
3+
import { StrictMode } from 'react';
4+
import { useIsInView } from '../lib/useIsInView';
5+
import { submitComponentEventTracking } from './SignInGate/componentEventTracking';
6+
import type { AuxiaAPIResponseDataUserTreatment } from './SignInGate/types';
7+
import { SignInGateSelector } from './SignInGateSelector.island';
8+
9+
jest.mock('../lib/useIsInView', () => ({ useIsInView: jest.fn() }));
10+
jest.mock('../lib/usePageViewId', () => ({
11+
usePageViewId: () => 'test-page-view',
12+
}));
13+
jest.mock('./ConfigContext', () => ({
14+
useConfig: () => ({ renderingTarget: 'Web' }),
15+
}));
16+
jest.mock('./SignInGate/componentEventTracking', () => ({
17+
submitComponentEventTracking: jest.fn().mockResolvedValue(undefined),
18+
}));
19+
jest.mock('./SignInGate/gateDesigns/SignInGateAuxiaV1', () => ({
20+
SignInGateAuxiaV1: () => <div data-testid="v1-gate" />,
21+
}));
22+
jest.mock('./SignInGate/gateDesigns/SignInGateAuxiaV2', () => ({
23+
SignInGateAuxiaV2: () => <div data-testid="v2-gate" />,
24+
}));
25+
26+
const makeTreatment = (
27+
overrides: Partial<AuxiaAPIResponseDataUserTreatment> = {},
28+
): AuxiaAPIResponseDataUserTreatment => ({
29+
treatmentId: 'test-treatment',
30+
treatmentTrackingId: 'test-tracking',
31+
treatmentType: 'NONDISMISSIBLE_SIGN_IN_GATE_POPUP',
32+
treatmentContent: '{}',
33+
rank: '1',
34+
contentLanguageCode: 'en',
35+
surface: 'test-surface',
36+
...overrides,
37+
});
38+
39+
const makeProps = (
40+
userTreatment = makeTreatment(),
41+
gandalfSignInGate = true,
42+
) => ({
43+
isPaidContent: false,
44+
isPreview: false,
45+
pageId: 'crosswords/quick/16914',
46+
contributionsServiceUrl: 'https://contributions.example.com',
47+
auxiaGateDisplayData: {
48+
browserId: undefined,
49+
gandalfCountryCode: 'NZ',
50+
auxiaData: {
51+
responseId: 'test-response',
52+
gandalfSignInGate,
53+
userTreatment,
54+
},
55+
},
56+
});
57+
58+
const mockSetNode = jest.fn();
59+
const mockUseIsInView = jest.mocked(useIsInView);
60+
const mockTrack = jest.mocked(submitComponentEventTracking);
61+
const mockModalOpen = jest.fn();
62+
const mockFetch = jest.fn<ReturnType<typeof fetch>, Parameters<typeof fetch>>();
63+
const originalFetch = global.fetch;
64+
65+
const expectViews = (count: number) => {
66+
expect(mockTrack).toHaveBeenCalledTimes(count);
67+
expect(storage.local.getRaw('gate_display_count')).toBe(String(count));
68+
expect(mockModalOpen).toHaveBeenCalledTimes(count);
69+
};
70+
71+
describe('SignInGateSelector view tracking', () => {
72+
beforeEach(() => {
73+
jest.clearAllMocks();
74+
global.fetch = mockFetch;
75+
mockFetch.mockResolvedValue({
76+
json: () => Promise.resolve({}),
77+
} as Response);
78+
storage.local.setRaw('gate_display_count', '0');
79+
mockUseIsInView.mockReturnValue([null, mockSetNode]);
80+
Object.defineProperty(document.documentElement, 'scrollHeight', {
81+
configurable: true,
82+
value: 3000,
83+
});
84+
document.addEventListener('modal:open', mockModalOpen);
85+
});
86+
87+
afterEach(() => {
88+
cleanup();
89+
document.removeEventListener('modal:open', mockModalOpen);
90+
jest.restoreAllMocks();
91+
global.fetch = originalFetch;
92+
});
93+
94+
it('shows a mandatory popup without scrolling and does not recount when the placeholder becomes visible', () => {
95+
const props = makeProps();
96+
const { rerender } = render(<SignInGateSelector {...props} />);
97+
98+
expect(testScreen.getByTestId('v2-gate')).toBeInTheDocument();
99+
expectViews(1);
100+
expect(mockTrack).toHaveBeenCalledWith(
101+
expect.objectContaining({ action: 'VIEW' }),
102+
'Web',
103+
);
104+
105+
mockUseIsInView.mockReturnValue([true, mockSetNode]);
106+
rerender(<SignInGateSelector {...props} />);
107+
expect(testScreen.getByTestId('v2-gate')).toBeInTheDocument();
108+
expectViews(1);
109+
expect(mockFetch).not.toHaveBeenCalled();
110+
});
111+
112+
it('does not recount an equivalent treatment supplied as a new object', () => {
113+
const { rerender } = render(<SignInGateSelector {...makeProps()} />);
114+
rerender(<SignInGateSelector {...makeProps()} />);
115+
expectViews(1);
116+
});
117+
118+
it.each([
119+
{ treatmentId: 'another-treatment' },
120+
{ treatmentTrackingId: 'another-tracking' },
121+
])('records a new treatment identity: %j', (identity) => {
122+
const { rerender } = render(<SignInGateSelector {...makeProps()} />);
123+
rerender(
124+
<SignInGateSelector {...makeProps(makeTreatment(identity))} />,
125+
);
126+
expectViews(2);
127+
});
128+
129+
it('records another view when the gate is unmounted and displayed again', () => {
130+
const { unmount } = render(<SignInGateSelector {...makeProps()} />);
131+
unmount();
132+
render(<SignInGateSelector {...makeProps()} />);
133+
expectViews(2);
134+
});
135+
136+
it('does not duplicate a view when StrictMode replays effects', () => {
137+
render(
138+
<StrictMode>
139+
<SignInGateSelector {...makeProps()} />
140+
</StrictMode>,
141+
);
142+
expectViews(1);
143+
});
144+
145+
it('keeps a dismissible popup deferred until visibility and records it only once', () => {
146+
const treatment = makeTreatment({
147+
treatmentType: 'DISMISSABLE_SIGN_IN_GATE_POPUP',
148+
});
149+
const { rerender } = render(
150+
<SignInGateSelector {...makeProps(treatment)} />,
151+
);
152+
expect(testScreen.queryByTestId('v2-gate')).not.toBeInTheDocument();
153+
expectViews(0);
154+
155+
mockUseIsInView.mockReturnValue([true, mockSetNode]);
156+
rerender(<SignInGateSelector {...makeProps(treatment)} />);
157+
expect(testScreen.getByTestId('v2-gate')).toBeInTheDocument();
158+
expectViews(1);
159+
rerender(<SignInGateSelector {...makeProps({ ...treatment })} />);
160+
expectViews(1);
161+
});
162+
163+
it('preserves inline gate rendering and visibility-based tracking', () => {
164+
const props = makeProps(
165+
makeTreatment({ treatmentType: 'DISMISSABLE_SIGN_IN_GATE' }),
166+
);
167+
const { rerender } = render(<SignInGateSelector {...props} />);
168+
expect(testScreen.getByTestId('v1-gate')).toBeInTheDocument();
169+
expectViews(0);
170+
mockUseIsInView.mockReturnValue([true, mockSetNode]);
171+
rerender(<SignInGateSelector {...props} />);
172+
expectViews(1);
173+
});
174+
175+
it('records the Auxia VIEWED interaction only once for non-Gandalf treatments', () => {
176+
const props = makeProps(makeTreatment(), false);
177+
const { rerender } = render(<SignInGateSelector {...props} />);
178+
mockUseIsInView.mockReturnValue([true, mockSetNode]);
179+
rerender(<SignInGateSelector {...props} />);
180+
expectViews(1);
181+
expect(mockFetch).toHaveBeenCalledTimes(1);
182+
expect(mockFetch).toHaveBeenCalledWith(
183+
'https://contributions.example.com/auxia/log-treatment-interaction',
184+
expect.objectContaining({ method: 'POST' }),
185+
);
186+
expect(mockFetch.mock.calls[0]?.[1]?.body).toContain(
187+
'"interactionType":"VIEWED"',
188+
);
189+
});
190+
});

dotcom-rendering/src/components/SignInGateSelector.island.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getCookie, isUndefined, storage } from '@guardian/libs';
2-
import { useEffect, useState } from 'react';
2+
import { useEffect, useRef, useState } from 'react';
33
import { constructQuery } from '../lib/querystring';
44
import { useIsInView } from '../lib/useIsInView';
55
import { useOnce } from '../lib/useOnce';
@@ -441,6 +441,7 @@ const ShowSignInGateAuxia = ({
441441
// element, which on long pages sits far below the viewport.
442442
const isMandatoryPopup =
443443
userTreatment.treatmentType === 'NONDISMISSIBLE_SIGN_IN_GATE_POPUP';
444+
const lastRecordedView = useRef<string>();
444445

445446
useEffect(() => {
446447
const signInGate = document.getElementById('sign-in-gate');
@@ -454,6 +455,17 @@ const ShowSignInGateAuxia = ({
454455
// The mandatory popup is shown on mount (see shouldShowV2Gate), so
455456
// its view is recorded immediately instead of waiting for scroll.
456457
if (hasBeenSeen === true || isMandatoryPopup) {
458+
const viewIdentity = JSON.stringify([
459+
treatmentId,
460+
userTreatment.treatmentTrackingId,
461+
]);
462+
// Visibility changes and equivalent treatment objects must not
463+
// record the same display again. A new treatment or mount can.
464+
if (lastRecordedView.current === viewIdentity) {
465+
return;
466+
}
467+
lastRecordedView.current = viewIdentity;
468+
457469
// Tell Auxia
458470
// Gandalf: never contact Auxia for Guardian-managed treatments.
459471
if (!isGandalf) {

0 commit comments

Comments
 (0)