Skip to content

fix(surveys): re-translate displayed surveys when user language changes - #4626

Open
nandinitiw wants to merge 6 commits into
PostHog:mainfrom
nandinitiw:fix/survey-retranslate-on-language-change
Open

fix(surveys): re-translate displayed surveys when user language changes#4626
nandinitiw wants to merge 6 commits into
PostHog:mainfrom
nandinitiw:fix/survey-retranslate-on-language-change

Conversation

@nandinitiw

Copy link
Copy Markdown
Contributor

Reopening #4195 as a fresh PR — the original was auto-closed by the stale bot after a week of no activity, not for cause. lucasheriques's last review was positive ("the re-translate path holds up") and all requested changes were already addressed; the GitHub reopen API is failing with an opaque validation error, so opening fresh from the same branch instead. Same commits, same diff.

What

When a survey popover is on screen, it was never re-translated if the user's language changed mid-session. This happened in two scenarios:

  1. posthog.identify() / setPersonPropertiesForFlags() sets a new language person property
  2. The browser fires a languagechange event (OS/browser language switch)

The TODO comment in survey-translations.ts already called this out as a known gap.

Also fixes a gap this surfaced: $survey_questions[].question and $survey_language were read at capture time, not answer time — if the language changed mid-survey, submitted events showed the new language's question text even for questions the user answered under the old language.

How

In SurveyManager:

  • Register a window.languagechange listener in the constructor
  • Subscribe to posthog.onFeatureFlags() (which fires after identify() reloads flags)
  • Both call _onLanguageChange(), which:
    • No-ops if no survey is in focus, or if the survey hasn't actually rendered yet (gates against a delayed survey — surveyPopupDelaySeconds — being forced on screen early by a mid-delay reload, skipping the eligibility recheck the delay timeout does)
    • Re-detects the resolved language
    • No-ops if the language is unchanged (idempotent, safe to call on every flag reload)
    • Otherwise updates _currentLanguage and re-renders the popover via renderPopover()
  • Listeners are cleaned up on destroy()
  • _currentLanguage is cleared when a survey is dismissed

questionSnapshots: onNextButtonClick now snapshots the currently-displayed question text into questionSnapshots: Record<string, string> (question id → text) before advancing, and persists it to InProgressSurveyState in localStorage. buildSurveyResponseProperties in @posthog/core now accepts this optional param and prefers snapshot text over the current (possibly re-translated) survey question text, so $survey_questions reflects what the user actually saw when they answered. $survey_language on dismissed now similarly prefers the in-progress language over the current display-time language.

The iOS reference implementation in posthog-ios #686 follows the same pattern.

Tests

Unit tests covering:

  • Re-renders when language differs
  • Does not re-render when language is unchanged
  • No-ops when no survey is in focus
  • Does not re-render a survey that's in focus but not yet rendered (pending delay)
  • Clears _currentLanguage on dismiss
  • Removes languagechange listener on destroy()
  • questionSnapshots recorded and used in place of current question text
  • A real end-to-end render test: renders a popup via the unmocked handlePopoverSurvey, types an answer, fires a real languagechange event, and asserts both that the rendered question text updates and that the typed answer survives the re-render

Closes #4174

@turnipdabeets turnipdabeets left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reopening this and for the earlier rounds. Confirmed the diff here is identical to #4195.

A few things need a push before we can merge:

  1. Need to address merge conflict
  2. The end-to-end test you added now fails on this head. It passes in isolation but fails in the full file — main added tests to that describe and the ordering shifted.
  3. #4532 also introduced SurveyManager.dispose(), which is what posthog-surveys.ts actually calls on teardown — destroy() has no production caller anymore. Could you move the listener/subscription cleanup into dispose() so the languagechange listener and onFeatureFlags subscription don't leak on reset?
  4. All your commits need to be verified and signed to merge the PR.

I'll help run CI once the above is pushed.

// surveys.tsx). Without destroying it, listeners from earlier tests' instances stay
// attached and fire on later tests' window.dispatchEvent(new Event('languagechange')),
// hitting a stale mockPostHog whose mocks may no longer exist.
surveyManager.destroy()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed with a run on this head: jest src/__tests__/extensions/surveys.test.ts fails with TypeError: this._posthog.get_property is not a function inside _onLanguageChange, while the same test passes on its own via -t "updates the rendered question text". It passed at the pre-merge head (92/92), so main's newer tests shifted the ordering. This afterEach only destroys whatever is in surveyManager at that moment — the nested describes reassign it (new SurveyManager(mockPH)) and orphan the outer instance, whose languagechange listener stays attached. I think we need to track every instance and destroy each one here.

this._unsubscribeFeatureFlags = posthog.onFeatureFlags(() => this._onLanguageChange())
}

public destroy(): void {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

destroy() has no production caller — teardown goes through dispose(), which posthog-surveys.ts calls as this._surveyManager?.dispose?.(). dispose() landed in #4532 after this branch was written, so as it stands the languagechange listener and the onFeatureFlags subscription both survive a posthog.surveys.dispose(). I think we need to move this cleanup into dispose().

}

private _onLanguageChange(): void {
if (isNull(this._surveyInFocus) || !this._surveyIsRendered) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] _handleWidget never calls _addSurveyToFocus, so _surveyInFocus stays null for feedback_button surveys and this returns on the first line — and the render below builds a SurveyPopup rather than a FeedbackWidget. #4174 mentions widgets too; is popover-only the intended scope here?

'@posthog/core': patch
---

fix(surveys): re-translate displayed surveys when the user's language changes mid-session — handles both browser `languagechange` events and `posthog.identify()` calls that update the `language` person property. Also snapshots each question's displayed text and language at answer time (`questionSnapshots`), so `$survey_questions` and `$survey_language` reflect what the user actually saw rather than the language active when the event was sent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] "displayed surveys" reads as every survey type, but widgets aren't covered (see the _surveyInFocus gate). Could we name popovers explicitly so the changelog doesn't over-promise?

Suggested change
fix(surveys): re-translate displayed surveys when the user's language changes mid-session — handles both browser `languagechange` events and `posthog.identify()` calls that update the `language` person property. Also snapshots each question's displayed text and language at answer time (`questionSnapshots`), so `$survey_questions` and `$survey_language` reflect what the user actually saw rather than the language active when the event was sent.
Re-translate popover surveys when the display language changes while the survey is on screen, either from a browser `languagechange` event or from `identify()` updating the `language` person property. In-progress answers are preserved. `$survey_questions[].question` and `$survey_language` on `survey sent` / `survey dismissed` now report the text and language the user saw when they answered, not the language active when the event fired. Feedback-button (widget) surveys are unchanged.

$survey_questions: survey.questions.map((question: SurveyQuestionForResponses) => ({
id: question.id,
question: question.question,
question: (question.id && questionSnapshots?.[question.id]) ?? question.question,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] ?? only catches null/undefined, so a question with id: '' gives '' ?? question.question''. Undefined ids fall through fine. questionSnapshots?.[question.id ?? ''] ?? question.question would cover it, if empty ids are reachable.

@turnipdabeets
turnipdabeets requested a review from a team August 25, 2026 18:00
When identify() sets a new 'language' person property, or the browser fires a
languagechange event, any popover survey currently on screen stays in the old
language until dismissed. This wires up both triggers to re-detect the resolved
language and re-render in place when it differs from what was originally shown.

Closes PostHog#4174
- Use addEventListener() wrapper (fixes CI lint rule)
- Snapshot style/properties/isSurveyCompleted on initial render and restore
  them on language-change re-render, so NextToTrigger position and custom
  event properties are preserved
- Add changeset entry
- Update tests to no longer rely on renderPopover mock (now renders inline)
Prevents _onLanguageChange from rendering a survey early when it is queued
with surveyPopupDelaySeconds > 0. The survey was gaining focus before the
delay timer fired, so a concurrent languagechange event could bypass the
delay and skip the eligibility recheck.
Ensures $survey_questions[].question and $survey_language in sent/dismissed
events reflect the language the user saw when they answered, not whatever
language is active at event-fire time after a mid-session switch.

- buildSurveyResponseProperties accepts questionSnapshots (id → displayed
  question text) and prefers those over the current survey question text
- InProgressSurveyState gains questionSnapshots, persisted to localStorage
- onNextButtonClick snapshots currentQuestion.question before advancing
- dismissedSurveyEvent prefers inProgressSurvey.surveyLanguage over the
  current display prop; falls back to the prop only when no in-progress
  state exists (dismissed before answering anything)
…g#4195

- Fix prettier failures in surveys.test.ts (pnpm lint:fix)
- Regenerate terser-mangled-names.json for the 7 new private fields
  introduced by the language re-translation and answer-time snapshotting
  work (_currentLanguage, _languageChangeListener, _onLanguageChange,
  _surveyIsRendered, _surveyPopupProps, _unsubscribeFeatureFlags, and
  the pre-existing _surveyInFocus entry)
- Add questionSnapshots to the two survey-popup.test.tsx assertions
  that pin the exact sendSurveyEvent call shape
- Add @posthog/core to the changeset (questionSnapshots flows through
  buildSurveyResponseProperties there) and mention it in the description
- Replace the internal-state-only language-change test with one that
  exercises the real render path: renders a popup via the unmocked
  handlePopoverSurvey, types an answer, fires a real languagechange
  event with navigator.language changed, and asserts both that the
  rendered question text updates and that the typed answer survives
  the re-render
- Add an afterEach to destroy() the SurveyManager between tests in the
  SurveyManager describe block — without it, each test's construction
  left a 'languagechange' window listener attached, so later real
  window.dispatchEvent(new Event('languagechange')) calls (including
  the new test) hit stale instances whose mockPostHog no longer had a
  get_property mock, throwing during event dispatch
…istener leaks

Addresses review feedback from turnipdabeets on PR PostHog#4626:

- SurveyManager.destroy() had no production caller — PostHog#4532 introduced
  dispose(), which is what posthog-surveys.ts actually calls on
  teardown. Moved the languagechange listener removal and
  onFeatureFlags unsubscribe into dispose() and removed destroy().

- The real-render end-to-end test added for this PR passed in
  isolation but failed when the full file ran, because several other
  SurveyManager instances constructed elsewhere in the file were never
  disposed, leaving their 'languagechange' window listeners attached.
  Tracing it down surfaced two separate leaks:
  - Several nested describes/tests reassign the shared `surveyManager`
    variable to a new instance without disposing the previous one, so
    only the last-assigned instance ever got cleaned up. Added a
    createSurveyManager() helper that tracks every constructed
    instance so the outer afterEach disposes all of them.
  - `jest.clearAllMocks()` in the 'timeout management' describe left
    a jest.spyOn(global, 'clearTimeout') spy installed (only clearing
    its call history, not restoring the original), which went stale
    once fake timers were torn down and threw "clearTimeout is not
    defined" the next time something called clearTimeout - including
    SurveyManager.dispose(). Switched to jest.restoreAllMocks().
  - generateSurveys() (survey display logic describe) constructs a
    real SurveyManager and never disposed it either; captured the
    returned manager and disposed it in a finally block.
@nandinitiw
nandinitiw force-pushed the fix/survey-retranslate-on-language-change branch from 2babe4b to 8bf867f Compare August 25, 2026 20:33
@nandinitiw

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review! Addressed the first three:

  1. Merge conflict: rebased on latest main and resolved. Main had reworked the delayed-render path into renderAfterDelay/renderIfStillEligible helpers (for delay-resumption-across-navigations) since this diff was last synced, so _surveyIsRendered = true now lives in the new renderAfterDelay function.

  2. The end-to-end test failing in the full file: traced this down — several other SurveyManager instances constructed elsewhere in the same test file were never disposed, so their languagechange window listeners stayed attached and fired on my test's real dispatchEvent call. Found and fixed three separate leaks: (a) several nested describes reassign the shared surveyManager variable without disposing the previous instance — added a createSurveyManager() helper that tracks every constructed instance so cleanup covers all of them; (b) a jest.clearAllMocks() in the "timeout management" describe left a jest.spyOn(global, 'clearTimeout') spy installed rather than restored, which went stale once fake timers tore down — switched to jest.restoreAllMocks(); (c) generateSurveys()'s own test never disposed the manager it returns — now does, in a finally.

  3. dispose() vs destroy(): moved the languagechange listener removal and onFeatureFlags unsubscribe into dispose() and removed destroy() entirely, since it has no production caller.

  4. Signed commits: I don't have commit signing configured on this machine yet — need to sort that out on my end before these can be verified. Will follow up once that's set up.

Full survey test suite (268 tests across surveys.test.ts, extensions/surveys.test.ts, survey-popup.test.tsx, posthog-surveys.test.ts) passes, along with lint and prettier.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Re-translate displayed survey on language change

2 participants