diff --git a/.changeset/hip-carrots-reply.md b/.changeset/hip-carrots-reply.md new file mode 100644 index 000000000..2b67fc6e8 --- /dev/null +++ b/.changeset/hip-carrots-reply.md @@ -0,0 +1,5 @@ +--- +"@scaleway/use-analytics": patch +--- + +Add normalize id on anonymousId, userId, groupId diff --git a/packages/use-analytics/package.json b/packages/use-analytics/package.json index ffc30561c..b44204a4e 100644 --- a/packages/use-analytics/package.json +++ b/packages/use-analytics/package.json @@ -40,7 +40,9 @@ "build": "vite build --config vite.config.ts && pnpm run type:generate", "build:profile": "npx vite-bundle-visualizer -c vite.config.ts", "lint": "eslint --report-unused-disable-directives --cache --cache-strategy content --ext ts,tsx .", - "lintpublish": "publint" + "lintpublish": "publint", + "test:unit": "vitest --run --config vite.config.ts", + "test:unit:coverage": "pnpm test:unit --coverage" }, "repository": { "type": "git", diff --git a/packages/use-analytics/src/__tests__/AnalyticsProvider.test.tsx b/packages/use-analytics/src/__tests__/AnalyticsProvider.test.tsx deleted file mode 100644 index 1929a0986..000000000 --- a/packages/use-analytics/src/__tests__/AnalyticsProvider.test.tsx +++ /dev/null @@ -1,160 +0,0 @@ -// import { AnalyticsBrowser } from '@segment/analytics-next' -// import type { Context } from '@segment/analytics-next' -// import { RudderAnalytics } from '@rudderstack/analytics-js' -// import { render, screen, waitFor } from '@testing-library/react' -// import { describe, expect, it, vi } from 'vitest' -// import {AnalyticsProvider} from './' -// import type { Analytics } from './index' - -// const TestChildren = () =>
children
- -// const defaultAnalytics = {} as Analytics - -// describe('AnalyticsProvider', () => { -// it('Provider should render children when shouldRenderOnlyWhenReady is false', async () => { -// const mock = vi -// .spyOn(RudderAnalytics, 'load') -// .mockResolvedValue([defaultAnalytics, {} as Context]) - -// const settings = { writeKey: 'helloworld', cdnURL: '', timeout: 300 } - -// render( -// () => Promise.resolve(), -// }} -// > -// -// , -// ) - -// await waitFor(() => { -// expect(mock).toHaveBeenCalledTimes(0) -// }) - -// expect(screen.getByTestId('test')).toBeTruthy() -// }) - -// it('Provider should not render children when options are not loaded ', async () => { -// const mock = vi -// .spyOn(AnalyticsBrowser, 'load') -// .mockResolvedValue([{} as Analytics, {} as Context]) - -// const settings = { writeKey: 'helloworld' } - -// render( -// () => Promise.resolve(), -// }} -// > -// -// , -// ) - -// await waitFor(() => { -// expect(mock).toHaveBeenCalledTimes(0) -// }) - -// expect(screen.queryByTestId('test')).toBe(null) -// }) - -// it('Provider should not render children when options are not loaded at first render, but load after options changed', async () => { -// const mock = vi -// .spyOn(AnalyticsBrowser, 'load') -// .mockResolvedValue([{} as Analytics, {} as Context]) - -// const settings = { writeKey: 'helloworld' } - -// const { rerender } = render( -// () => Promise.resolve(), -// }} -// > -// -// , -// ) - -// await waitFor(() => { -// expect(mock).toHaveBeenCalledTimes(0) -// }) - -// expect(screen.queryByTestId('test')).toBe(null) - -// rerender( -// () => Promise.resolve(), -// }} -// > -// -// , -// ) - -// await waitFor(() => { -// expect(mock).toHaveBeenCalledTimes(1) -// }) - -// expect(screen.queryByTestId('test')).toBeTruthy() -// }) - -// it('Provider should not render children when options are not loaded at first render, but load after options changed even without settings', async () => { -// const mock = vi -// .spyOn(AnalyticsBrowser, 'load') -// .mockResolvedValue([{} as Analytics, {} as Context]) - -// const { rerender } = render( -// () => Promise.resolve(), -// }} -// > -// -// , -// ) - -// await waitFor(() => { -// expect(mock).toHaveBeenCalledTimes(0) -// }) - -// expect(screen.queryByTestId('test')).toBe(null) - -// rerender( -// () => Promise.resolve(), -// }} -// > -// -// , -// ) - -// await waitFor(() => { -// expect(mock).toHaveBeenCalledTimes(0) -// }) - -// expect(screen.queryByTestId('test')).toBeTruthy() -// }) -// }) diff --git a/packages/use-analytics/src/__tests__/normalizeId.test.ts b/packages/use-analytics/src/__tests__/normalizeId.test.ts new file mode 100644 index 000000000..110bbf116 --- /dev/null +++ b/packages/use-analytics/src/__tests__/normalizeId.test.ts @@ -0,0 +1,37 @@ +// use-analytics/src/__tests__/normalizeId.test.ts +import { normalizeId } from '../analytics/normalizeId' + +describe('normalizeId', () => { + it('should return undefined for null or undefined input', () => { + expect(normalizeId(null)).toBeUndefined() + expect(normalizeId(undefined)).toBeUndefined() + }) + + it('should return the same string for non-JSON inputs', () => { + expect(normalizeId('user123')).toBe('user123') + expect(normalizeId('hello world!')).toBe('hello world!') + }) + + it('should parse valid JSON strings to string', () => { + expect(normalizeId('"user123"')).toBe('user123') + expect(normalizeId('"12345"')).toBe('12345') + expect(normalizeId('"\\"nested\\" string"')).toBe('"nested" string') + }) + + it('should ignore JSON strings that decode to non-strings', () => { + expect(normalizeId('{"id": "user123"}')).toBe('{"id": "user123"}') + expect(normalizeId('[1, 2, 3]')).toBe('[1, 2, 3]') + expect(normalizeId('123')).toBe('123') + expect(normalizeId('true')).toBe('true') + }) + + it('should convert numbers to strings', () => { + expect(normalizeId(12345)).toBe('12345') + expect(normalizeId(123e45)).toBe('1.23e+47') // Might vary based on the string conversion + }) + + it('should handle string inputs with special characters', () => { + expect(normalizeId('user!@#$%^&*()')).toBe('user!@#$%^&*()') + expect(normalizeId('user"123"')).toBe('user"123"') + }) +}) diff --git a/packages/use-analytics/src/analytics/index.ts b/packages/use-analytics/src/analytics/index.ts index 0f2f85ff3..ce11c56cc 100644 --- a/packages/use-analytics/src/analytics/index.ts +++ b/packages/use-analytics/src/analytics/index.ts @@ -9,3 +9,4 @@ export type { export { userMigrationsTraits } from './segments/userMigrationsTraits' export { defaultLoadOptions } from './constants' export { useDestinations } from './useDestinations' +export { normalizeId } from './normalizeId' diff --git a/packages/use-analytics/src/analytics/normalizeId.ts b/packages/use-analytics/src/analytics/normalizeId.ts new file mode 100644 index 000000000..54c693ab5 --- /dev/null +++ b/packages/use-analytics/src/analytics/normalizeId.ts @@ -0,0 +1,24 @@ +type ID = string | number | undefined | null + +export const normalizeId = (id: ID) => { + if (id === null || id === undefined) return undefined + + // Already a string but possibly JSON-encoded + if (typeof id === 'string') { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const parsed = JSON.parse(id) + // Only use parsed if it’s a string too + + if (typeof parsed === 'string') { + return parsed + } + } catch { + // not JSON, keep original + } + + return id + } + + return String(id) +} diff --git a/packages/use-analytics/src/analytics/normalizeIdsMigration.ts b/packages/use-analytics/src/analytics/normalizeIdsMigration.ts new file mode 100644 index 000000000..ae2b3087b --- /dev/null +++ b/packages/use-analytics/src/analytics/normalizeIdsMigration.ts @@ -0,0 +1,25 @@ +import type { RudderAnalytics } from '@rudderstack/analytics-js' +import { normalizeId } from './normalizeId' + +export const normalizeIdsMigration = (rudderAnalytics: RudderAnalytics) => { + // normalize id issue with segment migration + const anonymousId = rudderAnalytics.getAnonymousId() + const normalizeAnonymousId = normalizeId(anonymousId) + if (normalizeAnonymousId !== anonymousId) { + rudderAnalytics.setAnonymousId(normalizeAnonymousId) + } + + const userId = rudderAnalytics.getUserId() + const normalizeUserId = userId ? normalizeId(userId) : null + + if (userId !== normalizeUserId && normalizeUserId) { + rudderAnalytics.identify(normalizeUserId) + } + + const groupId = rudderAnalytics.getGroupId() + const normalizeGroupId = groupId ? normalizeId(groupId) : null + + if (userId !== normalizeGroupId && normalizeGroupId) { + rudderAnalytics.group(normalizeGroupId) + } +} diff --git a/packages/use-analytics/src/analytics/segments/userMigrationsTraits.ts b/packages/use-analytics/src/analytics/segments/userMigrationsTraits.ts index c833ab16c..c075f666e 100644 --- a/packages/use-analytics/src/analytics/segments/userMigrationsTraits.ts +++ b/packages/use-analytics/src/analytics/segments/userMigrationsTraits.ts @@ -1,4 +1,5 @@ import type { RudderAnalytics } from '@rudderstack/analytics-js' +import { normalizeId } from '../normalizeId' const SEGMENT_COOKIES_KEY = { ANONYMOUS_ID: 'ajs_anonymous_id', @@ -16,14 +17,20 @@ export const userMigrationsTraits = (rudderAnalytics: RudderAnalytics) => { const rudderGroupId = rudderAnalytics.getGroupId() if (segmentAnonymousId) { - rudderAnalytics.setAnonymousId(segmentAnonymousId) + rudderAnalytics.setAnonymousId(normalizeId(segmentAnonymousId)) } if (segmentUserId && (!rudderUserId || rudderUserId !== segmentUserId)) { - rudderAnalytics.identify(segmentUserId) + const normalizedUserId = normalizeId(segmentUserId) + if (normalizedUserId) { + rudderAnalytics.identify(normalizedUserId) + } } if (segmentGroupId && (!rudderGroupId || rudderGroupId !== segmentGroupId)) { - rudderAnalytics.group(segmentGroupId) + const normalizedGroupId = normalizeId(segmentGroupId) + if (normalizedGroupId) { + rudderAnalytics.group(normalizedGroupId) + } } } diff --git a/packages/use-analytics/src/analytics/useAnalytics.tsx b/packages/use-analytics/src/analytics/useAnalytics.tsx index 5ee9d31eb..fda0536b7 100644 --- a/packages/use-analytics/src/analytics/useAnalytics.tsx +++ b/packages/use-analytics/src/analytics/useAnalytics.tsx @@ -6,6 +6,7 @@ import { useDeepCompareEffectNoCheck } from 'use-deep-compare-effect' import { destSDKBaseURL, pluginsSDKBaseURL } from '../constants' import type { CategoryKind } from '../types' import { defaultConsentOptions, defaultLoadOptions } from './constants' +import { normalizeIdsMigration } from './normalizeIdsMigration' import { userMigrationsTraits } from './segments/userMigrationsTraits' type Analytics = RudderAnalytics @@ -130,6 +131,7 @@ export function AnalyticsProvider({ pluginsSDKBaseURL: pluginsSDKBaseURL(settings.cdnURL), onLoaded: (rudderAnalytics: Analytics) => { userMigrationsTraits(rudderAnalytics) + normalizeIdsMigration(rudderAnalytics) rudderAnalytics.consent({ ...defaultConsentOptions, diff --git a/packages/use-i18n/src/formatUnit.ts b/packages/use-i18n/src/formatUnit.ts index 1f3282b96..c02129c66 100644 --- a/packages/use-i18n/src/formatUnit.ts +++ b/packages/use-i18n/src/formatUnit.ts @@ -81,9 +81,8 @@ const formatShortUnit = ( shortenedUnit = symbols.short.octet } - return `${exponent.symbol}${shortenedUnit}${ - compoundUnit ? compoundUnitsSymbols[compoundUnit] : '' - }` + return `${exponent.symbol}${shortenedUnit}${compoundUnit ? compoundUnitsSymbols[compoundUnit] : '' + }` } const formatLongUnit = ( @@ -100,22 +99,21 @@ const formatLongUnit = ( ) { translation = localesWhoFavorOctetOverByte[ - locale as keyof typeof localesWhoFavorOctetOverByte + locale as keyof typeof localesWhoFavorOctetOverByte ] } - return `${exponent.name}${ - formatters - .getTranslationFormat( - `{amount, plural, + return `${exponent.name}${formatters + .getTranslationFormat( + `{amount, plural, =0 {${translation.singular}} =1 {${translation.singular}} other {${translation.plural}} }`, - locale, - ) - .format({ amount }) as string - }` + locale, + ) + .format({ amount }) as string + }` } const format = @@ -130,73 +128,71 @@ const format = exponent?: Exponent humanize?: boolean }) => - ( - locale: string, - amount: number, - { - maximumFractionDigits, - minimumFractionDigits, - short = true, - base = 10, - }: { - maximumFractionDigits?: number - minimumFractionDigits?: number - short?: boolean - base?: 2 | 10 - }, - ): string => { - let computedExponent = exponent - let computedValue = amount + ( + locale: string, + amount: number, + { + maximumFractionDigits, + minimumFractionDigits, + short = true, + base = 10, + }: { + maximumFractionDigits?: number + minimumFractionDigits?: number + short?: boolean + base?: 2 | 10 + }, + ): string => { + let computedExponent = exponent + let computedValue = amount - if (humanize) { - if (computedExponent) { - const value = filesize(amount, { - base, - exponent: exponents.findIndex( - exp => exp.name === (computedExponent as Exponent).name, - ), - output: 'object', - round: maximumFractionDigits, - }) + if (humanize) { + if (computedExponent) { + const value = filesize(amount, { + base, + exponent: exponents.findIndex( + exp => exp.name === (computedExponent as Exponent).name, + ), + output: 'object', + round: maximumFractionDigits, + }) - computedValue = Number.parseFloat(value.value) - } else { - const value = filesize(amount, { - base, - output: 'object', - round: maximumFractionDigits, - }) + computedValue = Number.parseFloat(value.value.toString()) + } else { + const value = filesize(amount, { + base, + output: 'object', + round: maximumFractionDigits, + }) - computedExponent = exponents[value.exponent] - computedValue = Number.parseFloat(value.value) + computedExponent = exponents[value.exponent] + computedValue = Number.parseFloat(value.value.toString()) + } } - } - return `${new Intl.NumberFormat(locale, { - maximumFractionDigits, - minimumFractionDigits, - }).format(computedValue)} ${ - short + return `${new Intl.NumberFormat(locale, { + maximumFractionDigits, + minimumFractionDigits, + }).format(computedValue)} ${short ? formatShortUnit( - locale, - computedExponent as Exponent, - unit, - compoundUnit, - ) + locale, + computedExponent as Exponent, + unit, + compoundUnit, + ) : formatLongUnit( - locale, - computedExponent as Exponent, - unit, - computedValue, - ) - }` - } + locale, + computedExponent as Exponent, + unit, + computedValue, + ) + }` + } type SimpleUnits = `${ExponentName}${Unit}${'-humanized' | ''}` type ComplexUnits = `${Unit}${'s' | ''}${'-humanized' | ''}` -type PerSecondUnit = `${ExponentName | ''}bit${'s' | ''}${'-per-second' | ''}${ - | '-humanized' - | ''}` +// eslint-disable-next-line @stylistic/space-infix-ops +type PerSecondUnit = `${ExponentName | ''}bit${'s' | ''}${'-per-second' | ''}${| '-humanized' | ''}` type SupportedUnits = SimpleUnits | ComplexUnits | PerSecondUnit export const supportedUnits: Partial<