diff --git a/bundlesize.config.json b/bundlesize.config.json index 472ae68a41..0a3f49a9c9 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,15 +10,15 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "83.25 kB" + "maxSize": "83.50 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", - "maxSize": "180.25 kB" + "maxSize": "181.50 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", - "maxSize": "51 kB" + "maxSize": "51.25 kB" }, { "path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js", diff --git a/packages/instantsearch.js/src/lib/__tests__/InstantSearch-integration-test.ts b/packages/instantsearch.js/src/lib/__tests__/InstantSearch-integration-test.ts index 217d6136b1..a6007c69e1 100644 --- a/packages/instantsearch.js/src/lib/__tests__/InstantSearch-integration-test.ts +++ b/packages/instantsearch.js/src/lib/__tests__/InstantSearch-integration-test.ts @@ -489,6 +489,7 @@ describe('network requests', () => { "params": { "clickAnalytics": true, "query": "", + "userToken": "cookie-key", }, }, ] @@ -563,6 +564,7 @@ describe('network requests', () => { "params": { "clickAnalytics": true, "query": "", + "userToken": "cookie-key", }, }, ] diff --git a/packages/instantsearch.js/src/lib/utils/uuid.ts b/packages/instantsearch.js/src/lib/utils/uuid.ts new file mode 100644 index 0000000000..a2737070d3 --- /dev/null +++ b/packages/instantsearch.js/src/lib/utils/uuid.ts @@ -0,0 +1,15 @@ +/** + * Create UUID according to + * https://www.ietf.org/rfc/rfc4122.txt. + * + * @returns Generated UUID. + */ +export function createUUID(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + /* eslint-disable no-bitwise */ + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + /* eslint-enable */ + return v.toString(16); + }); +} diff --git a/packages/instantsearch.js/src/middlewares/__tests__/createInsightsMiddleware.ts b/packages/instantsearch.js/src/middlewares/__tests__/createInsightsMiddleware.ts index 41ff775c50..476c74f06e 100644 --- a/packages/instantsearch.js/src/middlewares/__tests__/createInsightsMiddleware.ts +++ b/packages/instantsearch.js/src/middlewares/__tests__/createInsightsMiddleware.ts @@ -21,6 +21,7 @@ import { history } from '../../lib/routers'; import { warning } from '../../lib/utils'; import { dynamicWidgets, hits, refinementList } from '../../widgets'; +import type { InsightsProps } from '..'; import type { SearchClient } from '../../index.es'; import type { PlainSearchParameters } from 'algoliasearch-helper'; import type { JSDOM } from 'jsdom'; @@ -50,6 +51,10 @@ describe('insights', () => { searchClient = searchClientWithCredentials, started = true, insights = false, + }: { + searchClient?: SearchClient; + started?: boolean; + insights?: InsightsProps | boolean; } = {}) => { castToJestMock(searchClient.search).mockClear(); const { analytics, insightsClient } = createInsights(); @@ -437,37 +442,6 @@ describe('insights', () => { ); }); - it('warns when userToken is not set', () => { - const { insightsClient, instantSearchInstance } = createTestEnvironment(); - - instantSearchInstance.use( - createInsightsMiddleware({ - insightsClient, - insightsInitParams: { - useCookie: false, - anonymousUserToken: false, - }, - }) - ); - - expect(() => - instantSearchInstance.sendEventToInsights({ - eventType: 'view', - insightsMethod: 'viewedObjectIDs', - payload: { - eventName: 'Hits Viewed', - index: '', - objectIDs: ['1', '2'], - }, - widgetType: 'ais.hits', - }) - ).toWarnDev( - `[InstantSearch.js]: Cannot send event to Algolia Insights because \`userToken\` is not set. - -See documentation: https://www.algolia.com/doc/guides/building-search-ui/going-further/send-insights-events/js/#setting-the-usertoken` - ); - }); - it('applies clickAnalytics if $$automatic: undefined', () => { const { insightsClient, instantSearchInstance } = createTestEnvironment(); instantSearchInstance.use( @@ -778,37 +752,34 @@ See documentation: https://www.algolia.com/doc/guides/building-search-ui/going-f }); it('handles multiple setUserToken calls before search.start()', async () => { - const { insightsClient } = createInsights(); - const indexName = 'my-index'; - const instantSearchInstance = instantsearch({ - searchClient: createSearchClient({ - // @ts-expect-error only available in search client v4 - transporter: { - headers: { - 'x-algolia-application-id': 'myAppId', - 'x-algolia-api-key': 'myApiKey', - }, - }, - }), - indexName, - }); + const { insightsClient, instantSearchInstance, getUserToken } = + createTestEnvironment(); - const middleware = createInsightsMiddleware({ - insightsClient, - }); - instantSearchInstance.use(middleware); + instantSearchInstance.use( + createInsightsMiddleware({ + insightsClient, + }) + ); insightsClient('setUserToken', 'abc'); insightsClient('setUserToken', 'def'); - instantSearchInstance.start(); + expect(getUserToken()).toEqual('def'); - await wait(0); + instantSearchInstance.addWidgets([connectSearchBox(() => ({}))({})]); - expect( - (instantSearchInstance.mainHelper!.state as PlainSearchParameters) - .userToken - ).toEqual('def'); + await wait(0); + expect(instantSearchInstance.client.search).toHaveBeenCalledTimes(1); + expect(instantSearchInstance.client.search).toHaveBeenLastCalledWith([ + { + indexName: 'my-index', + params: { + clickAnalytics: true, + query: '', + userToken: getUserToken(), + }, + }, + ]); }); it('searches once per unique userToken', async () => { @@ -836,7 +807,8 @@ See documentation: https://www.algolia.com/doc/guides/building-search-ui/going-f }); it("doesn't search when userToken is falsy", async () => { - const { insightsClient, instantSearchInstance } = createTestEnvironment(); + const { insightsClient, instantSearchInstance, getUserToken } = + createTestEnvironment(); instantSearchInstance.addWidgets([connectSearchBox(() => ({}))({})]); @@ -866,8 +838,8 @@ See documentation: https://www.algolia.com/doc/guides/building-search-ui/going-f indexName: 'my-index', params: { clickAnalytics: true, - query: '', + userToken: getUserToken(), }, }, ]); @@ -878,6 +850,81 @@ See documentation: https://www.algolia.com/doc/guides/building-search-ui/going-f expect(instantSearchInstance.client.search).toHaveBeenCalledTimes(2); }); + it('sets an anonymous token as the userToken if none given', async () => { + const { instantSearchInstance, getUserToken } = createTestEnvironment({ + insights: true, + }); + + instantSearchInstance.addWidgets([connectSearchBox(() => ({}))({})]); + + await wait(0); + expect(instantSearchInstance.client.search).toHaveBeenCalledTimes(1); + expect(instantSearchInstance.client.search).toHaveBeenLastCalledWith([ + { + indexName: 'my-index', + params: { + clickAnalytics: true, + query: '', + userToken: getUserToken(), + }, + }, + ]); + + expect(getUserToken()).toEqual(expect.stringMatching(/^anonymous-/)); + }); + + it('saves an anonymous token to a cookie if useCookie is true in insights init props', () => { + const { getUserToken } = createTestEnvironment({ + insights: { + insightsInitParams: { + useCookie: true, + }, + }, + }); + + const userToken = getUserToken(); + expect(userToken).toEqual(expect.stringMatching(/^anonymous-/)); + expect(document.cookie).toBe(`_ALGOLIA=${userToken}`); + }); + + it('saves an anonymous token to a cookie if useCookie is true insights init method', async () => { + const { instantSearchInstance, insightsClient, getUserToken } = + createTestEnvironment({ + insights: true, + started: false, + }); + + insightsClient('init', { partial: true, useCookie: true }); + + instantSearchInstance.start(); + + await wait(0); + const userToken = getUserToken(); + expect(userToken).toEqual(expect.stringMatching(/^anonymous-/)); + expect(document.cookie).toBe(`_ALGOLIA=${userToken}`); + }); + + it('uses `userToken` from insights init props over anything else', async () => { + document.cookie = '_ALGOLIA=abc'; + + const { instantSearchInstance, insightsClient, getUserToken } = + createTestEnvironment({ + insights: { + insightsInitParams: { + userToken: 'def', + }, + }, + started: false, + }); + + insightsClient('init', { partial: true, userToken: 'ghi' }); + + instantSearchInstance.start(); + + await wait(0); + expect(getUserToken()).toBe('def'); + }); + describe('authenticatedUserToken', () => { describe('before `init`', () => { it('uses the `authenticatedUserToken` as the `userToken` when defined', () => { @@ -921,6 +968,39 @@ See documentation: https://www.algolia.com/doc/guides/building-search-ui/going-f expect(getUserToken()).toEqual('abc'); }); + + it('uses the `authenticatedUserToken` when a `userToken` is set after', () => { + const { insightsClient, instantSearchInstance, getUserToken } = + createTestEnvironment(); + + insightsClient('setAuthenticatedUserToken', 'def'); + + instantSearchInstance.use( + createInsightsMiddleware({ insightsClient }) + ); + + insightsClient('setUserToken', 'abc'); + + expect(getUserToken()).toEqual('def'); + }); + + it('resets the token to the `userToken` when `authenticatedUserToken` is set as undefined', () => { + const { insightsClient, instantSearchInstance, getUserToken } = + createTestEnvironment(); + + insightsClient('setUserToken', 'abc'); + insightsClient('setAuthenticatedUserToken', 'def'); + + instantSearchInstance.use( + createInsightsMiddleware({ insightsClient }) + ); + + expect(getUserToken()).toEqual('def'); + + insightsClient('setAuthenticatedUserToken', undefined); + + expect(getUserToken()).toEqual('abc'); + }); }); describe('after `init`', () => { @@ -1361,13 +1441,15 @@ See documentation: https://www.algolia.com/doc/guides/building-search-ui/going-f // Dynamic widgets will trigger 2 searches. To avoid missing the cache on the second search, createInsightsMiddleware delays setting the userToken. + const userToken = getUserToken(); + expect(searchClient.search).toHaveBeenCalledTimes(2); - expect( - searchClient.search.mock.calls[0][0][0].params.userToken - ).toBeUndefined(); - expect( - searchClient.search.mock.calls[1][0][0].params.userToken - ).toBeUndefined(); + expect(searchClient.search.mock.calls[0][0][0].params.userToken).toBe( + userToken + ); + expect(searchClient.search.mock.calls[1][0][0].params.userToken).toBe( + userToken + ); await wait(0); @@ -1383,7 +1465,7 @@ See documentation: https://www.algolia.com/doc/guides/building-search-ui/going-f expect(searchClient.search).toHaveBeenCalledTimes(3); expect(searchClient.search).toHaveBeenLastCalledWith([ expect.objectContaining({ - params: expect.objectContaining({ userToken: getUserToken() }), + params: expect.objectContaining({ userToken }), }), ]); }); diff --git a/packages/instantsearch.js/src/middlewares/createInsightsMiddleware.ts b/packages/instantsearch.js/src/middlewares/createInsightsMiddleware.ts index b7e5b3b394..3b6b4a6695 100644 --- a/packages/instantsearch.js/src/middlewares/createInsightsMiddleware.ts +++ b/packages/instantsearch.js/src/middlewares/createInsightsMiddleware.ts @@ -6,6 +6,7 @@ import { find, safelyRunOnBrowser, } from '../lib/utils'; +import { createUUID } from '../lib/utils/uuid'; import type { InsightsClient, @@ -50,6 +51,8 @@ export type InsightsClientWithGlobals = InsightsClient & { export type CreateInsightsMiddleware = typeof createInsightsMiddleware; +type TokenType = 'authenticated' | 'default'; + export function createInsightsMiddleware< TInsightsClient extends ProvidedInsightsClient >(props: InsightsProps = {}): InternalMiddleware { @@ -61,6 +64,7 @@ export function createInsightsMiddleware< $$automatic = false, } = props; + let currentTokenType: TokenType | undefined; let potentialInsightsClient: ProvidedInsightsClient = _insightsClient; if (!_insightsClient && _insightsClient !== null) { @@ -111,6 +115,8 @@ export function createInsightsMiddleware< 'could not extract Algolia credentials from searchClient in insights middleware.' ); + let queuedInitParams: Partial | undefined = + undefined; let queuedUserToken: string | undefined = undefined; let queuedAuthenticatedUserToken: string | undefined = undefined; let userTokenBeforeInit: string | undefined = undefined; @@ -129,9 +135,10 @@ export function createInsightsMiddleware< // At this point, even though `search-insights` is not loaded yet, // we still want to read the token from the queue. // Otherwise, the first search call will be fired without the token. - [queuedUserToken, queuedAuthenticatedUserToken] = [ + [queuedUserToken, queuedAuthenticatedUserToken, queuedInitParams] = [ 'setUserToken', 'setAuthenticatedUserToken', + 'init', ].map((key) => { const [, value] = find(queue.slice().reverse(), ([method]) => method === key) || []; @@ -197,6 +204,24 @@ export function createInsightsMiddleware< helper = instantSearchInstance.mainHelper!; + const { queue: queueAtStart } = insightsClient; + + if (Array.isArray(queueAtStart)) { + [queuedUserToken, queuedAuthenticatedUserToken, queuedInitParams] = [ + 'setUserToken', + 'setAuthenticatedUserToken', + 'init', + ].map((key) => { + const [, value] = + find( + queueAtStart.slice().reverse(), + ([method]) => method === key + ) || []; + + return value; + }); + } + initialParameters = { userToken: (helper.state as PlainSearchParameters).userToken, clickAnalytics: helper.state.clickAnalytics, @@ -217,7 +242,9 @@ export function createInsightsMiddleware< const setUserTokenToSearch = ( userToken?: string | number, - immediate = false + tokenType?: TokenType, + immediate = false, + unsetAuthenticatedUserToken = false ) => { const normalizedUserToken = normalizeUserToken(userToken); @@ -237,6 +264,19 @@ export function createInsightsMiddleware< if (existingToken && existingToken !== userToken) { instantSearchInstance.scheduleSearch(); } + + currentTokenType = tokenType; + } + + // the authenticated user token cannot be overridden by a user or anonymous token + // for instant search query requests + if ( + currentTokenType && + currentTokenType === 'authenticated' && + tokenType === 'default' && + !unsetAuthenticatedUserToken + ) { + return; } // Delay the token application to the next render cycle @@ -247,19 +287,16 @@ export function createInsightsMiddleware< } }; - const anonymousUserToken = getInsightsAnonymousUserTokenInternal(); - if (anonymousUserToken) { - // When `aa('init', { ... })` is called, it creates an anonymous user token in cookie. - // We can set it as userToken. - setUserTokenToSearch(anonymousUserToken, true); - } - function setUserToken( token: string | number, userToken?: string | number, authenticatedUserToken?: string | number ) { - setUserTokenToSearch(token, true); + setUserTokenToSearch( + token, + authenticatedUserToken ? 'authenticated' : 'default', + true + ); if (userToken) { insightsClient('setUserToken', userToken); @@ -269,30 +306,95 @@ export function createInsightsMiddleware< } } + let anonymousUserToken: string | undefined = undefined; + const anonymousTokenFromInsights = + getInsightsAnonymousUserTokenInternal(); + if (anonymousTokenFromInsights) { + // When `aa('init', { ... })` is called, it creates an anonymous user token in cookie. + // We can set it as userToken on instantsearch and insights. If it's not set as an insights + // userToken before a sendEvent, insights automatically generates a new anonymous token, + // causing a state change and an unnecessary query on instantsearch. + anonymousUserToken = anonymousTokenFromInsights; + } else { + const token = `anonymous-${createUUID()}`; + anonymousUserToken = token; + } + + let authenticatedUserTokenFromInit: string | undefined; + let userTokenFromInit: string | undefined; + + // With SSR, the token could be be set on the state. We make sure + // that insights is in sync with that token since, there is no + // insights lib on the server. + const tokenFromSearchParameters = initialParameters.userToken; + + // When the first query is sent, the token is possibly not yet be set by + // the insights onChange callbacks (if insights isn't yet loaded). + // It is explicitly being set here so that the first query has the + // initial tokens set and ensure a second query isn't automatically + // made when the onChange callback actually changes the state. + if (insightsInitParams) { + if (insightsInitParams.authenticatedUserToken) { + authenticatedUserTokenFromInit = + insightsInitParams.authenticatedUserToken; + } else if (insightsInitParams.userToken) { + userTokenFromInit = insightsInitParams.userToken; + } + } + // We consider the `userToken` or `authenticatedUserToken` before an - // `init` call of higher importance than one from the queue. + // `init` call of higher importance than one from the queue and ones set + // from the init props to be higher than that. + const tokenFromInit = + authenticatedUserTokenFromInit || userTokenFromInit; const tokenBeforeInit = authenticatedUserTokenBeforeInit || userTokenBeforeInit; - const queuedToken = queuedAuthenticatedUserToken || queuedUserToken; + const tokenFromQueue = queuedAuthenticatedUserToken || queuedUserToken; - if (tokenBeforeInit) { + if (tokenFromInit) { + setUserToken( + tokenFromInit, + userTokenFromInit, + authenticatedUserTokenFromInit + ); + } else if (tokenFromSearchParameters) { + setUserToken( + tokenFromSearchParameters, + tokenFromSearchParameters, + undefined + ); + } else if (tokenBeforeInit) { setUserToken( tokenBeforeInit, userTokenBeforeInit, authenticatedUserTokenBeforeInit ); - } else if (queuedToken) { + } else if (tokenFromQueue) { setUserToken( - queuedToken, + tokenFromQueue, queuedUserToken, queuedAuthenticatedUserToken ); + } else if (anonymousUserToken) { + setUserToken(anonymousUserToken, anonymousUserToken, undefined); + + if (insightsInitParams?.useCookie || queuedInitParams?.useCookie) { + saveTokenAsCookie( + anonymousUserToken, + insightsInitParams?.cookieDuration || + queuedInitParams?.cookieDuration + ); + } } // This updates userToken which is set explicitly by `aa('setUserToken', userToken)` - insightsClient('onUserTokenChange', setUserTokenToSearch, { - immediate: true, - }); + insightsClient( + 'onUserTokenChange', + (token) => setUserTokenToSearch(token, 'default', true), + { + immediate: true, + } + ); // This updates userToken which is set explicitly by `aa('setAuthenticatedtUserToken', authenticatedUserToken)` insightsClient( @@ -301,11 +403,11 @@ export function createInsightsMiddleware< // If we're unsetting the `authenticatedUserToken`, we revert to the `userToken` if (!authenticatedUserToken) { insightsClient('getUserToken', null, (_, userToken) => { - setUserTokenToSearch(userToken); + setUserTokenToSearch(userToken, 'default', true, true); }); } - setUserTokenToSearch(authenticatedUserToken); + setUserTokenToSearch(authenticatedUserToken, 'authenticated', true); }, { immediate: true, @@ -394,6 +496,14 @@ See documentation: https://www.algolia.com/doc/guides/building-search-ui/going-f }; } +function saveTokenAsCookie(token: string, cookieDuration?: number) { + const MONTH = 30 * 24 * 60 * 60 * 1000; + const d = new Date(); + d.setTime(d.getTime() + (cookieDuration || MONTH * 6)); + const expires = `expires=${d.toUTCString()}`; + document.cookie = `_ALGOLIA=${token};${expires};path=/`; +} + /** * Determines if a given insights `client` supports the optional call to `init` * and the ability to set credentials via extra parameters when sending events. diff --git a/packages/instantsearch.js/src/types/insights.ts b/packages/instantsearch.js/src/types/insights.ts index 7e86869b1b..14e1701c88 100644 --- a/packages/instantsearch.js/src/types/insights.ts +++ b/packages/instantsearch.js/src/types/insights.ts @@ -56,7 +56,7 @@ type QueueItemMap = { ]; }; -type QueueItem = QueueItemMap[keyof QueueItemMap]; +export type QueueItem = QueueItemMap[keyof QueueItemMap]; export type InsightsClient = _InsightsClient & { queue?: QueueItem[]; diff --git a/packages/react-instantsearch-core/src/server/__tests__/getServerState.test.tsx b/packages/react-instantsearch-core/src/server/__tests__/getServerState.test.tsx index 397e616bc1..af70e03753 100644 --- a/packages/react-instantsearch-core/src/server/__tests__/getServerState.test.tsx +++ b/packages/react-instantsearch-core/src/server/__tests__/getServerState.test.tsx @@ -608,4 +608,41 @@ describe('getServerState', () => { `); }); + + describe('insights', () => { + test('userToken is set when insights has been set to true', async () => { + const searchClient = createSearchClient({}); + const spiedSearch = jest.spyOn(searchClient, 'search'); + + function App({ + serverState, + }: { + serverState?: InstantSearchServerState; + }) { + return ( + + + + + +

instant_search

+ +
+
+ ); + } + + await getServerState(, { renderToString }); + + expect(spiedSearch).toHaveBeenCalledTimes(1); + + const userToken = (spiedSearch.mock.calls[0][0] as any)[0].params + ?.userToken; + expect(userToken).toEqual(expect.stringMatching(/^anonymous-/)); + }); + }); }); diff --git a/tests/common/shared/insights.ts b/tests/common/shared/insights.ts index a8cf693626..effa0ace22 100644 --- a/tests/common/shared/insights.ts +++ b/tests/common/shared/insights.ts @@ -76,7 +76,7 @@ export function createInsightsTests( }); // initial calls because the middleware is attached - expect(window.aa).toHaveBeenCalledTimes(6); + expect(window.aa).toHaveBeenCalledTimes(7); expect(window.aa).toHaveBeenCalledWith( 'addAlgoliaAgent', 'insights-middleware' @@ -151,7 +151,7 @@ export function createInsightsTests( await setup(options); // initial calls because the middleware is attached - expect(window.aa).toHaveBeenCalledTimes(5); + expect(window.aa).toHaveBeenCalledTimes(6); expect(window.aa).toHaveBeenCalledWith( 'addAlgoliaAgent', 'insights-middleware' @@ -164,7 +164,7 @@ export function createInsightsTests( }); // Once result is available - expect(window.aa).toHaveBeenCalledTimes(6); + expect(window.aa).toHaveBeenCalledTimes(7); expect(window.aa).toHaveBeenCalledWith( 'viewedObjectIDs', {