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<