diff --git a/.changeset/quiet-survey-languages.md b/.changeset/quiet-survey-languages.md new file mode 100644 index 0000000000..342f88ce66 --- /dev/null +++ b/.changeset/quiet-survey-languages.md @@ -0,0 +1,5 @@ +--- +'posthog-react-native': patch +--- + +Update displayed surveys when the person's language changes, preserving in-progress answers and keeping survey event language metadata in sync. diff --git a/packages/react-native/src/posthog-rn.ts b/packages/react-native/src/posthog-rn.ts index 5682b3b30d..a4515ce238 100644 --- a/packages/react-native/src/posthog-rn.ts +++ b/packages/react-native/src/posthog-rn.ts @@ -580,7 +580,12 @@ export class PostHog extends PostHogCore { setPersistedProperty(key: PostHogPersistedProperty, value: T | null): void { const storage = this._storageForKey(key) - return value !== null ? storage.setItem(key, value) : storage.removeItem(key) + value !== null ? storage.setItem(key, value) : storage.removeItem(key) + if (key === PostHogPersistedProperty.PersonProperties) { + // Notify surveys after the in-memory write, including unsets and resets, + // without waiting for a feature flag reload. + this._events.emit('personProperties', value) + } } /** diff --git a/packages/react-native/src/surveys/PostHogSurveyProvider.tsx b/packages/react-native/src/surveys/PostHogSurveyProvider.tsx index 332729b0e8..15ce69959d 100644 --- a/packages/react-native/src/surveys/PostHogSurveyProvider.tsx +++ b/packages/react-native/src/surveys/PostHogSurveyProvider.tsx @@ -11,7 +11,7 @@ import { Survey, SurveyAppearance, SurveyType, type SurveyResponses } from '@pos import { usePostHog } from '../hooks/usePostHog' import { useFeatureFlags } from '../hooks/useFeatureFlags' import { PostHog } from '../posthog-rn' -import { applySurveyTranslationForUser } from './survey-translations' +import { applySurveyTranslationForUser, detectUserLanguage } from './survey-translations' type ActiveSurveyContextType = | { @@ -106,13 +106,24 @@ export function PostHogSurveyProvider(props: PostHogSurveyProviderProps): JSX.El const activatedSurveys = useActivatedSurveys(posthog, surveys) const flags = useFeatureFlags(posthog) + const [userLanguage, setUserLanguage] = useState(() => detectUserLanguage(posthog)) + + useEffect(() => { + const updateLanguage = () => setUserLanguage(detectUserLanguage(posthog)) + const unsubscribe = posthog.on('personProperties', updateLanguage) + updateLanguage() + return unsubscribe + }, [posthog]) // Load surveys once useEffect(() => { posthog .ready() .then(() => posthog._onSurveysReady()) - .then(() => posthog.getSurveys()) + .then(() => { + setUserLanguage(detectUserLanguage(posthog)) + return posthog.getSurveys() + }) .then(setSurveys) .catch(() => {}) }, [posthog]) @@ -146,8 +157,8 @@ export function PostHogSurveyProvider(props: PostHogSurveyProviderProps): JSX.El }, [activeSurvey, flags, surveys, seenSurveys, activatedSurveys]) const translatedActiveSurvey = useMemo(() => { - return activeSurvey ? applySurveyTranslationForUser(activeSurvey, posthog) : undefined - }, [activeSurvey, posthog]) + return activeSurvey ? applySurveyTranslationForUser(activeSurvey, posthog, userLanguage) : undefined + }, [activeSurvey, posthog, userLanguage]) // Merge survey appearance so that components and hooks can use a consistent model const surveyAppearance = useMemo(() => { @@ -180,6 +191,10 @@ export function PostHogSurveyProvider(props: PostHogSurveyProviderProps): JSX.El survey: translatedActiveSurvey.survey, surveyLanguage: translatedActiveSurvey.language, onShow: () => { + // Updating translated copy changes this callback, but does not show a new survey. + if (shownSurveyIdRef.current === activeSurvey.id) { + return + } shownSurveyIdRef.current = activeSurvey.id sendSurveyShownEvent(translatedActiveSurvey.survey, posthog, translatedActiveSurvey.language) setLastSeenSurveyDate(new Date()) diff --git a/packages/react-native/src/surveys/components/QuestionTypes.tsx b/packages/react-native/src/surveys/components/QuestionTypes.tsx index a151b822a3..f7c4dcacc1 100644 --- a/packages/react-native/src/surveys/components/QuestionTypes.tsx +++ b/packages/react-native/src/surveys/components/QuestionTypes.tsx @@ -330,9 +330,10 @@ export function MultipleChoiceQuestion({ question = question as MultipleSurveyQuestion const isSingleChoice = question.type === SurveyQuestionType.SingleChoice const allowMultiple = question.type === SurveyQuestionType.MultipleChoice - const openChoice = question.hasOpenChoice ? question.choices[question.choices.length - 1] : null + const openChoiceIndex = question.hasOpenChoice ? question.choices.length - 1 : -1 const choices = useMemo(() => getDisplayOrderChoices(question as MultipleSurveyQuestion), [question]) - const [selectedChoices, setSelectedChoices] = useState([]) + // Choice labels change with survey translations; keep selection tied to the original order. + const [selectedChoiceIndices, setSelectedChoiceIndices] = useState([]) const [openEndedInput, setOpenEndedInput] = useState('') // Only skip submit for single-choice questions without open choice @@ -345,12 +346,14 @@ export function MultipleChoiceQuestion({ text={question.buttonText ?? appearance.submitButtonText} submitDisabled={ !question.optional && - (selectedChoices.length === 0 || - (openChoice !== null && selectedChoices.includes(openChoice) && openEndedInput.length === 0)) + (selectedChoiceIndices.length === 0 || + (selectedChoiceIndices.includes(openChoiceIndex) && openEndedInput.length === 0)) } appearance={appearance} onSubmit={() => { - const result = selectedChoices.map((c) => (c === openChoice ? openEndedInput : c)) + const result = selectedChoiceIndices.map((index) => + index === openChoiceIndex ? openEndedInput : question.choices[index] + ) onSubmit(allowMultiple ? result : result[0]) }} skipSubmitButton={shouldSkipSubmit} @@ -364,15 +367,15 @@ export function MultipleChoiceQuestion({ appearance={appearance} /> - {choices.map((choice: string, idx: number) => { - const isOpenChoice = choice === openChoice - const isSelected = selectedChoices.includes(choice) + {choices.map((choice: string, choiceIndex: number) => { + const isOpenChoice = choiceIndex === openChoiceIndex + const isSelected = selectedChoiceIndices.includes(choiceIndex) const choiceTextColor = appearance.inputTextColor ?? getContrastingTextColor(appearance.inputBackground) return ( { if (allowMultiple) { - setSelectedChoices( - isSelected ? selectedChoices.filter((c) => c !== choice) : [...selectedChoices, choice] + setSelectedChoiceIndices( + isSelected + ? selectedChoiceIndices.filter((index) => index !== choiceIndex) + : [...selectedChoiceIndices, choiceIndex] ) } else { - setSelectedChoices([choice]) + setSelectedChoiceIndices([choiceIndex]) if (shouldSkipSubmit && !isOpenChoice) { onSubmit(choice) } @@ -404,7 +409,7 @@ export function MultipleChoiceQuestion({ onChangeText={(userValue) => { setOpenEndedInput(userValue) if (!isSelected) { - setSelectedChoices(allowMultiple ? [...selectedChoices, choice] : [choice]) + setSelectedChoiceIndices(allowMultiple ? [...selectedChoiceIndices, choiceIndex] : [choiceIndex]) } }} /> diff --git a/packages/react-native/src/surveys/survey-translations.ts b/packages/react-native/src/surveys/survey-translations.ts index f83a6a1e41..63ee0010b4 100644 --- a/packages/react-native/src/surveys/survey-translations.ts +++ b/packages/react-native/src/surveys/survey-translations.ts @@ -21,9 +21,9 @@ export function detectUserLanguage(instance: PostHog): string | null { export function applySurveyTranslationForUser( survey: Survey, - instance: PostHog + instance: PostHog, + userLanguage = detectUserLanguage(instance) ): { survey: Survey; language: string | null } { - const userLanguage = detectUserLanguage(instance) const logger = getLogger(instance) if (!userLanguage) { diff --git a/packages/react-native/test/PostHogSurveyProvider.spec.tsx b/packages/react-native/test/PostHogSurveyProvider.spec.tsx index 1d1f80c75e..b1acda6f4a 100644 --- a/packages/react-native/test/PostHogSurveyProvider.spec.tsx +++ b/packages/react-native/test/PostHogSurveyProvider.spec.tsx @@ -50,6 +50,7 @@ vi.mock('../src/surveys/components/Surveys', () => ({ // Skip translation resolution — irrelevant to presentation gating. vi.mock('../src/surveys/survey-translations', () => ({ + detectUserLanguage: () => null, applySurveyTranslationForUser: (survey: Survey) => ({ survey, language: null }), })) diff --git a/packages/react-native/test/PostHogSurveyProvider.translations.spec.tsx b/packages/react-native/test/PostHogSurveyProvider.translations.spec.tsx new file mode 100644 index 0000000000..9c4cd62088 --- /dev/null +++ b/packages/react-native/test/PostHogSurveyProvider.translations.spec.tsx @@ -0,0 +1,287 @@ +/** @vitest-environment jsdom */ +import React from 'react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { Survey, SurveyQuestionType, SurveyType } from '@posthog/core' +import { PostHog } from '../src/posthog-rn' +import { PostHogSurveyProvider } from '../src/surveys/PostHogSurveyProvider' +import * as translations from '../src/surveys/survey-translations' +import { setupFetch } from './test-utils' + +// Keep the provider, modal, questions, translation and event paths real; only +// native primitives are replaced with interactive DOM equivalents. +vi.mock('react-native', async () => { + const native = await vi.importActual('./mocks/react-native') + const R = await vi.importActual('react') + const Box = ({ children }: any) => R.createElement('div', null, children) + const Button = ({ children, onPress, disabled }: any) => + R.createElement('button', { onClick: onPress, disabled }, children) + return { + ...native, + View: Box, + Text: Box, + ScrollView: Box, + Modal: Box, + KeyboardAvoidingView: Box, + TouchableOpacity: Button, + Pressable: Button, + TextInput: ({ value, onChangeText }: any) => + R.createElement('input', { value, onChange: (e: any) => onChangeText(e.target.value) }), + } +}) +vi.mock('../src/optional/OptionalReactNativeSvg', () => ({ OptionalReactNativeSvg: undefined })) +vi.mock('../src/optional/OptionalReactNativeSafeArea', () => ({ + useOptionalSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})) +vi.mock('../src/surveys/components/Cancel', () => ({ + Cancel: ({ onPress }: { onPress: () => void }) => , +})) +let posthog: PostHog +vi.mock('../src/hooks/usePostHog', () => ({ usePostHog: () => posthog })) + +const makeSurvey = (): Survey => ({ + id: 'translated-survey', + name: 'Survey', + type: SurveyType.Popover, + start_date: '2023-01-01T00:00:00Z', + questions: [0, 1].map((index) => ({ + id: `q${index}`, + type: SurveyQuestionType.Open, + originalQuestionIndex: index, + question: `Question ${index}`, + translations: { es: { question: `Pregunta ${index}` } }, + })), + appearance: { submitButtonText: 'Next', thankYouMessageHeader: 'Thanks' }, + translations: { es: { submitButtonText: 'Siguiente', thankYouMessageHeader: 'Gracias' } }, +}) + +async function mount(survey = makeSurvey(), overrideDisplayLanguage?: string) { + posthog = new PostHog('test-token', { + persistence: 'memory', + flushInterval: 0, + preloadFeatureFlags: false, + disableRemoteFeatureFlags: true, + captureAppLifecycleEvents: false, + overrideDisplayLanguage, + }) + await posthog.ready() + vi.spyOn(posthog, '_onSurveysReady').mockResolvedValue(undefined) + vi.spyOn(posthog, 'getSurveys').mockResolvedValue([survey]) + vi.spyOn(posthog, 'getCommonEventProperties').mockReturnValue({ $locale: 'en-US' }) + vi.spyOn(posthog, 'capture').mockImplementation(() => {}) + const result = render({null}) + await act(async () => {}) + return result +} + +function expectOnlyOneShown() { + expect(vi.mocked(posthog.capture).mock.calls.filter(([event]) => event === 'survey shown')).toHaveLength(1) +} + +beforeEach(() => { + vi.useRealTimers() + setupFetch() +}) +afterEach(async () => { + cleanup() + await posthog?.shutdown() + vi.restoreAllMocks() +}) + +it('retranslates after identify without losing the current question, draft, or previous answer', async () => { + const ui = await mount() + fireEvent.change(ui.getByRole('textbox'), { target: { value: 'First answer' } }) + fireEvent.click(ui.getByText('Next')) + fireEvent.change(ui.getByRole('textbox'), { target: { value: 'Draft answer' } }) + + act(() => posthog.identify('user', { language: 'es-MX' })) + + expect(ui.queryByText('Pregunta 1')).not.toBeNull() + expect((ui.getByRole('textbox') as HTMLInputElement).value).toBe('Draft answer') + fireEvent.click(ui.getByText('Siguiente')) + expect(ui.queryByText('Gracias')).not.toBeNull() + expect(posthog.capture).toHaveBeenCalledWith( + 'survey sent', + expect.objectContaining({ + $survey_language: 'es', + $survey_response_q0: 'First answer', + $survey_response_q1: 'Draft answer', + }) + ) + expectOnlyOneShown() +}) + +it('refreshes without a feature flag reload and dismisses with the displayed language', async () => { + const ui = await mount() + act(() => posthog.setPersonPropertiesForFlags({ language: 'es' }, false)) + expect(ui.queryByText('Pregunta 0')).not.toBeNull() + vi.useFakeTimers() + fireEvent.click(ui.getByText('Dismiss')) + act(() => vi.runAllTimers()) + expect(posthog.capture).toHaveBeenCalledWith('survey dismissed', expect.objectContaining({ $survey_language: 'es' })) + expectOnlyOneShown() + vi.useRealTimers() +}) + +it.each(['unset', 'reset properties', 'reset', 'unmatched'])( + 'returns to original copy and removes event language after %s', + async (operation) => { + const ui = await mount() + act(() => posthog.setPersonPropertiesForFlags({ language: 'es' }, false)) + expect(ui.queryByText('Pregunta 0')).not.toBeNull() + act(() => { + if (operation === 'unset') posthog.unsetPersonProperties('language', false) + else if (operation === 'reset properties') posthog.resetPersonPropertiesForFlags(false) + else if (operation === 'reset') posthog.reset() + else posthog.setPersonPropertiesForFlags({ language: 'zz' }, false) + }) + expect(ui.queryByText('Question 0')).not.toBeNull() + fireEvent.change(ui.getByRole('textbox'), { target: { value: 'Answer' } }) + fireEvent.click(ui.getByText('Next')) + fireEvent.change(ui.getByRole('textbox'), { target: { value: 'Answer 2' } }) + fireEvent.click(ui.getByText('Next')) + const sent = vi.mocked(posthog.capture).mock.calls.find(([event]) => event === 'survey sent') + expect(sent).toBeDefined() + expect(sent![1]).not.toHaveProperty('$survey_language') + expectOnlyOneShown() + } +) + +it('keeps override precedence and no-ops when the resolved language is unchanged', async () => { + const ui = await mount(makeSurvey(), 'es') + const translate = vi.spyOn(translations, 'applySurveyTranslationForUser') + act(() => posthog.setPersonPropertiesForFlags({ language: 'fr' }, false)) + act(() => posthog.setPersonPropertiesForFlags({ plan: 'paid' }, false)) + expect(ui.queryByText('Pregunta 0')).not.toBeNull() + expect(translate).not.toHaveBeenCalled() + expectOnlyOneShown() +}) + +it('unsubscribes from person property changes when unmounted', async () => { + const ui = await mount() + const emitter = (posthog as any)._events + expect(emitter.events.personProperties).toHaveLength(1) + ui.unmount() + expect(emitter.events.personProperties).toHaveLength(0) +}) + +it.each([ + [SurveyQuestionType.SingleChoice, true], + [SurveyQuestionType.MultipleChoice, true], + [SurveyQuestionType.SingleChoice, false], + [SurveyQuestionType.MultipleChoice, false], +] as const)('preserves selected %s choices and open text (language change: %s)', async (type, changeLanguage) => { + const survey = makeSurvey() + survey.questions = [ + { + id: 'choice', + type, + question: 'Pick', + originalQuestionIndex: 0, + choices: ['Apple', 'Other'], + hasOpenChoice: true, + translations: { es: { question: 'Elige', choices: ['Manzana', 'Otro'] } }, + }, + ] + const ui = await mount(survey) + fireEvent.click(ui.getByText('Apple')) + if (type === SurveyQuestionType.MultipleChoice) { + fireEvent.change(ui.getByRole('textbox'), { target: { value: 'Custom answer' } }) + } + if (changeLanguage) { + act(() => posthog.setPersonPropertiesForFlags({ language: 'es' }, false)) + } + expect(ui.queryByText(changeLanguage ? 'Elige' : 'Pick')).not.toBeNull() + const selectedLabel = changeLanguage ? 'Manzana' : 'Apple' + // The checkmark remains on the selected option, even when its label changes. + expect(ui.getByText(selectedLabel).closest('button')!.textContent).toBe(`${selectedLabel}v`) + fireEvent.click(ui.getByText(changeLanguage ? 'Siguiente' : 'Next')) + expect(posthog.capture).toHaveBeenCalledWith( + 'survey sent', + expect.objectContaining({ + ...(changeLanguage ? { $survey_language: 'es' } : {}), + $survey_response_choice: + type === SurveyQuestionType.SingleChoice ? selectedLabel : [selectedLabel, 'Custom answer'], + }) + ) + expectOnlyOneShown() +}) + +it('keeps a selected single open choice and its draft when translating', async () => { + const survey = makeSurvey() + survey.questions = [ + { + id: 'choice', + type: SurveyQuestionType.SingleChoice, + question: 'Pick', + originalQuestionIndex: 0, + choices: ['Apple', 'Other'], + hasOpenChoice: true, + translations: { es: { question: 'Elige', choices: ['Manzana', 'Otro'] } }, + }, + ] + const ui = await mount(survey) + fireEvent.change(ui.getByRole('textbox'), { target: { value: 'Custom answer' } }) + act(() => posthog.setPersonPropertiesForFlags({ language: 'es' }, false)) + expect(ui.queryByText('Elige')).not.toBeNull() + expect((ui.getByRole('textbox') as HTMLInputElement).value).toBe('Custom answer') + fireEvent.click(ui.getByText('Siguiente')) + expect(posthog.capture).toHaveBeenCalledWith( + 'survey sent', + expect.objectContaining({ + $survey_language: 'es', + $survey_response_choice: 'Custom answer', + }) + ) +}) + +it('does not retranslate for unchanged person language or unrelated properties', async () => { + const ui = await mount() + act(() => posthog.setPersonPropertiesForFlags({ language: 'es' }, false)) + const translate = vi.spyOn(translations, 'applySurveyTranslationForUser') + act(() => posthog.setPersonPropertiesForFlags({ language: 'es' }, false)) + act(() => posthog.setPersonPropertiesForFlags({ plan: 'paid' }, false)) + expect(ui.queryByText('Pregunta 0')).not.toBeNull() + expect(translate).not.toHaveBeenCalled() + expectOnlyOneShown() +}) + +it('uses the device locale translation after removing the person language', async () => { + const ui = await mount() + vi.mocked(posthog.getCommonEventProperties).mockReturnValue({ $locale: 'es-MX' }) + act(() => posthog.setPersonPropertiesForFlags({ language: 'fr' }, false)) + expect(ui.queryByText('Question 0')).not.toBeNull() + act(() => posthog.unsetPersonProperties('language', false)) + expect(ui.queryByText('Pregunta 0')).not.toBeNull() + expectOnlyOneShown() +}) + +it('preserves a selected rating when translating', async () => { + const survey = makeSurvey() + survey.questions = [ + { + id: 'rating', + type: SurveyQuestionType.Rating, + question: 'Rate', + originalQuestionIndex: 0, + display: 'number', + scale: 5, + lowerBoundLabel: 'Low', + upperBoundLabel: 'High', + translations: { es: { question: 'Califica', lowerBoundLabel: 'Bajo', upperBoundLabel: 'Alto' } }, + }, + ] + const ui = await mount(survey) + fireEvent.click(ui.getByText('4')) + act(() => posthog.setPersonPropertiesForFlags({ language: 'es' }, false)) + expect(ui.queryByText('Califica')).not.toBeNull() + expect(ui.queryByText('Bajo')).not.toBeNull() + fireEvent.click(ui.getByText('Siguiente')) + expect(posthog.capture).toHaveBeenCalledWith( + 'survey sent', + expect.objectContaining({ + $survey_language: 'es', + $survey_response_rating: 4, + }) + ) + expectOnlyOneShown() +})