Skip to content

Commit

Permalink
fix(insights): prevent duplicate events
Browse files Browse the repository at this point in the history
in sendEvent, we mark internal events (click and conversion), and prevent them being sent if there was a custom click right before.

Note that this doesn't yet work with bindEvent, as it's higher in the bubbling tree
  • Loading branch information
Haroenv committed Mar 3, 2023
1 parent bb4c356 commit 2b84e99
Show file tree
Hide file tree
Showing 9 changed files with 111 additions and 81 deletions.
2 changes: 1 addition & 1 deletion packages/instantsearch.js/src/components/Hits/Hits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const Hits = ({
rootProps={{
className: cssClasses.item,
onClick: () => {
sendEvent('click', hit, 'Hit Clicked');
sendEvent('click:internal', hit, 'Hit Clicked');
},
}}
key={hit.objectID}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('Hits', () => {

expect(props.sendEvent).toHaveBeenCalledTimes(1);
expect(props.sendEvent).toHaveBeenLastCalledWith(
'click',
'click:internal',
props.hits[0],
'Hit Clicked'
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ const InfiniteHits = ({
rootProps={{
className: cssClasses.item,
onClick: () => {
sendEvent('click', hit, 'Hit Clicked');
sendEvent('click:internal', hit, 'Hit Clicked');
},
}}
key={hit.objectID}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('InfiniteHits', () => {

expect(props.sendEvent).toHaveBeenCalledTimes(1);
expect(props.sendEvent).toHaveBeenLastCalledWith(
'click',
'click:internal',
props.hits[0],
'Hit Clicked'
);
Expand Down
146 changes: 88 additions & 58 deletions packages/instantsearch.js/src/lib/utils/createSendEventForHits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,17 @@ const buildPayloads = ({
methodName: 'sendEvent' | 'bindEvent';
args: any[];
instantSearchInstance: InstantSearch;
}): InsightsEvent[] => {
}): {
payloads: InsightsEvent[];
eventModifier?: string;
preventNextInternalEvent?: boolean;
} => {
// when there's only one argument, that means it's custom
if (args.length === 1 && typeof args[0] === 'object') {
return [args[0]];
return { payloads: [args[0]] };
}
const eventType: string = args[0];
const [eventType, eventModifier]: [string, string] = args[0].split(':');

const hits: Hit | Hit[] | EscapedHits = args[1];
const eventName: string | undefined = args[2];
if (!hits) {
Expand All @@ -57,7 +62,7 @@ const buildPayloads = ({
`
);
} else {
return [];
return { payloads: [] };
}
}
if ((eventType === 'click' || eventType === 'conversion') && !eventName) {
Expand All @@ -70,15 +75,15 @@ const buildPayloads = ({
`
);
} else {
return [];
return { payloads: [] };
}
}
const hitsArray: Hit[] = Array.isArray(hits)
? removeEscapedFromHits(hits)
: [hits];

if (hitsArray.length === 0) {
return [];
return { payloads: [] };
}
const queryID = hitsArray[0].__queryID;
const hitsChunks = chunk(hitsArray);
Expand All @@ -91,58 +96,69 @@ const buildPayloads = ({

if (eventType === 'view') {
if (instantSearchInstance.status !== 'idle') {
return [];
return { payloads: [] };
}
return hitsChunks.map((batch, i) => {
return {
insightsMethod: 'viewedObjectIDs',
widgetType,
eventType,
payload: {
eventName: eventName || 'Hits Viewed',
index,
objectIDs: objectIDsByChunk[i],
},
hits: batch,
};
});
return {
payloads: hitsChunks.map((batch, i) => {
return {
insightsMethod: 'viewedObjectIDs',
widgetType,
eventType,
payload: {
eventName: eventName || 'Hits Viewed',
index,
objectIDs: objectIDsByChunk[i],
},
hits: batch,
};
}),
eventModifier,
};
} else if (eventType === 'click') {
return hitsChunks.map((batch, i) => {
return {
insightsMethod: 'clickedObjectIDsAfterSearch',
widgetType,
eventType,
payload: {
eventName,
index,
queryID,
objectIDs: objectIDsByChunk[i],
positions: positionsByChunk[i],
},
hits: batch,
};
});
return {
payloads: hitsChunks.map((batch, i) => {
return {
insightsMethod: 'clickedObjectIDsAfterSearch',
widgetType,
eventType,
payload: {
eventName,
index,
queryID,
objectIDs: objectIDsByChunk[i],
positions: positionsByChunk[i],
},
hits: batch,
};
}),
eventModifier,
preventNextInternalEvent: eventModifier !== 'internal',
};
} else if (eventType === 'conversion') {
return hitsChunks.map((batch, i) => {
return {
insightsMethod: 'convertedObjectIDsAfterSearch',
widgetType,
eventType,
payload: {
eventName,
index,
queryID,
objectIDs: objectIDsByChunk[i],
},
hits: batch,
};
});
return {
payloads: hitsChunks.map((batch, i) => {
return {
insightsMethod: 'convertedObjectIDsAfterSearch',
widgetType,
eventType,
payload: {
eventName,
index,
queryID,
objectIDs: objectIDsByChunk[i],
},
hits: batch,
};
}),
eventModifier,
preventNextInternalEvent: eventModifier !== 'internal',
};
} else if (__DEV__) {
throw new Error(`eventType("${eventType}") is not supported.
If you want to send a custom payload, you can pass one object: ${methodName}(customPayload);
`);
} else {
return [];
return { payloads: [] };
}
};

Expand All @@ -160,14 +176,28 @@ export function createSendEventForHits({
index: string;
widgetType: string;
}): SendEventForHits {
let shouldSendInternalEvent = true;

const sendEventForHits: SendEventForHits = (...args: any[]) => {
const payloads = buildPayloads({
widgetType,
index,
methodName: 'sendEvent',
args,
instantSearchInstance,
});
const { payloads, eventModifier, preventNextInternalEvent } = buildPayloads(
{
widgetType,
index,
methodName: 'sendEvent',
args,
instantSearchInstance,
}
);

if (eventModifier === 'internal' && !shouldSendInternalEvent) {
// don't send internal events, but still send the next one
shouldSendInternalEvent = true;
return;
} else if (eventModifier === 'internal') {
shouldSendInternalEvent = true;
} else if (preventNextInternalEvent) {
shouldSendInternalEvent = false;
}

payloads.forEach((payload) =>
instantSearchInstance.sendEventToInsights(payload)
Expand All @@ -186,7 +216,7 @@ export function createBindEventForHits({
instantSearchInstance: InstantSearch;
}): BindEventForHits {
const bindEventForHits: BindEventForHits = (...args: any[]) => {
const payloads = buildPayloads({
const { payloads } = buildPayloads({
widgetType,
index,
methodName: 'bindEvent',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ describe('hits', () => {
);
});

test('sends a default `click` event when clicking on a hit', async () => {
test.only('sends a default `click` event when clicking on a hit', async () => {
const { search } = createInstantSearch();
const { insights, onEvent } = createInsightsMiddlewareWithOnEvent();

Expand Down Expand Up @@ -190,8 +190,8 @@ describe('hits', () => {

fireEvent.click(getByText(container, 'title 1'));

// The default `click` one + the custom one
expect(onEvent).toHaveBeenCalledTimes(2);
// The custom one only
expect(onEvent).toHaveBeenCalledTimes(1);
expect(onEvent.mock.calls[0][0]).toEqual({
eventType: 'click',
hits: [
Expand Down Expand Up @@ -242,8 +242,8 @@ describe('hits', () => {
onEvent.mockClear();

fireEvent.click(getByText(container, 'title 2'));
// The default `click` one + the custom one
expect(onEvent).toHaveBeenCalledTimes(2);
// The custom one only
expect(onEvent).toHaveBeenCalledTimes(1);
expect(onEvent.mock.calls[0][0]).toEqual({
eventType: 'conversion',
hits: [
Expand Down Expand Up @@ -289,9 +289,9 @@ describe('hits', () => {
onEvent.mockClear();

fireEvent.click(getByText(container, 'title 1'));
// The default `click` one + the custom one
expect(onEvent).toHaveBeenCalledTimes(2);
expect(onEvent.mock.calls[1][0]).toEqual({
// The custom one only
expect(onEvent).toHaveBeenCalledTimes(1);
expect(onEvent.mock.calls[0][0]).toEqual({
eventType: 'click',
hits: [
{
Expand Down Expand Up @@ -342,9 +342,9 @@ describe('hits', () => {

fireEvent.click(getByText(container, 'title 2'));

// The default `click` one + the custom one
expect(onEvent).toHaveBeenCalledTimes(2);
expect(onEvent.mock.calls[1][0]).toEqual({
// The custom one only
expect(onEvent).toHaveBeenCalledTimes(1);
expect(onEvent.mock.calls[0][0]).toEqual({
eventType: 'conversion',
hits: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,9 +434,9 @@ describe('infiniteHits', () => {

fireEvent.click(getByText(container, 'title 1'));

// The default `click` one + the custom one
expect(onEvent).toHaveBeenCalledTimes(2);
expect(onEvent.mock.calls[onEvent.mock.calls.length - 1][0]).toEqual({
// The custom one only
expect(onEvent).toHaveBeenCalledTimes(1);
expect(onEvent.mock.calls[0][0]).toEqual({
eventType: 'click',
hits: [
{
Expand Down Expand Up @@ -486,9 +486,9 @@ describe('infiniteHits', () => {

fireEvent.click(getByText(container, 'title 2'));

// The default `click` one + the custom one
expect(onEvent).toHaveBeenCalledTimes(2);
expect(onEvent.mock.calls[onEvent.mock.calls.length - 1][0]).toEqual({
// The custom one only
expect(onEvent).toHaveBeenCalledTimes(1);
expect(onEvent.mock.calls[0][0]).toEqual({
eventType: 'conversion',
hits: [
{
Expand Down
2 changes: 1 addition & 1 deletion packages/react-instantsearch-hooks-web/src/ui/Hits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function Hits<THit extends Hit>({
key={hit.objectID}
className={cx('ais-Hits-item', classNames.item)}
onClick={() => {
sendEvent('click', hit, 'Hit Clicked');
sendEvent('click:internal', hit, 'Hit Clicked');
}}
>
<HitComponent hit={hit} sendEvent={sendEvent} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ describe('Hits', () => {

expect(props.sendEvent).toHaveBeenCalledTimes(1);
expect(props.sendEvent).toHaveBeenLastCalledWith(
'click',
'click:internal',
props.hits[0],
'Hit Clicked'
);
Expand Down

0 comments on commit 2b84e99

Please sign in to comment.